From 10e484f552abbddf3694f13ccda2875323ba8e68 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Wed, 30 Sep 2026 10:53:42 +0000 Subject: [PATCH] feat: Tell the AI Assistant which preview tabs are open (#39768) Co-authored-by: Claude Opus 5.5 (1M context) --- .../__tests__/instance-ai.schema.test.ts | 4 +- .../src/schemas/instance-ai.schema.ts | 7 +- .../instance-ai/src/agent/system-prompt.ts | 12 ++ .../__tests__/instance-ai.service.test.ts | 164 +++++++++++++++++- .../__tests__/internal-messages.test.ts | 47 ++++- .../instance-ai/instance-ai.service.ts | 69 +++++++- .../modules/instance-ai/internal-messages.ts | 35 +++- .../ai/instanceAi/InstanceAiThreadView.vue | 7 + .../createThreadComponentRenderer.ts | 1 + .../instanceAi.threadRuntime.test.ts | 27 +++ .../__tests__/threadArtifacts.test.ts | 55 ++++++ .../ai/instanceAi/instanceAi.threadRuntime.ts | 25 ++- .../features/ai/instanceAi/threadArtifacts.ts | 55 ++++-- .../ai/instanceAi/useCanvasPreview.ts | 2 + 14 files changed, 479 insertions(+), 31 deletions(-) diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts index 5747fafcab0..2e5f615350a 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts @@ -67,7 +67,7 @@ describe('Instance AI prompt version requests', () => { } }); - it('accepts a thread artifact index and rejects an empty or oversized list', () => { + it('accepts a thread artifact index, including an empty one, and rejects an oversized list', () => { const base = { message: 'Change this', timeZone: 'UTC' }; expect( InstanceAiSendMessageRequest.safeParse({ @@ -83,7 +83,7 @@ describe('Instance AI prompt version requests', () => { ...base, threadArtifacts: { artifacts: [] }, }).success, - ).toBe(false); + ).toBe(true); expect( InstanceAiSendMessageRequest.safeParse({ ...base, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 372706f739a..5a19688afb3 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1649,9 +1649,12 @@ export const instanceAiThreadArtifactSchema = z.object({ }); export type InstanceAiThreadArtifact = z.infer; -/** The thread view's artifact tabs, plus which tab is focused when the preview is open. */ +/** + * The tabs open in the thread view, plus which tab is focused when the preview is open. + * An empty list means no tabs are open. + */ export const instanceAiThreadArtifactsContextSchema = z.object({ - artifacts: z.array(instanceAiThreadArtifactSchema).min(1).max(20), + artifacts: z.array(instanceAiThreadArtifactSchema).max(20), activeId: z.string().min(1).max(64).optional(), }); export type InstanceAiThreadArtifactsContext = z.infer< diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index cec1a883c3f..d13b4712f4a 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -141,6 +141,17 @@ A request to build something genuinely new goes straight to the build path — n `; } +/** + * The turn sends the tabs block only when the open tabs change, so the model + * needs to know what a user message without one means. + */ +function getPreviewTabsSection(): string { + return ` +## Preview Tabs + +The latest \`\` block lists the tabs the user has open now. A user message without one means the tabs did not change. When a tab from an earlier block is missing from the latest one, the user closed it: you can still work on it if the user asks, but do not assume the user is looking at it.`; +} + function getConversationRecallSection(): string { return ` ## Past Conversations @@ -233,6 +244,7 @@ export function createSystemPromptRenderer(communicationStyleSection: string) { ${workspaceRoot ? `${getSandboxWorkspaceSection(workspaceRoot)}` : ''} ${getProjectScopeSection(projectId)} ${getExistingResourcesSection()} +${getPreviewTabsSection()} ${conversationHistoryEnabled ? getConversationRecallSection() : ''} ${preferenceSavingEnabled ? getPreferenceSavingSection() : ''} ${SECRET_ASK_GUARDRAIL} diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 8dd5e76b4c1..b0c7cb592ed 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -303,7 +303,7 @@ import { import { INSTANCE_AI_RUN_TIMEOUT_REASON } from '../liveness/instance-ai-liveness.service'; import { InstanceAiRunLimitError } from '../instance-ai-run-limit.error'; import { InstanceAiService } from '../instance-ai.service'; -import { buildThreadContextBlock } from '../internal-messages'; +import { buildThreadArtifactsBlock, buildThreadContextBlock } from '../internal-messages'; import { InstanceAiSandboxService } from '../sandbox'; import type { RebuildSuspendedRunOutcome, @@ -5497,8 +5497,9 @@ describe('InstanceAiService — editor handoff context resources', () => { // Imported helpers compile to `(0,__vite_ssr_import_N__.fn)(args)`. Both // arguments must reach the block, or the editor hand-off drops out of it. expect(source).toMatch( - /buildThreadArtifactsBlock\)?\s*\(\s*threadArtifacts\s*,\s*contextAttachments\s*\)/, + /resolveThreadArtifactsTurn\(\s*threadId\s*,\s*threadArtifacts\s*,\s*contextAttachments\s*,/, ); + expect(source).toMatch(/buildThreadArtifactsBlock\)?\s*\(\s*context\s*,\s*attachments\s*\)/); expect(source).toMatch(/buildThreadContextBlock\)?/); expect(source).not.toContain('buildContextResourcesBlock'); expect(source).not.toContain('EDITOR_CONTEXT_OPEN_TAG'); @@ -6582,6 +6583,165 @@ describe('InstanceAiService — internal follow-up failure streak', () => { }); }); +describe('InstanceAiService — resolveThreadArtifactsTurn', () => { + type Context = { + artifacts: Array<{ type: 'workflow'; id: string; name?: string }>; + activeId?: string; + }; + type Internals = { + resolveThreadArtifactsTurn: ( + threadId: string, + context: Context | undefined, + attachments: Array<{ type: 'workflow'; id: string; name: string }>, + loadHistory: () => Promise, + ) => Promise; + getReplayedMessages: (threadId: string) => Promise; + agentMemory: { + getMessages: Mock; + getCursor: Mock; + getActiveObservationLog: Mock; + getMessagesForObservationScope: Mock; + }; + logger: { warn: Mock }; + }; + + function createService(): Internals { + const service = Object.create(InstanceAiService.prototype) as unknown as Internals; + service.agentMemory = { + getMessages: vi.fn().mockResolvedValue([]), + getCursor: vi.fn().mockResolvedValue(null), + getActiveObservationLog: vi.fn().mockResolvedValue([]), + getMessagesForObservationScope: vi.fn().mockResolvedValue([]), + }; + service.logger = { warn: vi.fn() }; + return service; + } + + const digest = { type: 'workflow' as const, id: 'wf-1', name: 'Digest' }; + const report = { type: 'workflow' as const, id: 'wf-2', name: 'Report' }; + const storedUserTurn = (block: string) => ({ + role: 'user', + content: [buildThreadContextBlock(['Ambient context.', block]), 'Change it'].join('\n\n'), + }); + + async function resolve( + service: Internals, + context: Context | undefined, + attachments: Array<{ type: 'workflow'; id: string; name: string }> = [], + ) { + return await service.resolveThreadArtifactsTurn( + 'thread-1', + context, + attachments, + async () => await service.getReplayedMessages('thread-1'), + ); + } + + it('sends nothing when the client sent no tabs', async () => { + expect(await resolve(createService(), undefined)).toBe(''); + }); + + it('sends the tabs when the history has no tabs block', async () => { + const block = await resolve(createService(), { artifacts: [digest], activeId: 'wf-1' }); + + expect(block).toContain(''); + expect(block).toContain('(id: `wf-1`) [current]'); + }); + + it('does not send the tabs again when they have not changed, also after a reorder', async () => { + const service = createService(); + const earlier = buildThreadArtifactsBlock({ artifacts: [digest, report], activeId: 'wf-1' }); + service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]); + + expect(await resolve(service, { artifacts: [digest, report], activeId: 'wf-1' })).toBe(''); + expect(await resolve(service, { artifacts: [report, digest], activeId: 'wf-1' })).toBe(''); + }); + + it('sends the tabs when a tab closed or the active tab changed', async () => { + const service = createService(); + const earlier = buildThreadArtifactsBlock({ artifacts: [digest, report], activeId: 'wf-1' }); + service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]); + + expect(await resolve(service, { artifacts: [digest], activeId: 'wf-1' })).toContain( + 'Workflow "Digest"', + ); + expect(await resolve(service, { artifacts: [digest, report], activeId: 'wf-2' })).toContain( + '(id: `wf-2`) [current]', + ); + }); + + it('says no tabs are open once, then not again while nothing changes', async () => { + const service = createService(); + const noTabs = await resolve(service, { artifacts: [] }); + expect(noTabs).toContain('The user has no tabs open in this conversation’s preview.'); + + service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(noTabs)]); + expect(await resolve(service, { artifacts: [] })).toBe(''); + }); + + it('says no tabs are open after a block that listed tabs or only an editor hand-off', async () => { + const service = createService(); + service.agentMemory.getMessages.mockResolvedValue([ + storedUserTurn(buildThreadArtifactsBlock({ artifacts: [digest] })), + ]); + expect(await resolve(service, { artifacts: [] })).toContain('no tabs open'); + + const handoffOnly = buildThreadArtifactsBlock({ artifacts: [] }, [ + { type: 'workflow' as const, id: 'wf-9', name: 'Handed off' }, + ]); + service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(handoffOnly)]); + expect(await resolve(service, { artifacts: [] })).toContain('no tabs open'); + }); + + it('says no tabs are open again when the same block was compacted out of the replay window', async () => { + const service = createService(); + // The full history has the same block, so only a replay-window read sends it again. + service.agentMemory.getMessages.mockResolvedValue([ + storedUserTurn(buildThreadArtifactsBlock({ artifacts: [] })), + ]); + service.agentMemory.getCursor.mockResolvedValue({ + lastObservedAt: new Date('2026-09-01T00:00:00.000Z'), + lastObservedMessageId: 'message-1', + }); + service.agentMemory.getActiveObservationLog.mockResolvedValue([{ id: 'observation-1' }]); + service.agentMemory.getMessagesForObservationScope.mockResolvedValue([]); + + expect(await resolve(service, { artifacts: [] })).toContain('no tabs open'); + }); + + it('always sends a block that carries an editor hand-off', async () => { + const service = createService(); + const attachments = [{ type: 'workflow' as const, id: 'wf-1', name: 'Digest' }]; + const earlier = buildThreadArtifactsBlock({ artifacts: [digest] }, attachments); + service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]); + + expect(await resolve(service, { artifacts: [digest] }, attachments)).toBe(earlier); + }); + + it('sends the tabs again when the earlier block was compacted out of the replay window', async () => { + const service = createService(); + service.agentMemory.getMessages.mockResolvedValue([ + storedUserTurn(buildThreadArtifactsBlock({ artifacts: [digest] })), + ]); + service.agentMemory.getCursor.mockResolvedValue({ + lastObservedAt: new Date('2026-09-01T00:00:00.000Z'), + lastObservedMessageId: 'message-1', + }); + service.agentMemory.getActiveObservationLog.mockResolvedValue([{ id: 'observation-1' }]); + service.agentMemory.getMessagesForObservationScope.mockResolvedValue([]); + + expect(await resolve(service, { artifacts: [digest] })).toContain('Workflow "Digest"'); + }); + + it('sends the tabs when the history cannot be read', async () => { + const service = createService(); + service.agentMemory.getMessages.mockRejectedValue(new Error('database is locked')); + + expect(await resolve(service, { artifacts: [] })).toContain('no tabs open'); + expect(service.logger.warn).toHaveBeenCalled(); + }); +}); + describe('InstanceAiService — resolveAiPreferencesTurn', () => { type StoredMessage = { role: string; content: string }; type Internals = { diff --git a/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts b/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts index c1c375eb266..a934849adb6 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/internal-messages.test.ts @@ -12,6 +12,7 @@ import { cleanStoredUserMessage, extractAgentPreviewHandoffContext, extractAiPreferencesBlock, + extractThreadArtifactsBlock, extractEditorContextResourceAttachments, withCurrentDateTime, withPastConversations, @@ -747,10 +748,52 @@ describe('extractAiPreferencesBlock', () => { }); }); +describe('extractThreadArtifactsBlock', () => { + const block = buildThreadArtifactsBlock({ + artifacts: [{ type: 'workflow', id: 'wf-1', name: 'Digest' }], + activeId: 'wf-1', + }); + + it('returns the block exactly as the thread-context wrapper stored it', () => { + const stored = [ + buildThreadContextBlock([ + instanceContextMarker(), + block, + buildCurrentDateTimeBlock('Monday 1 January 2026'), + ]), + 'Change it', + ].join('\n\n'); + + expect(extractThreadArtifactsBlock(stored)).toBe(asStoredThreadContextSection(block)); + }); + + it('ignores a tag lookalike in the user text', () => { + expect(extractThreadArtifactsBlock(`Please explain\n${block}`)).toBeUndefined(); + }); +}); + describe('buildThreadArtifactsBlock', () => { - it('returns empty when there are no artifacts', () => { + it('returns empty when the client sent no tabs', () => { expect(buildThreadArtifactsBlock(undefined)).toBe(''); - expect(buildThreadArtifactsBlock({ artifacts: [] })).toBe(''); + }); + + it('says the user has no tabs open when the client sent an empty list', () => { + expect(buildThreadArtifactsBlock({ artifacts: [] })).toBe( + '\nThe user has no tabs open in this conversation’s preview.\n', + ); + }); + + it('lists the open tabs as of this message, in the same order however the tabs are sorted', () => { + const workflow = { type: 'workflow' as const, id: 'wf-1', name: 'Digest' }; + const table = { type: 'data-table' as const, id: 'dt-1', name: 'FAQ' }; + + const block = buildThreadArtifactsBlock({ artifacts: [workflow, table] }); + + expect(block).toContain( + 'Tabs the user has open in this conversation’s preview, as of this message:', + ); + expect(block.indexOf('Data table "FAQ"')).toBeLessThan(block.indexOf('Workflow "Digest"')); + expect(buildThreadArtifactsBlock({ artifacts: [table, workflow] })).toBe(block); }); it('marks the focused tab as current and lists the rest', () => { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 9e92132a7ad..ca1d2d798c9 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -220,6 +220,7 @@ import { buildThreadContextBlock, buildWorkflowTestRequestBlock, extractAiPreferencesBlock, + extractThreadArtifactsBlock, getProjectContextSection, WORKFLOW_SETUP_STATE_CLOSE_TAG, WORKFLOW_SETUP_STATE_OPEN_TAG, @@ -4207,9 +4208,18 @@ export class InstanceAiService { // Keep setup handoffs first and the user's message last. // Group ambient context in the thread-context wrapper. + // One read of the replayed history serves every section sent only when it changed. + let replayedHistory: Promise | undefined; + const loadReplayedHistory = async () => + await (replayedHistory ??= this.getReplayedMessages(threadId)); const threadArtifactsBlock = resumeReason === undefined - ? buildThreadArtifactsBlock(threadArtifacts, contextAttachments) + ? await this.resolveThreadArtifactsTurn( + threadId, + threadArtifacts, + contextAttachments, + loadReplayedHistory, + ) : ''; const [boundProject, pastConversationsSection] = await Promise.all([ this.resolveBoundProject(context), @@ -4222,7 +4232,12 @@ export class InstanceAiService { // already in the history the follow-up replays. const aiPreferencesTurn = aiPreferencesEnabled && resumeReason === undefined && !isMachineFollowUp - ? await this.resolveAiPreferencesTurn(user.id, boundProject, threadId) + ? await this.resolveAiPreferencesTurn( + user.id, + boundProject, + threadId, + loadReplayedHistory, + ) : undefined; const threadContextBlock = buildThreadContextBlock([ instanceContext.state === 'injected' ? instanceContext.block : '', @@ -5378,6 +5393,8 @@ export class InstanceAiService { userId: string, project: ProjectSummary | undefined, threadId: string, + loadHistory: () => Promise = async () => + await this.getReplayedMessages(threadId), ): Promise<{ block: string | undefined; payload: AiPreferencesAppliedPayload }> { const resolved = await this.bestEffort( 'Instance AI failed to read the AI preferences for this turn', @@ -5400,7 +5417,7 @@ export class InstanceAiService { const history = await this.bestEffort( 'Instance AI failed to read the last AI preferences block of this thread', { threadId }, - async () => await this.findAiPreferencesHistory(threadId), + async () => await this.findAiPreferencesHistory(loadHistory), ); const lastBlock = history?.block; const savedSinceLastBlock = history?.savedSinceLastBlock === true; @@ -5459,9 +5476,9 @@ export class InstanceAiService { * current preferences must be injected again when the window has no block. */ private async findAiPreferencesHistory( - threadId: string, + loadHistory: () => Promise, ): Promise<{ block?: string; savedSinceLastBlock: boolean }> { - const history = await this.getReplayedMessages(threadId); + const history = await loadHistory(); let savedSinceLastBlock = false; for (let i = history.length - 1; i >= 0; i--) { const m = history[i]; @@ -5483,6 +5500,48 @@ export class InstanceAiService { return { savedSinceLastBlock }; } + /** + * The open tabs block for this turn, or `''` when the agent already has the same + * block in its replayed history. The agent reads the latest block as the current + * tabs, so an unchanged block is not sent again. + */ + private async resolveThreadArtifactsTurn( + threadId: string, + context: InstanceAiThreadArtifactsContext | undefined, + attachments: InstanceAiResourceAttachment[], + loadHistory: () => Promise, + ): Promise { + const freshBlock = buildThreadArtifactsBlock(context, attachments); + if (!freshBlock) return ''; + // A hand-off always rides its own turn: the parser rebuilds the attachments from it. + if (attachments.length > 0) return freshBlock; + + const history = await this.bestEffort( + 'Instance AI failed to read the last thread artifacts block of this thread', + { threadId }, + async () => ({ block: await this.findLastThreadArtifactsBlock(loadHistory) }), + ); + // Send the block when the history cannot be read, so the agent never has stale tabs. + if (!history) return freshBlock; + // A "no tabs" block is sent like any other: after compaction, an observation can + // still say that tabs are open. + return asStoredThreadContextSection(freshBlock) === history.block ? '' : freshBlock; + } + + /** The latest thread artifacts block in the replay window, or `undefined`. */ + private async findLastThreadArtifactsBlock( + loadHistory: () => Promise, + ): Promise { + const history = await loadHistory(); + for (let i = history.length - 1; i >= 0; i--) { + const m = history[i]; + if (!('role' in m) || m.role !== 'user') continue; + const block = extractThreadArtifactsBlock(this.extractStoredMessageText(m.content)); + if (block !== undefined) return block; + } + return undefined; + } + /** * The persisted messages the runtime replays to the model on the next turn. Mirrors * `MemoryOrchestrator.loadHistoryMessages`, including its desync guard: the post-cursor diff --git a/packages/cli/src/modules/instance-ai/internal-messages.ts b/packages/cli/src/modules/instance-ai/internal-messages.ts index 5c7e9d542a9..47083fcf173 100644 --- a/packages/cli/src/modules/instance-ai/internal-messages.ts +++ b/packages/cli/src/modules/instance-ai/internal-messages.ts @@ -257,6 +257,24 @@ export function extractAiPreferencesBlock(stored: string): string | undefined { return threadContext ? AI_PREFERENCES_BLOCK.exec(threadContext)?.[0] : undefined; } +const PREVIEW_TABS_HEADER = + 'Tabs the user has open in this conversation’s preview, as of this message:'; + +/** Matches the service-written thread artifacts block inside one `` block. */ +const THREAD_ARTIFACTS_BLOCK = /\n[\s\S]*?\n<\/thread-artifacts>/; + +/** + * The thread artifacts block a stored user message carries, exactly as stored, or + * `undefined`. Read from the leading internal blocks only, like the preferences block. + * Compare against `asStoredThreadContextSection(freshBlock)`, never the raw render. + */ +export function extractThreadArtifactsBlock(stored: string): string | undefined { + const threadContext = leadingInternalBlocks(stored).find((block) => + block.startsWith(THREAD_CONTEXT_OPEN_TAG), + ); + return threadContext ? THREAD_ARTIFACTS_BLOCK.exec(threadContext)?.[0] : undefined; +} + /** Longest a user-supplied value may be inside a block. Matches the instance-context bound. */ const PROMPT_TEXT_MAX_LENGTH = 128; @@ -523,8 +541,17 @@ export function buildThreadArtifactsBlock( context: InstanceAiThreadArtifactsContext | undefined, resourceAttachments: InstanceAiResourceAttachment[] = [], ): string { - const previewArtifacts = context?.artifacts ?? []; - if (previewArtifacts.length === 0 && resourceAttachments.length === 0) return ''; + // A fixed order, so reordering tabs does not change the block and re-send it. + const previewArtifacts = [...(context?.artifacts ?? [])].sort((a, b) => + `${a.type}:${a.id}`.localeCompare(`${b.type}:${b.id}`), + ); + if (previewArtifacts.length === 0 && resourceAttachments.length === 0) { + // An empty list that the client sent means no tabs are open. The wording holds + // whether the user closed tabs or never had any. + return context + ? `${THREAD_ARTIFACTS_OPEN_TAG}\nThe user has no tabs open in this conversation’s preview.\n${THREAD_ARTIFACTS_CLOSE_TAG}` + : ''; + } const executionByWorkflowId = new Map(); for (const attachment of resourceAttachments) { @@ -582,9 +609,7 @@ export function buildThreadArtifactsBlock( 'Use these ids when you act on the user’s request. Do not inspect, run, or describe their contents beyond what that request needs.'; const prose = [ - ...(previewLines.length > 0 - ? ['Artifacts the user can see in this conversation’s preview:', ...previewLines] - : []), + ...(previewLines.length > 0 ? [PREVIEW_TABS_HEADER, ...previewLines] : []), ...(handoffLines.length > 0 ? [ 'The user opened this conversation from the editor, where they are looking at:', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 4b042c1b55c..d7fc408cab1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -216,6 +216,13 @@ watch( }, { immediate: true }, ); +// The agent sees the open tabs, not every artifact the thread produced. Until the +// stored tabs load, the default tabs can still hold closed ones, so send none. +watch( + [() => preview.openTabs.value, () => preview.tabsLoaded.value], + ([tabs, loaded]) => thread.setOpenTabs(loaded ? tabs : null), + { immediate: true }, +); // --- Setup panel (checklist docked above the composer) --- // Early setup announcements can arrive before the first workflow artifact. const setupPanelWorkflowId = computed(() => { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts index 28cebef74af..0ba88418e10 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts @@ -36,6 +36,7 @@ export function makeThread(): ThreadRuntime { linkableResourceNameIndex: new Map(), activeArtifactId: undefined, setActiveArtifactId: vi.fn(), + setOpenTabs: vi.fn(), feedbackByResponseId: {}, rateableResponseId: null, pendingConfirmations: [], diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts index 611d92ec43d..6d39ecda826 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts @@ -1746,6 +1746,33 @@ describe('createThreadRuntime - SSE and hydration', () => { ); }); + test('sendMessage sends no tabs while the stored tabs of the view load', async () => { + mockPostMessage.mockResolvedValue({ runId: 'run-1' }); + const runtime = activeRuntime(registry); + runtime.producedArtifacts.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Maybe closed' }); + runtime.setOpenTabs(null); + + await runtime.sendMessage('Change it', { authorship: USER_TYPED_MESSAGE }); + + expect(mockPostMessage.mock.calls[0]?.[8]).toBeUndefined(); + }); + + test('sendMessage forwards the open tabs instead of every produced artifact', async () => { + mockPostMessage.mockResolvedValue({ runId: 'run-1' }); + const runtime = activeRuntime(registry); + runtime.producedArtifacts.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Closed' }); + runtime.producedArtifacts.set('wf-2', { type: 'workflow', id: 'wf-2', name: 'Open' }); + runtime.setOpenTabs([{ type: 'workflow', id: 'wf-2', name: 'Open' }]); + runtime.setActiveArtifactId('wf-2'); + + await runtime.sendMessage('Change it', { authorship: USER_TYPED_MESSAGE }); + + expect(mockPostMessage.mock.calls[0]?.[8]).toEqual({ + artifacts: [{ type: 'workflow', id: 'wf-2', name: 'Open' }], + activeId: 'wf-2', + }); + }); + test('sendMessage forwards the thread artifact index to postMessage', async () => { mockPostMessage.mockResolvedValue({ runId: 'run-1' }); const runtime = activeRuntime(registry); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/threadArtifacts.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/threadArtifacts.test.ts index c8304294b42..591d40c641c 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/threadArtifacts.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/threadArtifacts.test.ts @@ -68,4 +68,59 @@ describe('buildThreadArtifactsContext', () => { artifacts: [{ type: 'workflow', id: 'wf-1', name: 'A' }], }); }); + + describe('with the open tabs of the thread view', () => { + const produced = [ + entry({ type: 'workflow', id: 'wf-1', name: 'First', archived: true }), + entry({ type: 'workflow', id: 'wf-2', name: 'Second' }), + entry({ type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1', pending: true }), + ]; + + it('sends the open tabs in tab order, not every produced artifact', () => { + expect( + buildThreadArtifactsContext(produced, 'wf-2', [ + { type: 'workflow', id: 'wf-2', name: 'Second' }, + { type: 'data-table', id: 'dt-9', name: 'Picked table', projectId: 'proj-1' }, + ]), + ).toEqual({ + artifacts: [ + { type: 'workflow', id: 'wf-2', name: 'Second' }, + { type: 'data-table', id: 'dt-9', name: 'Picked table', projectId: 'proj-1' }, + ], + activeId: 'wf-2', + }); + }); + + it('sends an empty list when every tab is closed', () => { + expect(buildThreadArtifactsContext(produced, undefined, [])).toEqual({ artifacts: [] }); + }); + + it('keeps the archived and pending flags of the produced artifacts', () => { + expect( + buildThreadArtifactsContext(produced, undefined, [ + { type: 'workflow', id: 'wf-1', name: 'First' }, + { type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1' }, + ])?.artifacts, + ).toEqual([ + { type: 'workflow', id: 'wf-1', name: 'First', archived: true }, + { type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1', pending: true }, + ]); + }); + + it('keeps the first tabs and the focused one when over the cap', () => { + const tabs = Array.from({ length: 25 }, (_, index) => ({ + type: 'workflow' as const, + id: `wf-${index}`, + name: `Workflow ${index}`, + })); + + const result = buildThreadArtifactsContext([], 'wf-24', tabs); + + expect(result?.artifacts).toHaveLength(20); + expect(result?.artifacts[0]?.id).toBe('wf-0'); + expect(result?.artifacts.at(-1)?.id).toBe('wf-24'); + expect(result?.artifacts.some((artifact) => artifact.id === 'wf-19')).toBe(false); + expect(result?.activeId).toBe('wf-24'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts index 77e12591fea..ab45b4184fc 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts @@ -1,4 +1,4 @@ -import { computed, nextTick, reactive, ref, triggerRef, watch } from 'vue'; +import { computed, nextTick, reactive, ref, shallowRef, triggerRef, watch } from 'vue'; import { v4 as uuidv4 } from 'uuid'; import { ResponseError } from '@n8n/rest-api-client'; import { @@ -68,7 +68,7 @@ import { useResourceRegistry, type TransientWorkflowArtifactReference, } from './useResourceRegistry'; -import { buildThreadArtifactsContext } from './threadArtifacts'; +import { buildThreadArtifactsContext, type OpenThreadTab } from './threadArtifacts'; import { useResponseFeedback } from './useResponseFeedback'; import { INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY, @@ -530,6 +530,13 @@ export function createThreadRuntime( const lastEventId = ref(undefined); /** Focused preview tab id while the artifacts preview is open. */ const activeArtifactId = ref(); + /** + * The tabs the thread view has open, sent to the agent with each message. + * `undefined` when no view reports tabs; the agent then gets every artifact. + * `null` while the view's stored tabs load; the message then carries no tabs, + * so the agent keeps the last tabs it has instead of closed ones. + */ + const openTabs = shallowRef(); // Event ids already applied on this thread — guards against replay overlap, // e.g. an auto-reconnect replaying an id that already arrived just before // the disconnect. Not reactive: only consulted inside onSSEMessage. @@ -1408,6 +1415,10 @@ export function createThreadRuntime( activeArtifactId.value = id; } + function setOpenTabs(tabs?: OpenThreadTab[] | null): void { + openTabs.value = tabs; + } + /** Reset all state owned by this runtime. */ function resetState(): void { hydrationGeneration += 1; @@ -1432,6 +1443,7 @@ export function createThreadRuntime( lastEventId.value = undefined; seenEventIds.clear(); activeArtifactId.value = undefined; + openTabs.value = undefined; pendingWorkflowAttachment.value = null; transientWorkflowReferences.clear(); pendingHandoff.value = null; @@ -1637,7 +1649,13 @@ export function createThreadRuntime( Intl.DateTimeFormat().resolvedOptions().timeZone, pushRef, instanceAiSettingsStore.computerUseChannels, - buildThreadArtifactsContext(producedArtifacts.values(), activeArtifactId.value), + openTabs.value === null + ? undefined + : buildThreadArtifactsContext( + producedArtifacts.values(), + activeArtifactId.value, + openTabs.value, + ), ); return runId; @@ -1940,6 +1958,7 @@ export function createThreadRuntime( producedArtifactOrigins, activeArtifactId, setActiveArtifactId, + setOpenTabs, feedbackByResponseId, rateableResponseId, currentTasks, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/threadArtifacts.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/threadArtifacts.ts index feccc116654..3a416a0a6e6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/threadArtifacts.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/threadArtifacts.ts @@ -4,32 +4,67 @@ import type { ResourceEntry } from './useResourceRegistry'; type ThreadArtifactType = InstanceAiThreadArtifact['type']; +/** A tab the user has open in the thread view. */ +export type OpenThreadTab = { + type: ThreadArtifactType; + id: string; + name?: string; + projectId?: string; + pending?: boolean; +}; + const MAX_THREAD_ARTIFACTS = 20; function isThreadArtifactType(type: ResourceEntry['type']): type is ThreadArtifactType { return type === 'workflow' || type === 'agent' || type === 'data-table'; } +function toThreadArtifact( + tab: OpenThreadTab, + produced: ResourceEntry | undefined, +): InstanceAiThreadArtifact { + const name = tab.name ?? produced?.name; + const projectId = tab.projectId ?? produced?.projectId; + return { + type: tab.type, + id: tab.id, + ...(name ? { name } : {}), + ...(projectId ? { projectId } : {}), + ...(tab.pending || produced?.pending ? { pending: true as const } : {}), + ...(produced?.archived ? { archived: true as const } : {}), + }; +} + /** - * Build the per-turn index the backend injects inside ``. - * Matches the thread preview tabs: ids and names only. + * Build the per-turn index the backend injects inside ``: ids + * and names only. + * + * With `openTabs`, the index is the tabs the user has open, in tab order. An + * empty list tells the agent no tabs are open. Without it, for a + * client that has no tab bar, the index is every artifact the thread produced. */ export function buildThreadArtifactsContext( produced: Iterable, activeId?: string, + openTabs?: OpenThreadTab[], ): InstanceAiThreadArtifactsContext | undefined { + const producedById = new Map(); const all: InstanceAiThreadArtifact[] = []; for (const entry of produced) { if (!isThreadArtifactType(entry.type)) continue; - all.push({ - type: entry.type, - id: entry.id, - ...(entry.name ? { name: entry.name } : {}), - ...(entry.projectId ? { projectId: entry.projectId } : {}), - ...(entry.pending ? { pending: true as const } : {}), - ...(entry.archived ? { archived: true as const } : {}), - }); + producedById.set(entry.id, entry); + if (!openTabs) all.push(toThreadArtifact({ type: entry.type, id: entry.id }, entry)); } + + if (openTabs) { + const artifacts = openTabs.map((tab) => toThreadArtifact(tab, producedById.get(tab.id))); + // Over the cap, keep the first tabs in tab order and never drop the focused one. + const active = artifacts.find((artifact) => artifact.id === activeId); + let capped = artifacts.slice(0, MAX_THREAD_ARTIFACTS); + if (active && !capped.includes(active)) capped = [...capped.slice(0, -1), active]; + return { artifacts: capped, ...(active ? { activeId: active.id } : {}) }; + } + if (all.length === 0) return undefined; // Insertion order is oldest first. Over the cap, keep the newest tabs — the diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts index 2bfde972c0d..e03a736e29d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts @@ -582,6 +582,8 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan return { activeTabId, openTabs, + /** False until the stored tabs of the thread load. */ + tabsLoaded: tabs.isLoaded, activeWorkflowId, activeDataTableId, activeDataTableProjectId,