mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(editor): Keep the artifact tab the user picks during an AI Assistant run (#38178)
This commit is contained in:
parent
179dbb35b6
commit
80da0c8c21
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user