feat: Tell the AI Assistant which preview tabs are open (#39768)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaakko Husso 2026-09-30 10:53:42 +00:00 committed by GitHub
parent fa93a7f01f
commit 10e484f552
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 479 additions and 31 deletions

View File

@ -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,

View File

@ -1649,9 +1649,12 @@ export const instanceAiThreadArtifactSchema = z.object({
});
export type InstanceAiThreadArtifact = z.infer<typeof instanceAiThreadArtifactSchema>;
/** 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<

View File

@ -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 \`<thread-artifacts>\` 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}

View File

@ -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<unknown[]>,
) => Promise<string>;
getReplayedMessages: (threadId: string) => Promise<unknown[]>;
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('<thread-artifacts>');
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 = {

View File

@ -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(
'<thread-artifacts>\nThe user has no tabs open in this conversation’s preview.\n</thread-artifacts>',
);
});
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', () => {

View File

@ -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<AgentDbMessage[]> | 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<AgentDbMessage[]> = 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<AgentDbMessage[]>,
): 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<AgentDbMessage[]>,
): Promise<string> {
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<AgentDbMessage[]>,
): Promise<string | undefined> {
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

View File

@ -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 `<thread-context>` block. */
const THREAD_ARTIFACTS_BLOCK = /<thread-artifacts>\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<string, string>();
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:',

View File

@ -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(() => {

View File

@ -36,6 +36,7 @@ export function makeThread(): ThreadRuntime {
linkableResourceNameIndex: new Map(),
activeArtifactId: undefined,
setActiveArtifactId: vi.fn(),
setOpenTabs: vi.fn(),
feedbackByResponseId: {},
rateableResponseId: null,
pendingConfirmations: [],

View File

@ -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);

View File

@ -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');
});
});
});

View File

@ -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<number | undefined>(undefined);
/** Focused preview tab id while the artifacts preview is open. */
const activeArtifactId = ref<string>();
/**
* 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<OpenThreadTab[] | null>();
// 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,

View File

@ -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 `<thread-context>`.
* Matches the thread preview tabs: ids and names only.
* Build the per-turn index the backend injects inside `<thread-context>`: 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<ResourceEntry>,
activeId?: string,
openTabs?: OpenThreadTab[],
): InstanceAiThreadArtifactsContext | undefined {
const producedById = new Map<string, ResourceEntry>();
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

View File

@ -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,