feat(editor): Redesign instance AI approval dialogs (no-changelog) (#30654)

This commit is contained in:
Raúl Gómez Morales 2026-05-20 20:26:28 +02:00 committed by GitHub
parent e98809f9bd
commit 41c876ea68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1173 additions and 307 deletions

View File

@ -409,7 +409,7 @@ describe('credentials tool', () => {
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
requestId: expect.any(String),
message: 'Delete credential "My Cred"? This cannot be undone.',
message: 'Delete My Cred',
severity: 'destructive',
}),
);
@ -432,7 +432,7 @@ describe('credentials tool', () => {
expect(suspendFn).toHaveBeenCalledTimes(1);
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({
message: 'Delete credential "cred-99"? This cannot be undone.',
message: 'Delete cred-99',
}),
);
});

View File

@ -264,7 +264,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Create data table "Contacts"?',
message: 'Create Contacts',
severity: 'info',
}),
);
@ -297,7 +297,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Create data table "Contacts" in project "My Project"?',
message: 'Create Contacts in project My Project',
}),
);
});
@ -399,14 +399,31 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message:
'Delete data table "dt-1"? This will permanently remove the table and all its data.',
message: 'Delete dt-1',
severity: 'destructive',
}),
);
expect(context.dataTableService.delete).not.toHaveBeenCalled();
});
it('should include the table name in the suspend message when provided', async () => {
const context = createMockContext({ permissions: {} });
const suspendFn = jest.fn();
const tool = createDataTablesTool(context);
await executeTool(
tool,
{ ...deleteInput, dataTableName: 'Customer data' } as never,
suspendCtx(suspendFn),
);
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Delete Customer data (ID: dt-1)',
}),
);
});
it('should execute immediately when permission is always_allow', async () => {
const context = createMockContext({ permissions: { deleteDataTable: 'always_allow' } });
@ -472,7 +489,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Add column "age" (number) to data table "dt-1"?',
message: 'Add age (number) to dt-1',
severity: 'warning',
}),
);
@ -547,8 +564,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message:
'Delete column "col-1" from data table "dt-1"? All data in this column will be permanently lost.',
message: 'Delete col-1 from dt-1',
severity: 'destructive',
}),
);
@ -620,7 +636,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Rename column "col-1" to "full_name" in data table "dt-1"?',
message: 'Rename col-1 to full_name in dt-1',
severity: 'warning',
}),
);
@ -697,7 +713,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Insert 2 row(s) into data table "dt-1"?',
message: 'Insert 2 row(s) into dt-1',
severity: 'warning',
}),
);
@ -798,7 +814,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Update rows in data table "dt-1"?',
message: 'Update rows in dt-1',
severity: 'warning',
}),
);
@ -880,7 +896,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Delete rows where status eq inactive? This cannot be undone.',
message: 'Delete rows from dt-1 where status eq inactive',
severity: 'destructive',
}),
);
@ -909,7 +925,7 @@ describe('data-tables tool', () => {
expect(suspendFn).toHaveBeenCalled();
expect(suspendFn.mock.calls[0]![0]).toEqual(
expect.objectContaining({
message: 'Delete rows where status eq inactive or age lt 18? This cannot be undone.',
message: 'Delete rows from dt-1 where status eq inactive or age lt 18',
}),
);
});

View File

@ -167,7 +167,7 @@ describe('executions tool', () => {
const suspendPayload = suspendFn.mock.calls[0][0] as Record<string, unknown>;
expect(suspendPayload).toEqual(
expect.objectContaining({
message: 'Execute workflow "My Workflow" (ID: wf-1)?',
message: 'Execute My Workflow (ID: wf-1)',
severity: 'warning',
requestId: expect.any(String),
}),
@ -190,7 +190,7 @@ describe('executions tool', () => {
const suspendPayload = suspendFn.mock.calls[0][0] as Record<string, unknown>;
expect(suspendPayload).toEqual(
expect.objectContaining({
message: 'Execute workflow "wf-42" (ID: wf-42)?',
message: 'Execute wf-42 (ID: wf-42)',
}),
);
});

View File

@ -278,7 +278,7 @@ describe('workflows tool', () => {
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Update workflow version "1" — set name to "v1", description to (cleared)?',
message: 'Update version 1 — set name to "v1", description to (cleared)',
severity: 'info',
}),
);
@ -490,7 +490,7 @@ describe('workflows tool', () => {
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: expect.stringContaining('"wf1"'),
message: expect.stringContaining('wf1'),
});
});
@ -563,7 +563,6 @@ describe('workflows tool', () => {
message: expect.stringContaining('Archived WF'),
severity: 'warning',
});
expect(suspend.mock.calls[0][0].message).toContain('will not publish it');
});
it('should return the suspension result when approval is pending', async () => {
@ -768,7 +767,7 @@ describe('workflows tool', () => {
expect(context.workflowService.get).toHaveBeenCalledWith('wf1');
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: 'Publish workflow "My WF" (ID: wf1)?',
message: 'Publish My WF (ID: wf1)',
severity: 'warning',
});
});
@ -799,7 +798,7 @@ describe('workflows tool', () => {
} as never);
expect(suspend.mock.calls[0][0]).toMatchObject({
message: 'Publish workflow "My WF" (ID: wf1) and 1 referenced supporting workflow(s)?',
message: 'Publish My WF (ID: wf1) and 1 referenced supporting workflow(s)',
severity: 'warning',
});
});
@ -959,7 +958,7 @@ describe('workflows tool', () => {
expect(context.workflowService.get).toHaveBeenCalledWith('wf1');
expect(suspend).toHaveBeenCalled();
expect(suspend.mock.calls[0][0]).toMatchObject({
message: 'Unpublish workflow "My WF" (ID: wf1)?',
message: 'Unpublish My WF (ID: wf1)',
severity: 'warning',
});
});

View File

@ -304,7 +304,7 @@ async function handleDelete(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Delete credential "${input.credentialName ?? input.credentialId}"? This cannot be undone.`,
message: `Delete ${input.credentialName ?? input.credentialId}`,
severity: 'destructive' as const,
});
}

View File

@ -77,6 +77,16 @@ function isNameConflictError(error: unknown): boolean {
const projectIdDescribe =
'Project ID. For list/create, scopes the operation to this project (defaults to personal). For id-based actions (schema, query, delete, add-column, delete-column, rename-column, insert/update/delete-rows), disambiguates when `dataTableId` is a name that exists in multiple accessible projects. Ignored when `dataTableId` is a UUID; rejected when the UUID belongs to a different project.';
const dataTableNameDescribe =
'Human-readable name of the data table, shown alongside the ID in the approval card. Pass this whenever you know it (e.g. from a prior `list` call) so users see a recognisable label instead of a bare UUID.';
/** Renders `"{name} (ID: {id})"` when the agent supplied a name, otherwise the bare id. */
function buildDataTableLabel(input: { dataTableId: string; dataTableName?: string }): string {
return input.dataTableName
? `${input.dataTableName} (ID: ${input.dataTableId})`
: input.dataTableId;
}
const listAction = z.object({
action: z.literal('list').describe('List data tables in a project'),
projectId: z.string().optional().describe(projectIdDescribe),
@ -133,6 +143,7 @@ const deleteAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
});
@ -143,6 +154,7 @@ const addColumnAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
columnName: z.string().describe('Column name (alphanumeric + underscores)'),
type: columnTypeSchema.describe('Column data type'),
@ -155,6 +167,7 @@ const deleteColumnAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
columnId: z.string().describe('ID of the column'),
});
@ -166,6 +179,7 @@ const renameColumnAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
columnId: z.string().describe('ID of the column'),
newName: z.string().describe('New column name'),
@ -178,6 +192,7 @@ const insertRowsAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
rows: z
.array(z.record(z.unknown()))
@ -193,6 +208,7 @@ const updateRowsAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
filter: filterSchema.describe('Row filter conditions'),
data: z.record(z.unknown()).describe('Column values to set on matching rows'),
@ -209,6 +225,7 @@ const deleteRowsAction = z.object({
.describe(
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
),
dataTableName: z.string().optional().describe(dataTableNameDescribe),
projectId: z.string().optional().describe(projectIdDescribe),
filter: filterSchemaWithMinOne.describe('Row filter conditions'),
});
@ -291,11 +308,11 @@ async function handleCreate(
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
let message = `Create data table "${input.name}"?`;
let message = `Create ${input.name}`;
if (input.projectId) {
const project = await context.workspaceService?.getProject?.(input.projectId);
const projectLabel = project?.name ?? input.projectId;
message = `Create data table "${input.name}" in project "${projectLabel}"?`;
message = `Create ${input.name} in project ${projectLabel}`;
}
return await ctx.suspend({
requestId: nanoid(),
@ -345,7 +362,7 @@ async function handleDelete(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Delete data table "${input.dataTableId}"? This will permanently remove the table and all its data.`,
message: `Delete ${buildDataTableLabel(input)}`,
severity: 'destructive' as const,
});
}
@ -377,7 +394,7 @@ async function handleAddColumn(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Add column "${input.columnName}" (${input.type}) to data table "${input.dataTableId}"?`,
message: `Add ${input.columnName} (${input.type}) to ${buildDataTableLabel(input)}`,
severity: 'warning' as const,
});
}
@ -413,7 +430,7 @@ async function handleDeleteColumn(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Delete column "${input.columnId}" from data table "${input.dataTableId}"? All data in this column will be permanently lost.`,
message: `Delete ${input.columnId} from ${buildDataTableLabel(input)}`,
severity: 'destructive' as const,
});
}
@ -447,7 +464,7 @@ async function handleRenameColumn(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Rename column "${input.columnId}" to "${input.newName}" in data table "${input.dataTableId}"?`,
message: `Rename ${input.columnId} to ${input.newName} in ${buildDataTableLabel(input)}`,
severity: 'warning' as const,
});
}
@ -481,7 +498,7 @@ async function handleInsertRows(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Insert ${input.rows.length} row(s) into data table "${input.dataTableId}"?`,
message: `Insert ${input.rows.length} row(s) into ${buildDataTableLabel(input)}`,
severity: 'warning' as const,
});
}
@ -514,7 +531,7 @@ async function handleUpdateRows(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Update rows in data table "${input.dataTableId}"?`,
message: `Update rows in ${buildDataTableLabel(input)}`,
severity: 'warning' as const,
});
}
@ -556,7 +573,7 @@ async function handleDeleteRows(
.join(` ${input.filter.type} `);
return await ctx.suspend({
requestId: nanoid(),
message: `Delete rows where ${filterDesc}? This cannot be undone.`,
message: `Delete rows from ${buildDataTableLabel(input)} where ${filterDesc}`,
severity: 'destructive' as const,
});
}

View File

@ -157,7 +157,7 @@ async function handleRun(
.catch(() => input.workflowId);
return await suspend({
requestId: nanoid(),
message: `Execute workflow "${workflowName}" (ID: ${input.workflowId})?`,
message: `Execute ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning' as const,
});
}

