mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
refactor(editor): Refine AI Assistant interaction panels (#36799)
This commit is contained in:
@@ -169,8 +169,8 @@ watch(
|
||||
const hasAssistantResponse = computed(() => displayedMessages.some((m) => m.role === 'assistant'));
|
||||
|
||||
// True when at least one pending confirmation should occupy the chat-input
|
||||
// slot (generic approvals + domain/web-search access). Drives the swap
|
||||
// between the input and the floating confirmation panel.
|
||||
// slot (questions, generic approvals, or domain/web-search access). Drives
|
||||
// the swap between the input and the floating confirmation panel.
|
||||
const hasFloatingConfirmation = computed(() =>
|
||||
thread.pendingConfirmations.some(isPendingItemFloating),
|
||||
);
|
||||
@@ -1165,8 +1165,8 @@ async function dismissComposerContextChip() {
|
||||
:agent-node="builder"
|
||||
/>
|
||||
</div>
|
||||
<!-- Inline confirmations (questions, plan review, text, setup,
|
||||
credential, gateway resource-decision, continue) render in
|
||||
<!-- Inline confirmations (plan review, text, setup, credential,
|
||||
gateway resource-decision, continue) render in
|
||||
the chat flow. Floating-eligible items take over the chat
|
||||
input slot below instead - see `hasFloatingConfirmation`. -->
|
||||
<InstanceAiConfirmationPanel kind="inline" />
|
||||
@@ -1197,7 +1197,7 @@ async function dismissComposerContextChip() {
|
||||
</div>
|
||||
|
||||
<!-- Floating input slot - replaced by the confirmation panel while a
|
||||
floating-eligible approval is pending. The credit banner stays
|
||||
floating interaction is pending. The credit banner stays
|
||||
anchored above the slot in both states. The leaving child is
|
||||
positioned absolutely during the cross-fade so the in-flow child
|
||||
can size the slot to its natural height. -->
|
||||
|
||||
+40
-5
@@ -92,7 +92,7 @@ vi.mock('../components/InstanceAiCredentialSetup.vue', () => ({
|
||||
}));
|
||||
vi.mock('../workflowSetup/InstanceAiWorkflowSetup.vue', () => ({
|
||||
default: {
|
||||
template: '<div />',
|
||||
template: '<div data-test-id="mock-workflow-setup" />',
|
||||
props: ['requestId', 'setupRequests', 'workflowId', 'message', 'projectId', 'credentialFlow'],
|
||||
},
|
||||
}));
|
||||
@@ -710,11 +710,46 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
||||
],
|
||||
};
|
||||
|
||||
it('renders questions only in the floating mount', () => {
|
||||
injectPendingConfirmation(thread, questionsConfirmation);
|
||||
|
||||
const floating = renderComponent({ props: { kind: 'floating' } });
|
||||
expect(floating.getByTestId('mock-questions')).toBeVisible();
|
||||
floating.unmount();
|
||||
|
||||
const inline = renderComponent({ props: { kind: 'inline' } });
|
||||
expect(inline.queryByTestId('mock-questions')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps setup metadata on the inline setup renderer', () => {
|
||||
injectPendingConfirmation(thread, {
|
||||
...questionsConfirmation,
|
||||
setupRequests: [
|
||||
{
|
||||
node: {
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
parameters: {},
|
||||
position: [0, 0],
|
||||
id: 'node-1',
|
||||
},
|
||||
isTrigger: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const inline = renderComponent({ props: { kind: 'inline' } });
|
||||
|
||||
expect(inline.getByTestId('mock-workflow-setup')).toBeVisible();
|
||||
expect(inline.queryByTestId('mock-questions')).toBeNull();
|
||||
});
|
||||
|
||||
it('includes all available options and correct option_chosen for single-select', () => {
|
||||
injectPendingConfirmation(thread, questionsConfirmation);
|
||||
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
|
||||
renderComponent({ props: { kind: 'inline' } });
|
||||
renderComponent({ props: { kind: 'floating' } });
|
||||
|
||||
const answers: QuestionAnswer[] = [
|
||||
{
|
||||
@@ -776,7 +811,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
||||
injectPendingConfirmation(thread, questionsConfirmation);
|
||||
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
|
||||
renderComponent({ props: { kind: 'inline' } });
|
||||
renderComponent({ props: { kind: 'floating' } });
|
||||
|
||||
const answers: QuestionAnswer[] = [
|
||||
{
|
||||
@@ -819,7 +854,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
||||
injectPendingConfirmation(thread, questionsConfirmation);
|
||||
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
|
||||
renderComponent({ props: { kind: 'inline' } });
|
||||
renderComponent({ props: { kind: 'floating' } });
|
||||
|
||||
const answers: QuestionAnswer[] = [
|
||||
{
|
||||
@@ -860,7 +895,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
||||
injectPendingConfirmation(thread, questionsConfirmation);
|
||||
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
|
||||
renderComponent({ props: { kind: 'inline' } });
|
||||
renderComponent({ props: { kind: 'floating' } });
|
||||
|
||||
const answers: QuestionAnswer[] = [
|
||||
{
|
||||
|
||||
+3
-3
@@ -1472,7 +1472,7 @@ describe('InstanceAiThreadView', () => {
|
||||
expect(queryByTestId('instance-ai-input-stub')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the chat input visible when only inline confirmations are pending', () => {
|
||||
it('swaps the chat input for the floating panel when questions are pending', () => {
|
||||
thread.pendingConfirmations = [
|
||||
{
|
||||
messageId: 'msg-questions',
|
||||
@@ -1496,8 +1496,8 @@ describe('InstanceAiThreadView', () => {
|
||||
|
||||
const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
expect(getByTestId('instance-ai-input-stub')).toBeTruthy();
|
||||
expect(queryByTestId('instance-ai-confirmation-panel-floating')).toBeNull();
|
||||
expect(getByTestId('instance-ai-confirmation-panel-floating')).toBeTruthy();
|
||||
expect(queryByTestId('instance-ai-input-stub')).toBeNull();
|
||||
});
|
||||
|
||||
it('connects the route thread when navigating to a known thread', async () => {
|
||||
|
||||
+1
-1
@@ -133,9 +133,9 @@ async function confirm(decision: InstanceGatewayResourceDecision) {
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
+24
-25
@@ -24,12 +24,11 @@ interface Props {
|
||||
/**
|
||||
* Where this panel is mounted. The component renders different subsets of
|
||||
* `pendingConfirmations` depending on this:
|
||||
* - `inline`: full-form confirmations rendered in the chat flow (questions,
|
||||
* plan review, text, setup, credential, gateway resource-decision,
|
||||
* continue).
|
||||
* - `floating`: single-click approvals and domain/web-search access, which
|
||||
* replace the chat input slot. Only the oldest pending item is rendered
|
||||
* at a time — no stacking.
|
||||
* - `inline`: full-form confirmations rendered in the chat flow (plan review,
|
||||
* text, setup, credential, gateway resource-decision, continue).
|
||||
* - `floating`: questions, single-click approvals, and domain/web-search
|
||||
* access, which replace the chat input slot. Only the oldest pending item
|
||||
* is rendered at a time — no stacking.
|
||||
*/
|
||||
kind: 'inline' | 'floating';
|
||||
}
|
||||
@@ -98,9 +97,8 @@ type ConfirmationChunk = FloatingChunk | StandaloneChunk;
|
||||
/**
|
||||
* Filter pending confirmations to those that belong in this panel mount.
|
||||
*
|
||||
* - `inline`: every non-floating item (questions/plan/text/setup/etc.) in
|
||||
* chronological order — these forms coexist comfortably in the chat
|
||||
* flow.
|
||||
* - `inline`: every non-floating item (plan/text/setup/etc.) in chronological
|
||||
* order — these forms coexist comfortably in the chat flow.
|
||||
* - `floating`: only the **oldest** floating item. We intentionally do not
|
||||
* stack: the floating panel replaces the chat input, and stacking would
|
||||
* shove the input far up the screen. The user must resolve the visible
|
||||
@@ -433,8 +431,21 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
||||
<template>
|
||||
<TransitionGroup name="confirmation-slide">
|
||||
<template v-for="chunk in chunks" :key="chunk.item.toolCall.confirmation.requestId">
|
||||
<!-- Structured questions replace the chat input like other floating confirmations. -->
|
||||
<InstanceAiQuestions
|
||||
v-if="
|
||||
chunk.type === 'floating' &&
|
||||
chunk.item.toolCall.confirmation.inputType === 'questions' &&
|
||||
chunk.item.toolCall.confirmation.questions
|
||||
"
|
||||
:key="'q-' + chunk.item.toolCall.confirmation.requestId"
|
||||
:questions="chunk.item.toolCall.confirmation.questions!"
|
||||
:intro-message="chunk.item.toolCall.confirmation.introMessage"
|
||||
@submit="(answers) => handleQuestionsSubmit(chunk.item.toolCall.confirmation, answers)"
|
||||
/>
|
||||
|
||||
<!-- ============ Standalone items (no approval wrapper) ============ -->
|
||||
<template v-if="chunk.type === 'standalone'">
|
||||
<template v-else-if="chunk.type === 'standalone'">
|
||||
<!-- Workflow setup -->
|
||||
<!-- Threads are project-bound: fall back to the thread's project so a
|
||||
payload without projectId never degrades to the personal project. -->
|
||||
@@ -460,18 +471,6 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
||||
:require-user-selection="chunk.item.toolCall.confirmation.requireUserSelection"
|
||||
/>
|
||||
|
||||
<!-- Structured questions -->
|
||||
<InstanceAiQuestions
|
||||
v-else-if="
|
||||
chunk.item.toolCall.confirmation.inputType === 'questions' &&
|
||||
chunk.item.toolCall.confirmation.questions
|
||||
"
|
||||
:key="'q-' + chunk.item.toolCall.confirmation.requestId"
|
||||
:questions="chunk.item.toolCall.confirmation.questions!"
|
||||
:intro-message="chunk.item.toolCall.confirmation.introMessage"
|
||||
@submit="(answers) => handleQuestionsSubmit(chunk.item.toolCall.confirmation, answers)"
|
||||
/>
|
||||
|
||||
<!-- Plan review -->
|
||||
<PlanReviewPanel
|
||||
v-else-if="chunk.item.toolCall.confirmation.inputType === 'plan-review'"
|
||||
@@ -640,10 +639,9 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
box-shadow: var(--shadow--sm);
|
||||
background-color: var(--background--surface);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.floatingRoot {
|
||||
@@ -696,8 +694,9 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
||||
}
|
||||
|
||||
.textCard {
|
||||
border: 2px solid var(--color--primary);
|
||||
border: 0;
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
+1
-1
@@ -736,9 +736,9 @@ async function handleSetupAutomatically() {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding: 0;
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
+2
-1
@@ -263,7 +263,8 @@ function openSettings(row: CardRow) {
|
||||
}
|
||||
|
||||
.awaitingInput {
|
||||
border: 2px solid var(--color--primary);
|
||||
border: 0;
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
+26
-7
@@ -414,7 +414,14 @@ function onOptionMouseEnter(idx: number) {
|
||||
>
|
||||
<span :class="$style.numberBadge">{{ idx + 1 }}</span>
|
||||
<span :class="$style.optionLabel">{{ option }}</span>
|
||||
<N8nIcon :class="$style.arrowIndicator" icon="arrow-right" :size="16" />
|
||||
<span :class="$style.arrowIndicator">
|
||||
<N8nIcon
|
||||
:class="$style.arrowIcon"
|
||||
icon="arrow-right"
|
||||
size="large"
|
||||
:stroke-width="2.5"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
@@ -585,9 +592,9 @@ function onOptionMouseEnter(idx: number) {
|
||||
|
||||
.container {
|
||||
outline: none;
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.question {
|
||||
@@ -609,8 +616,9 @@ function onOptionMouseEnter(idx: number) {
|
||||
@include questionOptions.active-selected;
|
||||
|
||||
&:hover .arrowIndicator,
|
||||
&.highlighted .arrowIndicator {
|
||||
opacity: 1;
|
||||
&.highlighted .arrowIndicator,
|
||||
&.activeSelected .arrowIndicator {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,10 +628,21 @@ function onOptionMouseEnter(idx: number) {
|
||||
|
||||
.arrowIndicator {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
visibility: hidden;
|
||||
width: var(--spacing--lg);
|
||||
height: var(--spacing--lg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius--full);
|
||||
background-color: var(--color--primary);
|
||||
color: var(--color--neutral-white);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.arrowIcon {
|
||||
width: var(--spacing--sm);
|
||||
height: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
|
||||
+4
-3
@@ -276,10 +276,11 @@ function handleDeny() {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
// Highlight that the plan is waiting for the user's review; read-only /
|
||||
// resolved / building cards keep the regular border.
|
||||
// Elevate the plan while it is waiting for review; read-only / resolved /
|
||||
// building cards keep the regular border.
|
||||
.awaitingInput {
|
||||
border: 2px solid var(--color--primary);
|
||||
border: 0;
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.expiredHint {
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { PendingConfirmationItem } from './instanceAi.store';
|
||||
* Decides whether a pending confirmation belongs in the floating slot (takes
|
||||
* over the chat input) or the inline list (renders in the chat flow).
|
||||
*
|
||||
* Floating: single-click approvals + domain/web-search access.
|
||||
* Inline: questions, plan-review, text, setup, credential, gateway
|
||||
* resource-decision, continue, channel setup.
|
||||
* Floating: structured questions, single-click approvals, and domain/web-search access.
|
||||
* Inline: plan-review, text, setup, credential, gateway resource-decision,
|
||||
* continue, channel setup.
|
||||
*
|
||||
* Items are inline-by-presence: if `setupRequests` / `credentialRequests` /
|
||||
* `credentialFlow` / `channelConfig` is set, the panel renders a setup or
|
||||
@@ -22,12 +22,12 @@ export function isPendingItemFloating(item: PendingConfirmationItem): boolean {
|
||||
if (conf.channelConfig) return false;
|
||||
|
||||
switch (conf.inputType) {
|
||||
case 'questions':
|
||||
case 'plan-review':
|
||||
case 'text':
|
||||
case 'resource-decision':
|
||||
case 'continue':
|
||||
return false;
|
||||
case 'questions':
|
||||
case 'approval':
|
||||
case undefined:
|
||||
return true;
|
||||
|
||||
+1
-1
@@ -102,9 +102,9 @@ const displayName = computed(() => {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding-top: var(--spacing--sm);
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
+1
-1
@@ -118,9 +118,9 @@ function getSectionNodeType(section: WorkflowSetupSection) {
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -168,10 +168,10 @@ const title = computed<{ key: string; text: string }>(() => {
|
||||
@use '@n8n/design-system/css/mixins/motion';
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: var(--spacing--4xs) 0;
|
||||
|
||||
@@ -362,11 +362,9 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding-top: var(--spacing--sm);
|
||||
/* Waiting-for-input highlight (#33959) — ported from InstanceAiChannelSetup
|
||||
when the card body moved here, so both surfaces get it. */
|
||||
border: 2px solid var(--color--primary);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--background--surface);
|
||||
box-shadow: var(--shadow--sm), var(--shadow--outline);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
Reference in New Issue
Block a user