fix(editor): Keep the artifact tab the user picks during an AI Assistant run (#38178)

This commit is contained in:
Raúl Gómez Morales 2026-09-09 11:20:12 +00:00 committed by GitHub
parent 179dbb35b6
commit 80da0c8c21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 224 additions and 21 deletions

View File

@ -1494,9 +1494,10 @@ async function dismissComposerContextChip() {
@resizeend="isResizingPreview = false"
>
<TabsRoot
v-model="preview.activeTabId.value"
:model-value="preview.activeTabId.value"
orientation="horizontal"
:class="$style.previewPanel"
@update:model-value="preview.selectTab"
>
<InstanceAiPreviewTabBar
:tabs="preview.allArtifactTabs.value"

View File

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, inject, reactive, ref, type PropType, type Ref } from 'vue';
import { defineComponent, h, inject, reactive, ref, type PropType, type Ref, nextTick } from 'vue';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
@ -1936,6 +1936,58 @@ describe('InstanceAiThreadView', () => {
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<HTMLElement>('[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,

View File

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

View File

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