View File

@ -1135,8 +1135,7 @@ describe('createBuildWorkflowAgentTool — existing workflow approval', () => {
expect(out).toBeUndefined();
expect(suspend).toHaveBeenCalledWith(
expect.objectContaining({
message:
'Edit existing workflow "Existing Workflow" (ID: WF_EXISTING)? Reason: Swap Slack channel on this notifier.',
message: 'Edit Existing Workflow (ID: WF_EXISTING)',
severity: 'warning',
}),
);

View File

@ -1737,10 +1737,9 @@ export function createBuildWorkflowAgentTool(context: OrchestrationContext) {
context,
input.workflowId,
);
const reason = input.reason?.trim();
return await ctx.suspend({
requestId: nanoid(),
message: `Edit existing workflow "${workflowName}" (ID: ${input.workflowId})?${reason ? ` Reason: ${reason}` : ''}`,
message: `Edit ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning',
});
}

View File

@ -406,7 +406,7 @@ async function handleDelete(
const workflowName = await resolveWorkflowName(context, input.workflowId);
return await ctx.suspend({
requestId: nanoid(),
message: `Archive workflow "${workflowName}" (ID: ${input.workflowId})? This will deactivate it if needed and can be undone later.`,
message: `Archive ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning' as const,
});
}
@ -437,7 +437,7 @@ async function handleUnarchive(
const workflowName = await resolveWorkflowName(context, input.workflowId);
return await ctx.suspend({
requestId: nanoid(),
message: `Restore archived workflow "${workflowName}" (ID: ${input.workflowId})? This will make it visible again but will not publish it.`,
message: `Restore ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning' as const,
});
}
@ -733,8 +733,8 @@ async function handlePublish(
return await ctx.suspend({
requestId: nanoid(),
message: input.versionId
? `Publish version "${input.versionId}" of workflow "${workflowName}" (ID: ${input.workflowId})${dependencyNote}?`
: `Publish workflow "${workflowName}" (ID: ${input.workflowId})${dependencyNote}?`,
? `Publish version ${input.versionId} of ${workflowName} (ID: ${input.workflowId})${dependencyNote}`
: `Publish ${workflowName} (ID: ${input.workflowId})${dependencyNote}`,
severity: 'warning' as const,
});
}
@ -879,7 +879,7 @@ async function handleUnpublish(
const workflowName = await resolveWorkflowName(context, input.workflowId);
return await ctx.suspend({
requestId: nanoid(),
message: `Unpublish workflow "${workflowName}" (ID: ${input.workflowId})?`,
message: `Unpublish ${workflowName} (ID: ${input.workflowId})`,
severity: 'warning' as const,
});
}
@ -942,7 +942,7 @@ async function handleRestoreVersion(
return await ctx.suspend({
requestId: nanoid(),
message: `Restore workflow to version ${versionLabel}? This will overwrite the current draft.`,
message: `Restore to version ${versionLabel}`,
severity: 'warning' as const,
});
}
@ -987,7 +987,7 @@ async function handleUpdateVersion(
return await ctx.suspend({
requestId: nanoid(),
message: `Update workflow version "${input.versionId}" — set ${summary}?`,
message: `Update version ${input.versionId} — set ${summary}`,
severity: 'info' as const,
});
}

View File

@ -167,7 +167,7 @@ async function handleTagWorkflow(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Tag workflow "${input.workflowName ?? input.workflowId}" (ID: ${input.workflowId}) with [${input.tags.join(', ')}]?`,
message: `Tag ${input.workflowName ?? input.workflowId} (ID: ${input.workflowId}) with [${input.tags.join(', ')}]`,
severity: 'info' as const,
});
}
@ -200,7 +200,7 @@ async function handleCleanupTestExecutions(
const hours = input.olderThanHours ?? 1;
return await ctx.suspend({
requestId: nanoid(),
message: `Delete test executions for workflow "${input.workflowName ?? input.workflowId}" older than ${hours} hour(s)?`,
message: `Delete executions for ${input.workflowName ?? input.workflowId} older than ${hours} hour(s)`,
severity: 'warning' as const,
});
}
@ -248,7 +248,7 @@ async function handleCreateFolder(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Create folder "${input.name}" in project "${input.projectId}"?`,
message: `Create ${input.name} in project ${input.projectId}`,
severity: 'info' as const,
});
}
@ -288,12 +288,9 @@ async function handleDeleteFolder(
// State 1: First call — suspend for confirmation (unless always_allow)
if (needsApproval && (resumeData === undefined || resumeData === null)) {
const transferNote = input.transferToFolderId
? ` Contents will be moved to folder "${input.transferToFolderName ?? input.transferToFolderId}".`
: ' Contents will be flattened to project root and archived.';
return await ctx.suspend({
requestId: nanoid(),
message: `Delete folder "${input.folderName ?? input.folderId}"?${transferNote}`,
message: `Delete ${input.folderName ?? input.folderId}`,
severity: 'destructive' as const,
});
}
@ -329,7 +326,7 @@ async function handleMoveWorkflowToFolder(
if (needsApproval && (resumeData === undefined || resumeData === null)) {
return await ctx.suspend({
requestId: nanoid(),
message: `Move workflow "${input.workflowName ?? input.workflowId}" to folder "${input.folderName ?? input.folderId}"?`,
message: `Move ${input.workflowName ?? input.workflowId} to folder ${input.folderName ?? input.folderId}`,
severity: 'info' as const,
});
}

View File

@ -5526,9 +5526,12 @@
"instanceAi.delegateCard.delegatingTo": "Delegating to",
"instanceAi.delegateCard.briefing": "Briefing",
"instanceAi.delegateCard.tools": "Tools",
"instanceAi.confirmation.approve": "Approve",
"instanceAi.confirmation.approve": "Allow once",
"instanceAi.confirmation.alwaysAllow": "Always allow",
"instanceAi.confirmation.alwaysAllowSuffix": "during this session",
"instanceAi.confirmation.allowPrompt": "Allow AI Assistant to {action}?",
"instanceAi.confirmation.deny": "Deny",
"instanceAi.confirmation.approved": "Approved",
"instanceAi.confirmation.approved": "Allowed",
"instanceAi.confirmation.denied": "Denied",
"instanceAi.confirmation.deferred": "Skipped",
"instanceAi.confirmation.pendingInline": "Waiting for approval",
@ -5537,18 +5540,18 @@
"instanceAi.confirmation.continue": "Continue",
"instanceAi.gatewayConfirmation.allowForSession": "Allow for session",
"instanceAi.gatewayConfirmation.allowOnce": "Allow once",
"instanceAi.gatewayConfirmation.denyOnce": "Deny once",
"instanceAi.gatewayConfirmation.prompt": "AI Assistant wants to access '{resources}' on Computer Use",
"instanceAi.gatewayConfirmation.denyOnce": "Deny",
"instanceAi.gatewayConfirmation.prompt": "Allow AI Assistant to access '{resources}' on Computer Use?",
"instanceAi.askUser.placeholder": "Type your answer...",
"instanceAi.askUser.submit": "Submit",
"instanceAi.askUser.skip": "Skip",
"instanceAi.domainAccess.prompt": "AI Assistant wants to access {domain}",
"instanceAi.domainAccess.prompt": "Allow AI Assistant to access {domain}?",
"instanceAi.domainAccess.allowOnce": "Allow once",
"instanceAi.domainAccess.allowDomain": "Always allow {domain}",
"instanceAi.domainAccess.allowAll": "Allow all domains",
"instanceAi.domainAccess.deny": "Deny",
"instanceAi.webSearch.prompt": "AI Assistant wants to search the web",
"instanceAi.webSearch.allowThread": "Always allow for this thread",
"instanceAi.webSearch.allowThread": "Always allow",
"instanceAi.settings.permissions.fetchUrl.label": "Fetch URLs without approval",
"instanceAi.credential.selected": "Credential selected",
"instanceAi.credential.required": "This action requires a credential",
@ -5698,7 +5701,7 @@
"instanceAi.planReview.description": "Review the plan, then approve it to start building or request changes to revise it.",
"instanceAi.planReview.feedbackPlaceholder": "Describe what should change before execution starts.",
"instanceAi.planReview.requestChanges": "Request changes",
"instanceAi.planReview.approve": "Approve",
"instanceAi.planReview.approve": "Allow",
"instanceAi.planReview.approved": "Plan approved",
"instanceAi.message.retry": "Retry",
"instanceAi.input.amendPlaceholder": "Amend the {role} agent...",
@ -5738,17 +5741,24 @@
"instanceAi.tools.workflows.get": "Reading workflow",
"instanceAi.tools.workflows.get-as-code": "Reading workflow code",
"instanceAi.tools.workflows.delete": "Archiving workflow",
"instanceAi.tools.workflows.delete.imperative": "archive workflow",
"instanceAi.tools.workflows.unarchive.imperative": "restore archived workflow",
"instanceAi.tools.workflows.setup": "Setting up workflow",
"instanceAi.tools.workflows.publish": "Publishing workflow",
"instanceAi.tools.workflows.publish.imperative": "publish workflow",
"instanceAi.tools.workflows.unpublish": "Unpublishing workflow",
"instanceAi.tools.workflows.unpublish.imperative": "unpublish workflow",
"instanceAi.tools.workflows.list-versions": "Listing workflow versions",
"instanceAi.tools.workflows.get-version": "Reading workflow version",
"instanceAi.tools.workflows.update-version": "Updating workflow version",
"instanceAi.tools.workflows.update-version.imperative": "update workflow version",
"instanceAi.tools.workflows.restore-version": "Restoring workflow version",
"instanceAi.tools.workflows.restore-version.imperative": "restore workflow version",
"instanceAi.tools.executions": "Executions",
"instanceAi.tools.executions.list": "Checking executions",
"instanceAi.tools.executions.get": "Getting execution result",
"instanceAi.tools.executions.run": "Running workflow",
"instanceAi.tools.executions.run.imperative": "execute workflow",
"instanceAi.tools.executions.debug": "Debugging execution",
"instanceAi.tools.executions.get-node-output": "Reading node output",
"instanceAi.tools.executions.stop": "Stopping execution",
@ -5756,6 +5766,7 @@
"instanceAi.tools.credentials.list": "Checking credentials",
"instanceAi.tools.credentials.get": "Reading credential",
"instanceAi.tools.credentials.delete": "Deleting credential",
"instanceAi.tools.credentials.delete.imperative": "delete credential",
"instanceAi.tools.credentials.search-types": "Searching credential types",
"instanceAi.tools.credentials.setup": "Setting up credentials",
"instanceAi.tools.credentials.test": "Testing credential",
@ -5764,22 +5775,35 @@
"instanceAi.tools.data-tables.schema": "Reading table schema",
"instanceAi.tools.data-tables.query": "Querying data",
"instanceAi.tools.data-tables.create": "Creating data table",
"instanceAi.tools.data-tables.create.imperative": "create data table",
"instanceAi.tools.data-tables.delete": "Deleting data table",
"instanceAi.tools.data-tables.delete.imperative": "delete data table",
"instanceAi.tools.data-tables.add-column": "Adding table column",
"instanceAi.tools.data-tables.add-column.imperative": "add column",
"instanceAi.tools.data-tables.delete-column": "Removing table column",
"instanceAi.tools.data-tables.delete-column.imperative": "delete column",
"instanceAi.tools.data-tables.rename-column": "Renaming table column",
"instanceAi.tools.data-tables.rename-column.imperative": "rename column",
"instanceAi.tools.data-tables.insert-rows": "Inserting rows",
"instanceAi.tools.data-tables.insert-rows.imperative": "insert rows",
"instanceAi.tools.data-tables.update-rows": "Updating rows",
"instanceAi.tools.data-tables.update-rows.imperative": "update rows",
"instanceAi.tools.data-tables.delete-rows": "Deleting rows",
"instanceAi.tools.data-tables.delete-rows.imperative": "delete rows",
"instanceAi.tools.workspace": "Workspace",
"instanceAi.tools.workspace.list-projects": "Listing projects",
"instanceAi.tools.workspace.list-tags": "Listing tags",
"instanceAi.tools.workspace.tag-workflow": "Tagging workflow",
"instanceAi.tools.workspace.tag-workflow.imperative": "tag workflow",
"instanceAi.tools.workspace.cleanup-test-executions": "Cleaning up test executions",
"instanceAi.tools.workspace.cleanup-test-executions.imperative": "delete test executions",
"instanceAi.tools.workspace.list-folders": "Listing folders",
"instanceAi.tools.workspace.create-folder": "Creating folder",
"instanceAi.tools.workspace.create-folder.imperative": "create folder",
"instanceAi.tools.workspace.delete-folder": "Deleting folder",
"instanceAi.tools.workspace.delete-folder.imperative": "delete folder",
"instanceAi.tools.workspace.move-workflow-to-folder": "Moving workflow to folder",
"instanceAi.tools.workspace.move-workflow-to-folder.imperative": "move workflow",
"instanceAi.tools.research": "Research",
"instanceAi.tools.research.web-search": "Searching the web",
"instanceAi.tools.research.fetch-url": "Fetching page",
@ -5845,6 +5869,7 @@
"instanceAi.tools.fetch-url": "Fetching page",
"instanceAi.tools.research-with-agent": "Researching",
"instanceAi.tools.build-workflow-with-agent": "Building workflow",
"instanceAi.tools.build-workflow-with-agent.imperative": "edit workflow",
"instanceAi.tools.manage-data-tables-with-agent": "Managing data tables",
"instanceAi.tools.browser-credential-setup": "Setting up credentials in browser",
"instanceAi.tools.get-best-practices": "Loading best practices",

View File

@ -27,6 +27,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { COLLAPSED_MAIN_SIDEBAR_WIDTH, useSidebarLayout } from '@/app/composables/useSidebarLayout';
import { provideThread, useInstanceAiStore } from './instanceAi.store';
import { isPendingItemFloating } from './confirmationKinds';
import { useCanvasPreview } from './useCanvasPreview';
import { useEventRelay } from './useEventRelay';
import { useExecutionPushEvents } from './useExecutionPushEvents';
@ -79,6 +80,13 @@ const builderAgents = computed(() => collectActiveBuilderAgents(thread.messages)
// otherwise leave an empty wrapper in the list filter them out.
const displayedMessages = computed(() => thread.messages.filter(messageHasVisibleContent));
// 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.
const hasFloatingConfirmation = computed(() =>
thread.pendingConfirmations.some(isPendingItemFloating),
);
// --- Execution tracking via push events ---
const executionTracking = useExecutionPushEvents();
const fixWithAiOffer = useFixWithAiOffer();
@ -631,7 +639,11 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
:agent-node="builder"
/>
</div>
<InstanceAiConfirmationPanel />
<!-- Inline confirmations (questions, 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" />
<Transition name="confirmation-slide">
<InstanceAiFixWithAiPanel
v-if="activeFixWithAiOffer"
@ -664,7 +676,12 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
</Transition>
</div>
<!-- Floating input -->
<!-- Floating input replaced by the confirmation panel while a
floating-eligible approval is pending. StatusBar and credit
banner stay 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. -->
<div ref="inputContainer" :class="$style.inputContainer">
<div :class="$style.inputConstraint">
<InstanceAiStatusBar />
@ -675,17 +692,28 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
@dismiss="creditBanner.dismiss()"
/>
<InstanceAiInput
ref="chatInputRef"
:is-streaming="thread.isStreaming"
:is-submitting="thread.isSendingMessage"
:is-awaiting-confirmation="thread.isAwaitingConfirmation"
:current-thread-id="thread.id"
:amend-context="thread.amendContext"
:contextual-suggestion="thread.contextualSuggestion"
@submit="handleSubmit"
@stop="handleStop"
/>
<div :class="$style.inputSwap">
<Transition name="input-swap">
<InstanceAiConfirmationPanel
v-if="hasFloatingConfirmation"
key="floating-confirmation"
kind="floating"
/>
<InstanceAiInput
v-else
ref="chatInputRef"
key="chat-input"
:is-streaming="thread.isStreaming"
:is-submitting="thread.isSendingMessage"
:is-awaiting-confirmation="thread.isAwaitingConfirmation"
:current-thread-id="thread.id"
:amend-context="thread.amendContext"
:contextual-suggestion="thread.contextualSuggestion"
@submit="handleSubmit"
@stop="handleStop"
/>
</Transition>
</div>
</div>
</div>
</div>
@ -1031,6 +1059,13 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
}
}
// The leaving child is detached from layout (see `.input-swap-leave-active`
// below) so the slot follows the entering child's intrinsic height during
// the cross-fade.
.inputSwap {
position: relative;
}
.previewPanel {
display: flex;
flex-direction: column;
@ -1197,4 +1232,23 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
.artifacts-panel-preview-leave-active {
pointer-events: none;
}
// Cross-fade between the chat input and the floating confirmation panel.
// Default-mode cross-fade: both children co-exist briefly, the leaving one
// is absolute-positioned so it doesn't push the entering one down, and the
// slot sizes to the in-flow (entering) child.
.input-swap-enter-from,
.input-swap-leave-to {
opacity: 0;
}
.input-swap-enter-active,
.input-swap-leave-active {
transition: opacity 120ms ease;
}
.input-swap-leave-active {
position: absolute;
inset: 0;
}
</style>

View File

@ -0,0 +1,68 @@
import { render, fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import ApprovalOptionList, {
type ApprovalOption,
} from '@/features/ai/instanceAi/components/ApprovalOptionList.vue';
const OPTIONS: ApprovalOption[] = [
{ key: 'always-allow', icon: 'check', label: 'Always allow', testId: 'opt-always-allow' },
{ key: 'allow-once', icon: 'check', label: 'Allow once', testId: 'opt-allow-once' },
{ key: 'deny', icon: 'ban', label: 'Deny', withArrow: false, testId: 'opt-deny' },
];
describe('ApprovalOptionList', () => {
it('pre-selects the first option on mount', () => {
const { getByTestId } = render(ApprovalOptionList, { props: { options: OPTIONS } });
expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('true');
expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('false');
});
it('moves highlight on ArrowDown and stops at the last option', async () => {
const { getByTestId, getByRole } = render(ApprovalOptionList, {
props: { options: OPTIONS },
});
const listbox = getByRole('listbox');
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('true');
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true');
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true');
});
it('moves highlight on ArrowUp and stops at the first option', async () => {
const { getByTestId, getByRole } = render(ApprovalOptionList, {
props: { options: OPTIONS },
});
const listbox = getByRole('listbox');
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await fireEvent.keyDown(listbox, { key: 'ArrowUp' });
expect(getByTestId('opt-allow-once').getAttribute('aria-selected')).toBe('true');
await fireEvent.keyDown(listbox, { key: 'ArrowUp' });
await fireEvent.keyDown(listbox, { key: 'ArrowUp' });
expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('true');
});
it('hovering a row moves the highlight to that row', async () => {
const { getByTestId } = render(ApprovalOptionList, { props: { options: OPTIONS } });
await fireEvent.mouseEnter(getByTestId('opt-deny'));
expect(getByTestId('opt-deny').getAttribute('aria-selected')).toBe('true');
expect(getByTestId('opt-always-allow').getAttribute('aria-selected')).toBe('false');
});
it('emits select with the highlighted option on Enter', async () => {
const { getByRole, emitted } = render(ApprovalOptionList, { props: { options: OPTIONS } });
const listbox = getByRole('listbox');
await fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await fireEvent.keyDown(listbox, { key: 'Enter' });
expect(emitted('select')).toEqual([['allow-once']]);
});
it('emits select on click', async () => {
const { getByTestId, emitted } = render(ApprovalOptionList, { props: { options: OPTIONS } });
await userEvent.click(getByTestId('opt-deny'));
expect(emitted('select')).toEqual([['deny']]);
});
});

View File

@ -167,7 +167,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent();
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-approve'));
expect(mockTelemetryTrack).toHaveBeenCalledWith(
@ -179,7 +179,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
provided_inputs: [
{
label: 'Run this workflow?',
options: ['approve', 'deny'],
options: ['approve', 'deny', 'approve_always'],
option_chosen: 'approve',
},
],
@ -197,7 +197,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent();
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-approve'));
expect(confirmSpy).toHaveBeenCalledWith('req-explicit-approval', {
@ -214,7 +214,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent();
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-deny'));
expect(mockTelemetryTrack).toHaveBeenCalledWith(
@ -224,13 +224,147 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
provided_inputs: [
{
label: 'Delete this file?',
options: ['approve', 'deny'],
options: ['approve', 'deny', 'approve_always'],
option_chosen: 'deny',
},
],
}),
);
});
it('tracks always-allow click and records the key on the runtime', async () => {
injectPendingConfirmation(
thread,
{
requestId: 'req-always',
severity: 'info',
message: 'Run this workflow?',
},
{ action: 'run' },
);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const addKeySpy = vi.spyOn(thread, 'addAlwaysAllowKey');
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-always-allow'));
expect(addKeySpy).toHaveBeenCalledWith('test-tool', { action: 'run' });
expect(mockTelemetryTrack).toHaveBeenCalledWith(
'User finished providing input',
expect.objectContaining({
type: 'approval',
provided_inputs: [
{
label: 'Run this workflow?',
options: ['approve', 'deny', 'approve_always'],
option_chosen: 'approve_always',
},
],
}),
);
});
it('does not grant the session-allow key when always-allow POST fails', async () => {
injectPendingConfirmation(
thread,
{
requestId: 'req-always-fail',
severity: 'info',
message: 'Run this workflow?',
},
{ action: 'run' },
);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(false);
const addKeySpy = vi.spyOn(thread, 'addAlwaysAllowKey');
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-always-allow'));
expect(addKeySpy).not.toHaveBeenCalled();
expect(resolveSpy).not.toHaveBeenCalled();
expect(mockTelemetryTrack).not.toHaveBeenCalled();
});
it('does not resolve the confirmation when an approve POST fails', async () => {
injectPendingConfirmation(thread, {
requestId: 'req-approve-fail',
severity: 'info',
message: 'Run this workflow?',
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(false);
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
await userEvent.click(getByTestId('instance-ai-panel-confirm-approve'));
expect(resolveSpy).not.toHaveBeenCalled();
expect(mockTelemetryTrack).not.toHaveBeenCalled();
});
it('ignores repeated clicks while the confirm POST is in flight', async () => {
injectPendingConfirmation(thread, {
requestId: 'req-double-click',
severity: 'info',
message: 'Run this workflow?',
});
let resolvePost: (value: boolean) => void = () => {};
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockReturnValue(
new Promise<boolean>((resolve) => {
resolvePost = resolve;
}),
);
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
const approveButton = getByTestId('instance-ai-panel-confirm-approve');
await userEvent.click(approveButton);
await userEvent.click(approveButton);
await userEvent.click(approveButton);
expect(confirmSpy).toHaveBeenCalledTimes(1);
resolvePost(true);
});
it('hides always-allow on destructive confirmations and narrows the option set', async () => {
injectPendingConfirmation(thread, {
requestId: 'req-destructive',
severity: 'destructive',
message: 'Delete workflow?',
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId, queryByTestId } = renderComponent({ props: { kind: 'floating' } });
expect(queryByTestId('instance-ai-panel-confirm-always-allow')).toBeNull();
await userEvent.click(getByTestId('instance-ai-panel-confirm-approve'));
expect(mockTelemetryTrack).toHaveBeenCalledWith(
'User finished providing input',
expect.objectContaining({
provided_inputs: [
{
label: 'Delete workflow?',
options: ['approve', 'deny'],
option_chosen: 'approve',
},
],
}),
);
});
it('renders nothing when mounted as inline for a floating-eligible confirmation', () => {
injectPendingConfirmation(thread, {
requestId: 'req-inline-skip',
severity: 'info',
message: 'Run this workflow?',
});
const { queryByTestId } = renderComponent({ props: { kind: 'inline' } });
expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull();
expect(queryByTestId('instance-ai-confirmation-panel')).toBeNull();
});
});
describe('text input confirmation', () => {
@ -243,7 +377,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { container } = renderComponent();
const { container } = renderComponent({ props: { kind: 'inline' } });
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
await userEvent.type(input, 'My Workflow');
await userEvent.keyboard('{Enter}');
@ -275,7 +409,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { container } = renderComponent();
const { container } = renderComponent({ props: { kind: 'inline' } });
// Find the skip button (shown when input is empty)
const buttons = container.querySelectorAll('button');
const skipBtn = Array.from(buttons).find(
@ -312,7 +446,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId, queryByTestId } = renderComponent();
const { getByTestId, queryByTestId } = renderComponent({ props: { kind: 'inline' } });
expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull();
expect(queryByTestId('instance-ai-panel-confirm-deny')).toBeNull();
@ -371,7 +505,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
injectPendingConfirmation(thread, questionsConfirmation);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
renderComponent();
renderComponent({ props: { kind: 'inline' } });
const answers: QuestionAnswer[] = [
{
@ -433,7 +567,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
injectPendingConfirmation(thread, questionsConfirmation);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
renderComponent();
renderComponent({ props: { kind: 'inline' } });
const answers: QuestionAnswer[] = [
{
@ -476,7 +610,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
injectPendingConfirmation(thread, questionsConfirmation);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
renderComponent();
renderComponent({ props: { kind: 'inline' } });
const answers: QuestionAnswer[] = [
{
@ -517,7 +651,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
injectPendingConfirmation(thread, questionsConfirmation);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
renderComponent();
renderComponent({ props: { kind: 'inline' } });
const answers: QuestionAnswer[] = [
{

View File

@ -54,6 +54,16 @@ const InstanceAiInputStub = defineComponent({
},
});
const InstanceAiConfirmationPanelStub = defineComponent({
name: 'InstanceAiConfirmationPanelStub',
props: {
kind: { type: String, required: true },
},
setup(props) {
return () => h('div', { 'data-test-id': `instance-ai-confirmation-panel-${props.kind}` });
},
});
const renderView = createComponentRenderer(InstanceAiThreadView, {
global: {
provide: {
@ -61,6 +71,7 @@ const renderView = createComponentRenderer(InstanceAiThreadView, {
},
stubs: {
InstanceAiInput: InstanceAiInputStub,
InstanceAiConfirmationPanel: InstanceAiConfirmationPanelStub,
},
},
});
@ -153,6 +164,65 @@ describe('InstanceAiThreadView', () => {
expect(thread.connectSSE).toHaveBeenCalledWith();
});
it('keeps the chat input visible when no floating-eligible confirmation is pending', () => {
const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
expect(getByTestId('instance-ai-input-stub')).toBeTruthy();
expect(queryByTestId('instance-ai-confirmation-panel-floating')).toBeNull();
// Inline mount is always present so non-floating forms can render.
expect(getByTestId('instance-ai-confirmation-panel-inline')).toBeTruthy();
});
it('swaps the chat input for the floating panel when a generic approval is pending', () => {
thread.pendingConfirmations = [
{
messageId: 'msg-floating',
agentNode: { agentId: 'agent-1', role: 'orchestrator' },
toolCall: {
toolCallId: 'tc-1',
toolName: 'workflows',
args: { action: 'run' },
isLoading: true,
confirmationStatus: 'pending',
confirmation: { requestId: 'req-1', severity: 'info', message: 'Run?' },
},
},
] as unknown as ThreadRuntime['pendingConfirmations'];
const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
expect(getByTestId('instance-ai-confirmation-panel-floating')).toBeTruthy();
expect(queryByTestId('instance-ai-input-stub')).toBeNull();
});
it('keeps the chat input visible when only inline confirmations are pending', () => {
thread.pendingConfirmations = [
{
messageId: 'msg-questions',
agentNode: { agentId: 'agent-1', role: 'orchestrator' },
toolCall: {
toolCallId: 'tc-q',
toolName: 'ask-user',
args: {},
isLoading: true,
confirmationStatus: 'pending',
confirmation: {
requestId: 'req-q',
severity: 'info',
message: 'Pick',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick?', type: 'single', options: ['a'] }],
},
},
},
] as unknown as ThreadRuntime['pendingConfirmations'];
const { getByTestId, queryByTestId } = renderView({ props: { threadId: 'thread-1' } });
expect(getByTestId('instance-ai-input-stub')).toBeTruthy();
expect(queryByTestId('instance-ai-confirmation-panel-floating')).toBeNull();
});
it('connects the route thread when navigating to a known thread', async () => {
thread.sseState = 'disconnected';
store.threads = [

View File

@ -967,3 +967,152 @@ describe('createThreadRuntime - gateway resource-decision confirmation', () => {
expect(mockPostConfirmation).toHaveBeenCalledOnce();
});
});
describe('createThreadRuntime - session always-allow', () => {
let registry: RuntimeRegistry;
beforeEach(() => {
setupRuntimePinia();
registry = createRuntimeRegistry();
activeThreadId = 'thread-always-allow';
mockPostConfirmation.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
function pushPendingApproval(
runtime: ThreadRuntime,
opts: {
messageId: string;
requestId: string;
toolName: string;
args?: Record<string, unknown>;
severity?: 'info' | 'warning' | 'destructive';
},
): void {
runtime.messages.push({
id: opts.messageId,
role: 'assistant',
createdAt: new Date().toISOString(),
content: '',
reasoning: '',
isStreaming: false,
agentTree: {
agentId: 'agent-1',
role: 'orchestrator',
status: 'active',
textContent: '',
reasoning: '',
timeline: [],
children: [],
toolCalls: [
{
toolCallId: `tc-${opts.requestId}`,
toolName: opts.toolName,
args: opts.args ?? {},
isLoading: true,
confirmationStatus: 'pending',
confirmation: {
requestId: opts.requestId,
severity: opts.severity ?? 'info',
message: 'Approve?',
},
},
],
},
});
}
it('auto-approves matching generic-eligible confirmations after key is added', async () => {
const runtime = registry.getOrCreateRuntime(activeThreadId);
runtime.addAlwaysAllowKey('workflows', { action: 'run' });
pushPendingApproval(runtime, {
messageId: 'msg-auto',
requestId: 'req-auto',
toolName: 'workflows',
args: { action: 'run' },
});
await vi.waitFor(() => {
expect(runtime.resolvedConfirmationIds.get('req-auto')).toBe('approved');
});
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-auto', {
kind: 'approval',
approved: true,
});
});
it('does not auto-approve destructive confirmations even when the key matches', async () => {
const runtime = registry.getOrCreateRuntime(activeThreadId);
runtime.addAlwaysAllowKey('workflows', { action: 'delete' });
pushPendingApproval(runtime, {
messageId: 'msg-destructive',
requestId: 'req-destructive',
toolName: 'workflows',
args: { action: 'delete' },
severity: 'destructive',
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(runtime.resolvedConfirmationIds.has('req-destructive')).toBe(false);
expect(mockPostConfirmation).not.toHaveBeenCalled();
});
it('distinguishes submit-workflow create vs update grants by workflowId presence', async () => {
const runtime = registry.getOrCreateRuntime(activeThreadId);
runtime.addAlwaysAllowKey('submit-workflow', {});
pushPendingApproval(runtime, {
messageId: 'msg-create',
requestId: 'req-create',
toolName: 'submit-workflow',
args: {},
});
await vi.waitFor(() => {
expect(runtime.resolvedConfirmationIds.get('req-create')).toBe('approved');
});
pushPendingApproval(runtime, {
messageId: 'msg-update',
requestId: 'req-update',
toolName: 'submit-workflow',
args: { workflowId: 'wf-1' },
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(runtime.resolvedConfirmationIds.has('req-update')).toBe(false);
});
it('clears keys on resetState', () => {
const runtime = registry.getOrCreateRuntime(activeThreadId);
runtime.addAlwaysAllowKey('workflows', { action: 'run' });
expect(runtime.sessionAlwaysAllowKeys.size).toBe(1);
runtime.resetState();
expect(runtime.sessionAlwaysAllowKeys.size).toBe(0);
});
it('keeps the confirmation pending when auto-approve POST fails', async () => {
mockPostConfirmation.mockRejectedValueOnce(new Error('network down'));
const runtime = registry.getOrCreateRuntime(activeThreadId);
runtime.addAlwaysAllowKey('workflows', { action: 'run' });
pushPendingApproval(runtime, {
messageId: 'msg-fail',
requestId: 'req-fail',
toolName: 'workflows',
args: { action: 'run' },
});
await vi.waitFor(() => {
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-fail', {
kind: 'approval',
approved: true,
});
});
expect(runtime.resolvedConfirmationIds.has('req-fail')).toBe(false);
});
});

View File

@ -0,0 +1,183 @@
<script lang="ts" setup>
import { N8nIcon, type IconName } from '@n8n/design-system';
import { onMounted, ref, useTemplateRef, watch } from 'vue';
import ConfirmationFooter from './ConfirmationFooter.vue';
export interface ApprovalOption {
/** Stable identifier emitted on click. */
key: string;
/** Leading icon — typically `check` for allow rows, `ban` for deny. */
icon: IconName;
/** Primary (bold-eligible) label text. */
label: string;
/** Optional muted text rendered after the label. */
suffix?: string;
/** Mark this row as the destructive choice — picks up the red highlight. */
destructive?: boolean;
/** Show the trailing arrow indicator on the highlighted row. Defaults to `true`. */
withArrow?: boolean;
/** `data-test-id` for the row button. */
testId?: string;
}
const props = defineProps<{ options: ApprovalOption[] }>();
const emit = defineEmits<{ select: [key: string] }>();
// Keyboard model: first option is pre-selected; Up/Down move the highlight;
// Enter submits the highlighted option; hover also drives the highlight so
// the mouse and keyboard agree on what's active.
const containerRef = useTemplateRef<HTMLElement>('container');
const highlightedIndex = ref(0);
watch(
() => props.options.length,
(length) => {
if (highlightedIndex.value >= length) highlightedIndex.value = Math.max(0, length - 1);
},
);
onMounted(() => {
containerRef.value?.focus();
});
function onKeydown(event: KeyboardEvent) {
if (props.options.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
highlightedIndex.value = Math.min(props.options.length - 1, highlightedIndex.value + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
highlightedIndex.value = Math.max(0, highlightedIndex.value - 1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const option = props.options[highlightedIndex.value];
if (option) emit('select', option.key);
}
}
</script>
<template>
<ConfirmationFooter layout="column">
<div
ref="container"
:class="$style.list"
role="listbox"
tabindex="0"
:aria-activedescendant="
options[highlightedIndex] ? `approval-option-${options[highlightedIndex].key}` : undefined
"
@keydown="onKeydown"
>
<button
v-for="(option, idx) in options"
:id="`approval-option-${option.key}`"
:key="option.key"
type="button"
role="option"
:aria-selected="highlightedIndex === idx"
:class="[
$style.row,
highlightedIndex === idx && $style.highlighted,
option.destructive && $style.rowDestructive,
]"
:data-test-id="option.testId"
tabindex="-1"
@click="emit('select', option.key)"
@mouseenter="highlightedIndex = idx"
>
<N8nIcon :class="$style.leadingIcon" :icon="option.icon" size="small" />
<span :class="$style.label">
<span :class="option.suffix ? $style.labelStrong : undefined">{{ option.label }}</span>
<span v-if="option.suffix" :class="$style.labelMuted">{{ option.suffix }}</span>
</span>
<N8nIcon
v-if="option.withArrow !== false"
:class="$style.trailingIcon"
icon="arrow-right"
size="small"
/>
</button>
</div>
</ConfirmationFooter>
</template>
<style lang="scss" module>
.list {
display: flex;
flex-direction: column;
outline: none;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
padding: var(--spacing--3xs) var(--spacing--2xs);
border: none;
border-radius: var(--radius--lg);
background: none;
cursor: pointer;
text-align: left;
font-size: var(--font-size--sm);
color: var(--color--text);
transition: background-color 0.15s ease;
}
// Highlight: applied when the row is the current selection (keyboard or
// mouse). Unifies the visual for hover and arrow-key states so the user
// always sees one and only one active row.
.highlighted {
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
.trailingIcon {
opacity: 1;
}
}
// Destructive variant only changes the highlight colour, so the cost of
// confirming becomes obvious the moment the user lands on the row.
.rowDestructive.highlighted {
background-color: var(--callout--color--background--danger);
color: var(--callout--color--text--danger);
.leadingIcon {
color: var(--callout--color--text--danger);
}
}
.leadingIcon {
flex-shrink: 0;
color: var(--color--text--tint-1);
}
.label {
flex: 1;
display: flex;
align-items: baseline;
gap: var(--spacing--3xs);
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
}
.labelStrong {
font-weight: var(--font-weight--bold);
}
.labelMuted {
color: var(--color--text--tint-1);
font-weight: var(--font-weight--regular);
}
.trailingIcon {
margin-left: auto;
opacity: 0;
color: var(--color--text--tint-1);
flex-shrink: 0;
transition: opacity 0.15s ease;
}
</style>

View File

@ -11,7 +11,6 @@
color: var(--color--text);
word-break: break-all;
padding: var(--spacing--2xs);
background: light-dark(var(--color--background), var(--color--neutral-850));
border-radius: var(--radius);
border: var(--border);
}

View File

@ -1,12 +1,10 @@
<script lang="ts" setup>
import { N8nButton, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { useThread } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue';
import ApprovalOptionList, { type ApprovalOption } from './ApprovalOptionList.vue';
import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue';
type DomainAction = 'allow_once' | 'allow_domain' | 'allow_all';
@ -53,13 +51,35 @@ const persistentLabel = computed(() =>
}),
);
const primaryAction: DomainAction = 'allow_once';
const primaryLabel = computed(() => i18n.baseText('instanceAi.domainAccess.allowOnce'));
const dropdownItems = computed<Array<ActionDropdownItem<DomainAction>>>(() => [
{ id: 'allow_domain' as const, label: persistentLabel.value },
]);
// Mirrors the floating-approval layout: persistent option first, single-use
// allow next, deny last. Destructive hides the persistent row by design.
const options = computed<ApprovalOption[]>(() => {
const list: ApprovalOption[] = [];
if (!isDestructive.value) {
list.push({
key: 'allow_domain',
icon: 'check',
label: persistentLabel.value,
suffix: i18n.baseText('instanceAi.confirmation.alwaysAllowSuffix'),
testId: 'domain-access-allow-domain',
});
}
list.push({
key: 'allow_once',
icon: 'check',
label: i18n.baseText('instanceAi.domainAccess.allowOnce'),
destructive: isDestructive.value,
testId: 'domain-access-allow-once',
});
list.push({
key: 'deny',
icon: 'ban',
label: i18n.baseText('instanceAi.domainAccess.deny'),
withArrow: false,
testId: 'domain-access-deny',
});
return list;
});
function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
resolved.value = true;
@ -72,22 +92,14 @@ function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
);
}
function onPrimaryClick() {
handleAction(true, primaryAction);
}
const DOMAIN_ACTIONS: readonly DomainAction[] = [
'allow_once',
'allow_domain',
'allow_all',
] as const;
function isDomainAction(value: string): value is DomainAction {
return (DOMAIN_ACTIONS as readonly string[]).includes(value);
}
function onDropdownSelect(action: string) {
if (isDomainAction(action)) handleAction(true, action);
function onSelect(key: string) {
if (key === 'deny') {
handleAction(false);
return;
}
if (key === 'allow_once' || key === 'allow_domain' || key === 'allow_all') {
handleAction(true, key);
}
}
</script>
@ -100,25 +112,7 @@ function onDropdownSelect(action: string) {
<ConfirmationPreview>{{ previewText }}</ConfirmationPreview>
</div>
<ConfirmationFooter>
<N8nButton
variant="outline"
size="medium"
:label="i18n.baseText('instanceAi.domainAccess.deny')"
data-test-id="domain-access-deny"
@click="handleAction(false)"
/>
<SplitButton
:variant="isDestructive ? 'destructive' : 'solid'"
:label="primaryLabel"
:items="dropdownItems"
data-test-id="domain-access-primary"
dropdown-test-id="domain-access-dropdown"
caret-aria-label="More approval options"
@click="onPrimaryClick"
@select="onDropdownSelect"
/>
</ConfirmationFooter>
<ApprovalOptionList :options="options" @select="onSelect" />
</div>
</template>

View File

@ -1,13 +1,14 @@
<script lang="ts" setup>
import { N8nButton, N8nCard, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import type { InstanceAiConfirmation } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useThread, type PendingConfirmationItem } from '../instanceAi.store';
import { isPendingItemFloating } from '../confirmationKinds';
import { useToolLabel } from '../toolLabels';
import ConfirmationFooter from './ConfirmationFooter.vue';
import ApprovalOptionList, { type ApprovalOption } from './ApprovalOptionList.vue';
import DomainAccessApproval from './DomainAccessApproval.vue';
import GatewayResourceDecision from './GatewayResourceDecision.vue';
import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue';
@ -17,6 +18,22 @@ import InstanceAiWorkflowSetup from '../workflowSetup/InstanceAiWorkflowSetup.vu
import ConfirmationPreview from './ConfirmationPreview.vue';
import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue';
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.
*/
kind: 'inline' | 'floating';
}
const props = defineProps<Props>();
const thread = useThread();
const i18n = useI18n();
const rootStore = useRootStore();
@ -59,108 +76,204 @@ function trackInputCompleted(
telemetry.track('User finished providing input', eventProps);
}
const ROLE_LABELS: Record<string, string> = {
orchestrator: 'Agent',
'workflow-builder': 'Workflow Builder',
'data-table-manager': 'Data Table Manager',
researcher: 'Researcher',
};
function getRoleLabel(role: string): string {
return ROLE_LABELS[role] ?? role;
}
interface ApprovalWrappedGroup {
type: 'approvalWrapped';
agentId: string;
role: string;
items: PendingConfirmationItem[];
}
interface StandaloneChunk {
type: 'standalone';
item: PendingConfirmationItem;
}
type ConfirmationChunk = ApprovalWrappedGroup | StandaloneChunk;
/** Items that need the "Agent needs approval" wrapper (generic approvals, domain access, web search). */
function isApprovalWrapped(item: PendingConfirmationItem): boolean {
const conf = item.toolCall.confirmation;
if (conf.domainAccess) return true;
if (conf.webSearch) return true;
// Generic approval: no special fields and no structured input UI
if (
!conf.credentialRequests?.length &&
!conf.setupRequests?.length &&
(!conf.inputType || conf.inputType === 'approval') &&
!conf.questions
) {
return true;
}
return false;
interface FloatingChunk {
type: 'floating';
item: PendingConfirmationItem;
}
/** Split confirmations into standalone items and approval-wrapped groups. */
const chunks = computed((): ConfirmationChunk[] => {
const result: ConfirmationChunk[] = [];
const wrappedByAgent = new Map<string, ApprovalWrappedGroup>();
type ConfirmationChunk = FloatingChunk | StandaloneChunk;
for (const item of thread.pendingConfirmations) {
if (isApprovalWrapped(item)) {
const key = item.agentNode.agentId;
let group = wrappedByAgent.get(key);
if (!group) {
group = { type: 'approvalWrapped', agentId: key, role: item.agentNode.role, items: [] };
wrappedByAgent.set(key, group);
}
group.items.push(item);
} else {
/**
* 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.
* - `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
* card before the next one appears.
*/
const chunks = computed((): ConfirmationChunk[] => {
if (props.kind === 'inline') {
const result: ConfirmationChunk[] = [];
for (const item of thread.pendingConfirmations) {
if (isPendingItemFloating(item)) continue;
result.push({ type: 'standalone', item });
}
return result;
}
for (const group of wrappedByAgent.values()) {
result.push(group);
for (const item of thread.pendingConfirmations) {
if (!isPendingItemFloating(item)) continue;
return [{ type: 'floating', item }];
}
return result;
return [];
});
function isDestructive(item: PendingConfirmationItem): boolean {
return item.toolCall.confirmation.severity === 'destructive';
}
/**
* Title for the floating approval. We resolve a short imperative phrase
* (e.g. "archive workflow") via i18n keyed by the tool name and optional
* action `instanceAi.tools.{tool}.{action}.imperative`. When that key
* exists we render the unified "Allow AI Assistant to {action}?" prompt;
* otherwise we fall back to the tool's display label. Doing the lookup on
* the frontend keeps the action phrase translatable without sending
* English strings over the wire.
*/
function buildApprovalTitle(item: PendingConfirmationItem): string {
const { toolName, args } = item.toolCall;
const action = typeof args?.action === 'string' ? args.action : undefined;
const imperativeKey = (
action
? `instanceAi.tools.${toolName}.${action}.imperative`
: `instanceAi.tools.${toolName}.imperative`
) as BaseTextKey;
const phrase = i18n.baseText(imperativeKey);
if (phrase !== imperativeKey) {
return i18n.baseText('instanceAi.confirmation.allowPrompt', {
interpolate: { action: phrase },
});
}
return getToolLabel(toolName, args);
}
/**
* Subtitle for the floating approval. Tools send a short resource line;
* we still defensively trim at the first `?` so any legacy tool whose
* message includes a trailing explanation doesn't bloat the card.
*/
function buildApprovalSubtitle(item: PendingConfirmationItem): string {
const message = item.toolCall.confirmation.message ?? '';
const idx = message.indexOf('?');
return idx === -1 ? message : message.slice(0, idx + 1);
}
/**
* Build the floating-approval option list. Destructive confirmations hide
* "Always allow" by design, irreversible actions must be opted into one
* at a time.
*/
function buildApprovalOptions(item: PendingConfirmationItem): ApprovalOption[] {
const destructive = isDestructive(item);
const options: ApprovalOption[] = [];
if (!destructive) {
options.push({
key: 'always-allow',
icon: 'check',
label: i18n.baseText('instanceAi.confirmation.alwaysAllow'),
suffix: i18n.baseText('instanceAi.confirmation.alwaysAllowSuffix'),
testId: 'instance-ai-panel-confirm-always-allow',
});
}
options.push({
key: 'allow-once',
icon: 'check',
label: i18n.baseText('instanceAi.confirmation.approve'),
destructive,
testId: 'instance-ai-panel-confirm-approve',
});
options.push({
key: 'deny',
icon: 'ban',
label: i18n.baseText('instanceAi.confirmation.deny'),
withArrow: false,
testId: 'instance-ai-panel-confirm-deny',
});
return options;
}
function handleApprovalSelect(item: PendingConfirmationItem, key: string) {
switch (key) {
case 'always-allow':
void handleAlwaysAllow(item);
return;
case 'allow-once':
void handleConfirm(item, true);
return;
case 'deny':
void handleConfirm(item, false);
}
}
// Text input state per requestId
const textInputValues = ref<Record<string, string>>({});
function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
// In-flight guard so a double-click or repeated Enter while the first POST
// is still pending doesn't fire a second request for the same requestId.
// `resolvedConfirmationIds` is only updated *after* the await, so we need
// our own synchronous lock for the window in between.
const inFlightConfirmations = new Set<string>();
async function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
const conf = item.toolCall.confirmation;
if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
trackInputCompleted(
conf,
[
{
label: conf.message,
options: ['approve', 'deny'],
option_chosen: approved ? 'approve' : 'deny',
},
],
[],
);
thread.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
void thread.confirmAction(conf.requestId, { kind: 'approval', approved });
}
function handleApproveAll(items: PendingConfirmationItem[]) {
for (const item of items) {
const conf = item.toolCall.confirmation;
if (thread.resolvedConfirmationIds.has(conf.requestId)) continue;
if (inFlightConfirmations.has(conf.requestId)) return;
inFlightConfirmations.add(conf.requestId);
try {
// Await the POST first so a network failure leaves the card visible and
// the backend's wait state intact matches the auto-approve watcher
// behaviour. `confirmAction` already surfaces a toast on failure.
const ok = await thread.confirmAction(conf.requestId, { kind: 'approval', approved });
if (!ok) return;
// "Always allow" is offered alongside Approve/Deny for non-destructive
// generic approvals; include it in the option set so telemetry reflects
// what the user actually chose between.
const alwaysAllowAvailable = !isDestructive(item);
trackInputCompleted(
conf,
[{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }],
[
{
label: conf.message,
options: alwaysAllowAvailable
? ['approve', 'deny', 'approve_always']
: ['approve', 'deny'],
option_chosen: approved ? 'approve' : 'deny',
},
],
[],
);
thread.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
} finally {
inFlightConfirmations.delete(conf.requestId);
}
}
async function handleAlwaysAllow(item: PendingConfirmationItem) {
const conf = item.toolCall.confirmation;
if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
if (inFlightConfirmations.has(conf.requestId)) return;
inFlightConfirmations.add(conf.requestId);
try {
// Confirm with the backend before granting the session-allow key a
// failed POST would otherwise hide the card while the backend keeps
// waiting, AND seed an auto-approve key the watcher would use to
// silently approve later matching confirmations.
const ok = await thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
if (!ok) return;
thread.addAlwaysAllowKey(item.toolCall.toolName, item.toolCall.args ?? {});
trackInputCompleted(
conf,
[
{
label: conf.message,
options: ['approve', 'deny', 'approve_always'],
option_chosen: 'approve_always',
},
],
[],
);
thread.resolveConfirmation(conf.requestId, 'approved');
void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
} finally {
inFlightConfirmations.delete(conf.requestId);
}
}
@ -276,25 +389,11 @@ function handlePlanRequestChanges(
userInput: feedback,
});
}
/** True when every item in the group is a generic approval (not domain/web-search/cred/text). */
function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
return items.every(
(item) => !item.toolCall.confirmation.domainAccess && !item.toolCall.confirmation.webSearch,
);
}
</script>
<template>
<TransitionGroup name="confirmation-slide">
<template
v-for="chunk in chunks"
:key="
chunk.type === 'approvalWrapped'
? 'group-' + chunk.agentId
: chunk.item.toolCall.confirmation.requestId
"
>
<template v-for="chunk in chunks" :key="chunk.item.toolCall.confirmation.requestId">
<!-- ============ Standalone items (no approval wrapper) ============ -->
<template v-if="chunk.type === 'standalone'">
<!-- Workflow setup -->
@ -434,56 +533,30 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
/>
</template>
<!-- ============ Approval-wrapped group ============ -->
<!-- ============ Floating approval ============ -->
<div
v-else
:key="'group-' + chunk.agentId"
:class="[$style.root, $style.confirmation]"
:key="'floating-' + chunk.item.toolCall.confirmation.requestId"
:class="[$style.root, $style.floatingRoot]"
data-test-id="instance-ai-confirmation-panel"
>
<!-- Group header -->
<template v-if="isAllGenericApproval(chunk.items) && chunk.items.length > 1">
<div :class="$style.generic">
<N8nText>
{{
i18n.baseText('instanceAi.confirmation.agentContext', {
interpolate: { agent: getRoleLabel(chunk.role) },
})
}}
</N8nText>
<N8nButton
data-test-id="instance-ai-panel-confirm-approve-all"
size="medium"
variant="subtle"
@click="handleApproveAll(chunk.items)"
>
{{ i18n.baseText('instanceAi.confirmation.approveAll') }}
</N8nButton>
</div>
</template>
<!-- Items -->
<div :class="$style.items">
<div
v-for="item in chunk.items"
:key="item.toolCall.confirmation.requestId"
:class="[$style.item, chunk.items.length > 1 ? $style.itemBordered : '']"
>
<div :class="$style.item">
<!-- Domain access -->
<DomainAccessApproval
v-if="item.toolCall.confirmation.domainAccess"
:request-id="item.toolCall.confirmation.requestId"
:url="item.toolCall.confirmation.domainAccess!.url"
:host="item.toolCall.confirmation.domainAccess!.host"
:severity="item.toolCall.confirmation.severity"
v-if="chunk.item.toolCall.confirmation.domainAccess"
:request-id="chunk.item.toolCall.confirmation.requestId"
:url="chunk.item.toolCall.confirmation.domainAccess!.url"
:host="chunk.item.toolCall.confirmation.domainAccess!.host"
:severity="chunk.item.toolCall.confirmation.severity"
/>
<!-- Web search -->
<DomainAccessApproval
v-else-if="item.toolCall.confirmation.webSearch"
:request-id="item.toolCall.confirmation.requestId"
:query="item.toolCall.confirmation.webSearch!.query"
:severity="item.toolCall.confirmation.severity"
v-else-if="chunk.item.toolCall.confirmation.webSearch"
:request-id="chunk.item.toolCall.confirmation.requestId"
:query="chunk.item.toolCall.confirmation.webSearch!.query"
:severity="chunk.item.toolCall.confirmation.severity"
/>
<!-- Generic approval -->
@ -491,35 +564,15 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
<div :class="$style.approvalRow">
<div :class="$style.approvalRowBody">
<N8nText size="medium" bold>
{{ getToolLabel(item.toolCall.toolName, item.toolCall.args) }}
{{ buildApprovalTitle(chunk.item) }}
</N8nText>
<ConfirmationPreview>{{
item.toolCall.confirmation!.message
}}</ConfirmationPreview>
<ConfirmationPreview>{{ buildApprovalSubtitle(chunk.item) }}</ConfirmationPreview>
</div>
<ConfirmationFooter>
<N8nButton
data-test-id="instance-ai-panel-confirm-deny"
size="medium"
variant="outline"
@click="handleConfirm(item, false)"
>
{{ i18n.baseText('instanceAi.confirmation.deny') }}
</N8nButton>
<N8nButton
:variant="
item.toolCall.confirmation.severity === 'destructive'
? 'destructive'
: 'solid'
"
data-test-id="instance-ai-panel-confirm-approve"
size="medium"
@click="handleConfirm(item, true)"
>
{{ i18n.baseText('instanceAi.confirmation.approve') }}
</N8nButton>
</ConfirmationFooter>
<ApprovalOptionList
:options="buildApprovalOptions(chunk.item)"
@select="(key) => handleApprovalSelect(chunk.item, key)"
/>
</div>
</div>
</div>
@ -541,6 +594,13 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
background-color: var(--color--background--light-3);
}
.floatingRoot {
// Fills the input-slot constraint width; no 90% reduction the inline
// `.confirmation` class applies inside the message list.
width: 100%;
max-width: none;
}
.items {
display: flex;
flex-direction: column;
@ -552,10 +612,6 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
}
}
.itemBordered {
// Only applies when there are multiple items visual grouping
}
.approvalRow {
display: flex;
flex-direction: column;
@ -583,14 +639,6 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
margin-top: var(--spacing--2xs);
}
.generic {
padding: var(--spacing--sm);
border-bottom: var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.textCard {
background-color: var(--color--background--light-3);
}

View File

@ -0,0 +1,34 @@
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.
*
* Items are inline-by-presence: if `setupRequests` / `credentialRequests` /
* `credentialFlow` is set, the panel renders a setup or credential card
* regardless of `inputType`. Otherwise `inputType` drives the choice; an
* absent or `'approval'` `inputType` falls through to floating.
*/
export function isPendingItemFloating(item: PendingConfirmationItem): boolean {
const conf = item.toolCall.confirmation;
if (conf.setupRequests?.length) return false;
if (conf.credentialRequests?.length) return false;
if (conf.credentialFlow) return false;
switch (conf.inputType) {
case 'questions':
case 'plan-review':
case 'text':
case 'resource-decision':
case 'continue':
return false;
case 'approval':
case undefined:
return true;
}
}

View File

@ -1,4 +1,4 @@
import { computed, reactive, ref, triggerRef } from 'vue';
import { computed, reactive, ref, triggerRef, watch } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { ResponseError } from '@n8n/rest-api-client';
import {
@ -338,6 +338,86 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
return undefined;
}
// --- Session "Always allow" ---
// Thread-scoped: cleared by `resetState()` so grants don't leak when the
// runtime is disposed and recreated. Key: `${toolName}:${args.action ?? ''}`
// for most tools; `submit-workflow` is keyed on `workflowId` presence so a
// create grant doesn't silently auto-approve later updates (the backend
// distinguishes createWorkflow vs updateWorkflow by that field).
const sessionAlwaysAllowKeys = ref<Set<string>>(new Set());
function buildAlwaysAllowKey(toolName: string, args: Record<string, unknown>): string {
if (toolName === 'submit-workflow') {
const isUpdate = typeof args.workflowId === 'string' && args.workflowId.length > 0;
return `submit-workflow:${isUpdate ? 'update' : 'create'}`;
}
const action = typeof args.action === 'string' ? args.action : '';
return `${toolName}:${action}`;
}
function addAlwaysAllowKey(toolName: string, args: Record<string, unknown>): void {
const next = new Set(sessionAlwaysAllowKeys.value);
next.add(buildAlwaysAllowKey(toolName, args));
sessionAlwaysAllowKeys.value = next;
}
function isGenericApprovalEligible(item: PendingConfirmationItem): boolean {
const conf = item.toolCall.confirmation;
if (conf.severity === 'destructive') return false;
if (conf.domainAccess) return false;
if (conf.inputType) return false;
if (conf.setupRequests?.length) return false;
if (conf.credentialRequests?.length) return false;
if (conf.questions?.length) return false;
return true;
}
// In-flight guard for the auto-approve watcher. We can't rely on
// `resolvedConfirmationIds` to skip duplicates here because we only mark
// resolved *after* `confirmAction` succeeds — otherwise a failed request
// would hide the card while the backend still waits for approval.
const autoApproveInFlight = new Set<string>();
watch(
pendingConfirmations,
async (items) => {
if (sessionAlwaysAllowKeys.value.size === 0) return;
for (const item of items) {
const conf = item.toolCall.confirmation;
if (resolvedConfirmationIds.value.has(conf.requestId)) continue;
if (autoApproveInFlight.has(conf.requestId)) continue;
if (!isGenericApprovalEligible(item)) continue;
const key = buildAlwaysAllowKey(item.toolCall.toolName, item.toolCall.args ?? {});
if (!sessionAlwaysAllowKeys.value.has(key)) continue;
autoApproveInFlight.add(conf.requestId);
try {
const ok = await confirmAction(conf.requestId, { kind: 'approval', approved: true });
if (!ok) continue;
resolveConfirmation(conf.requestId, 'approved');
telemetry.track('User finished providing input', {
thread_id: threadId,
input_thread_id: conf.inputThreadId ?? '',
instance_id: rootStore.instanceId,
type: 'approval',
provided_inputs: [
{
label: conf.message,
options: ['approve', 'deny', 'approve_always'],
option_chosen: 'approve_auto',
},
],
skipped_inputs: [],
auto_resolved: true,
});
} finally {
autoApproveInFlight.delete(conf.requestId);
}
}
},
{ deep: true },
);
// --- SSE lifecycle ---
function onSSEMessage(sseEvent: MessageEvent): void {
@ -554,6 +634,7 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
debugEvents.value = [];
resetFeedback();
resolvedConfirmationIds.value = new Map();
sessionAlwaysAllowKeys.value = new Set();
runStateByGroupId = {};
groupIdByRunId = {};
lastEventId.value = undefined;
@ -822,6 +903,7 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
latestTasks,
debugEvents,
resolvedConfirmationIds,
sessionAlwaysAllowKeys,
pendingMessageCount,
hydrationStatus,
sseState,
@ -856,6 +938,7 @@ export function createThreadRuntime(threadId: string, hooks: ThreadRuntimeHooks)
confirmAction,
confirmResourceDecision,
resolveConfirmation,
addAlwaysAllowKey,
findToolCallByRequestId,
copyFullTrace,
submitFeedback,

View File

@ -188,9 +188,7 @@ test.describe(
);
await expect(
n8n.instanceAi.getConfirmationText(
`Edit existing workflow "${APPROVE_EDIT_WORKFLOW_NAME}"`,
),
n8n.instanceAi.getConfirmationText(`Edit ${APPROVE_EDIT_WORKFLOW_NAME}`),
).toBeVisible({ timeout: 120_000 });
await expect(n8n.instanceAi.getConfirmApproveButton()).toBeVisible({ timeout: 120_000 });
const whileAwaitingApproval = await n8n.api.workflows.getWorkflow(workflow.id);
@ -224,7 +222,7 @@ test.describe(
);
await expect(
n8n.instanceAi.getConfirmationText(`Edit existing workflow "${DENY_EDIT_WORKFLOW_NAME}"`),
n8n.instanceAi.getConfirmationText(`Edit ${DENY_EDIT_WORKFLOW_NAME}`),
).toBeVisible({ timeout: 120_000 });
await expect(n8n.instanceAi.getConfirmDenyButton()).toBeVisible({ timeout: 120_000 });
await n8n.instanceAi.getConfirmDenyButton().click();