refactor(editor): Refine AI Assistant interaction panels (#36799)

This commit is contained in:
Tuukka Kantola
2026-08-25 10:54:42 +00:00
committed by GitHub
parent ad15ba4458
commit 8db12ccf0a
14 changed files with 115 additions and 62 deletions
@@ -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. -->
@@ -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[] = [
{
@@ -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 () => {
@@ -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,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>
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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;
@@ -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 {
@@ -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 {