mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 23:07:12 +02:00
feat(editor): Redesign instance AI approval dialogs (no-changelog) (#30654)
This commit is contained in:
parent
e98809f9bd
commit
41c876ea68
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user