From 80da0c8c2123456164ba8d9c49064360346237da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 9 Sep 2026 11:20:12 +0000 Subject: [PATCH] fix(editor): Keep the artifact tab the user picks during an AI Assistant run (#38178) --- .../ai/instanceAi/InstanceAiThreadView.vue | 3 +- .../__tests__/InstanceAiThreadView.test.ts | 54 +++++++- .../__tests__/useCanvasPreview.test.ts | 128 ++++++++++++++++++ .../ai/instanceAi/useCanvasPreview.ts | 60 +++++--- 4 files changed, 224 insertions(+), 21 deletions(-) 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 5aa779e2cb3..1f15c523133 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -1494,9 +1494,10 @@ async function dismissComposerContextChip() { @resizeend="isResizingPreview = false" > { expect(getByTestId('instance-ai-thread-area')).not.toHaveClass('agentPreviewDockOpen'); }); + it('keeps the tab the user clicked while the agent is working', async () => { + thread.producedArtifacts = new Map([ + ['workflow-1', { type: 'workflow', id: 'workflow-1', name: 'First' }], + ['workflow-2', { type: 'workflow', id: 'workflow-2', name: 'Second' }], + ]) as typeof thread.producedArtifacts; + thread.isStreaming = true; + const pushBuildResult = (toolCallId: string, workflowId: string) => { + thread.messages.push({ + id: `msg-${toolCallId}`, + role: 'assistant', + content: '', + reasoning: '', + isStreaming: false, + createdAt: '2026-04-01T00:00:00.000Z', + agentTree: { + agentId: 'agent-1', + role: 'orchestrator', + status: 'completed', + textContent: '', + reasoning: '', + timeline: [], + children: [], + toolCalls: [ + { + toolCallId, + toolName: 'build-workflow', + args: {}, + isLoading: false, + result: { success: true, workflowId }, + }, + ], + }, + } as never); + }; + const user = userEvent.setup(); + const { container } = renderView({ props: { threadId: 'thread-1' } }); + + pushBuildResult('tc-1', 'workflow-1'); + const secondTab = await vi.waitFor(() => { + const tab = container.querySelector('[data-tab-id="workflow-2"]'); + expect(tab).not.toBeNull(); + return tab!; + }); + await user.click(secondTab); + expect(secondTab).toHaveAttribute('data-state', 'active'); + + pushBuildResult('tc-2', 'workflow-1'); + await nextTick(); + + expect(secondTab).toHaveAttribute('data-state', 'active'); + }); + it('clears the agent dock layout when switching artifacts', async () => { const { container, getByTestId, user } = await renderAgentArtifact({ includeWorkflow: true, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts index d2356e5944a..7861fab6dc1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts @@ -1257,4 +1257,132 @@ describe('useCanvasPreview', () => { expect(ctx.activeTabId.value).toBe('wf-1'); }); }); + describe('tab picked by the user during a run', () => { + function buildMessage(toolCallId: string, workflowId: string) { + return makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolCallId, + toolName: 'build-workflow', + result: { success: true, workflowId }, + }), + ], + }), + }); + } + + async function startRunOnWf1() { + const ctx = setup(); + registerWorkflow(ctx.thread, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-2'); + ctx.thread.isStreaming = true; + ctx.thread.messages = [buildMessage('tc-1', 'wf-1')]; + await nextTick(); + expect(ctx.activeTabId.value).toBe('wf-1'); + return ctx; + } + + test('stays on the picked tab when the agent builds another workflow', async () => { + const ctx = await startRunOnWf1(); + ctx.selectTab('wf-2'); + const refreshKey = ctx.workflowRefreshKey.value; + + ctx.thread.messages = [buildMessage('tc-2', 'wf-1')]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-2'); + expect(ctx.isPreviewVisible.value).toBe(true); + // wf-1 is off screen; it mounts fresh when opened later. + expect(ctx.workflowRefreshKey.value).toBe(refreshKey); + }); + + test('still refreshes the picked tab when the agent builds that one', async () => { + const ctx = await startRunOnWf1(); + ctx.selectTab('wf-2'); + const refreshKey = ctx.workflowRefreshKey.value; + + ctx.thread.messages = [buildMessage('tc-2', 'wf-2')]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-2'); + expect(ctx.workflowRefreshKey.value).toBe(refreshKey + 1); + }); + + test('stays on the picked tab when a builder starts editing another workflow', async () => { + const ctx = await startRunOnWf1(); + ctx.selectTab('wf-2'); + + ctx.thread.messages = [ + makeMessage({ + agentTree: makeAgentNode({ + children: [ + makeAgentNode({ + agentId: 'agent-builder-1', + role: 'workflow-builder', + kind: 'builder', + status: 'active', + targetResource: { type: 'workflow', id: 'wf-1' }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-2'); + }); + + test('drops the pick when the picked data table is deleted', async () => { + const ctx = await startRunOnWf1(); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); + ctx.selectTab('dt-1'); + + const deleteMessage = makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolCallId: 'tc-delete', + toolName: 'data-tables', + args: { action: 'delete', dataTableId: 'dt-1' }, + result: { success: true }, + }), + ], + }), + }); + ctx.thread.messages = [deleteMessage]; + await nextTick(); + expect(ctx.activeTabId.value).toBe('wf-1'); + + ctx.thread.messages = [deleteMessage, buildMessage('tc-2', 'wf-2')]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-2'); + }); + + test('follows the agent again once the run ends', async () => { + const ctx = await startRunOnWf1(); + ctx.selectTab('wf-2'); + + ctx.thread.isStreaming = false; + await nextTick(); + ctx.thread.isStreaming = true; + ctx.thread.messages = [buildMessage('tc-2', 'wf-1')]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-1'); + }); + + test('closing the preview drops the pick', async () => { + const ctx = await startRunOnWf1(); + ctx.selectTab('wf-2'); + ctx.closePreview(); + + ctx.thread.messages = [buildMessage('tc-2', 'wf-1')]; + await nextTick(); + + expect(ctx.activeTabId.value).toBe('wf-1'); + expect(ctx.isPreviewVisible.value).toBe(true); + }); + }); }); 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 f400060c647..0f4341526aa 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts @@ -14,6 +14,7 @@ import { type ExecutionResult, } from './canvasPreview.utils'; import { useBuildingArtifactIds } from './composables/useBuildingArtifactIds'; +import { useIsAgentWorking } from './composables/useIsAgentWorking'; import type { ThreadRuntime } from './instanceAi.store'; export interface ArtifactTab { @@ -73,6 +74,16 @@ export function useCanvasPreview({ ); const buildingArtifactIds = useBuildingArtifactIds(thread); + const isAgentWorking = useIsAgentWorking(thread); + + // Tab the user picked while the agent was working. The auto-open watchers + // below keep it in view until the run settles, so a streamed tool call + // cannot undo the click. The pick lapses when something else moves the + // selection, for example when the picked artifact is deleted. + const userTabId = ref(); + watch(isAgentWorking, (working) => { + if (!working) userTabId.value = undefined; + }); // All previewable artifacts in the current thread, derived from resource registry. const allArtifactTabs = computed((): ArtifactTab[] => { @@ -223,13 +234,23 @@ export function useCanvasPreview({ function selectTab(tabId: string) { activeTabId.value = tabId; + userTabId.value = isAgentWorking.value ? tabId : undefined; setPreviewOpen(true); } function closePreview() { + userTabId.value = undefined; setPreviewOpen(false); } + // Show an artifact the agent just touched. Do not override a tab the user + // picked during this run while it is still the one on screen. + function showAgentArtifact(tabId: string) { + const pinned = userTabId.value !== undefined && userTabId.value === activeTabId.value; + if (!pinned) activeTabId.value = tabId; + setPreviewOpen(true); + } + /** * Open or switch the preview to a workflow. * Returns true if the preview tab changed; false if the tab was already @@ -237,8 +258,7 @@ export function useCanvasPreview({ */ function openWorkflowPreview(workflowId: string): boolean { if (activeTabId.value === workflowId && isPreviewOpen.value) return false; - activeTabId.value = workflowId; - setPreviewOpen(true); + selectTab(workflowId); return true; } @@ -249,8 +269,7 @@ export function useCanvasPreview({ */ function openDataTablePreview(dataTableId: string, _projectId: string): boolean { if (activeTabId.value === dataTableId && isPreviewOpen.value) return false; - activeTabId.value = dataTableId; - setPreviewOpen(true); + selectTab(dataTableId); return true; } @@ -262,8 +281,7 @@ export function useCanvasPreview({ function openAgentPreview(agentId: string, projectId: string): boolean { linkedAgentTarget.value = { agentId, projectId }; if (activeTabId.value === agentId && isPreviewOpen.value) return false; - activeTabId.value = agentId; - setPreviewOpen(true); + selectTab(agentId); return true; } @@ -309,9 +327,12 @@ export function useCanvasPreview({ if (!toolCallId || !latestBuildResult.value) return; if (thread.isHydratingThread) return; - activeTabId.value = latestBuildResult.value.workflowId; - setPreviewOpen(true); - workflowRefreshKey.value++; + const targetId = latestBuildResult.value.workflowId; + showAgentArtifact(targetId); + // Refresh only the tab on screen; a tab opened later mounts fresh anyway. + if (activeTabId.value === targetId) { + workflowRefreshKey.value++; + } }, { flush: 'sync' }, ); @@ -340,8 +361,7 @@ export function useCanvasPreview({ if (!agentId || !latestBuilderTarget.value) return; if (thread.isHydratingThread) return; - activeTabId.value = latestBuilderTarget.value.workflowId; - setPreviewOpen(true); + showAgentArtifact(latestBuilderTarget.value.workflowId); }, { flush: 'sync' }, ); @@ -369,8 +389,7 @@ export function useCanvasPreview({ if (!agentId || !latestAgentBuilderTarget.value) return; if (thread.isHydratingThread) return; - activeTabId.value = latestAgentBuilderTarget.value.targetAgentId; - setPreviewOpen(true); + showAgentArtifact(latestAgentBuilderTarget.value.targetAgentId); }, { flush: 'sync' }, ); @@ -429,9 +448,10 @@ export function useCanvasPreview({ const targetId = latestUpdateResult.value.workflowId; - activeTabId.value = targetId; - setPreviewOpen(true); - workflowRefreshKey.value++; + showAgentArtifact(targetId); + if (activeTabId.value === targetId) { + workflowRefreshKey.value++; + } }, { flush: 'sync' }, ); @@ -455,9 +475,11 @@ export function useCanvasPreview({ if (!toolCallId || !latestDataTableResult.value) return; if (thread.isHydratingThread) return; - activeTabId.value = latestDataTableResult.value.dataTableId; - setPreviewOpen(true); - dataTableRefreshKey.value++; + const targetId = latestDataTableResult.value.dataTableId; + showAgentArtifact(targetId); + if (activeTabId.value === targetId) { + dataTableRefreshKey.value++; + } }, { flush: 'sync' }, );