From 9f0e653c5ae656641a2ba5cf9fd0abd052d94b19 Mon Sep 17 00:00:00 2001
From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com>
Date: Wed, 16 Sep 2026 13:49:17 +0000
Subject: [PATCH] feat(editor): Add the n8n Assistant panel to the agent
builder (#38726)
---
.../frontend/@n8n/i18n/src/locales/en.json | 3 +
.../AgentBuilderEditorColumn.spec.ts | 24 +
.../__tests__/AgentBuilderHeader.test.ts | 58 +-
.../agents/__tests__/AgentBuilderView.test.ts | 692 ++++++++++++++---
.../agents/__tests__/AgentInfoPanel.spec.ts | 27 +
.../__tests__/AgentModelSelector.spec.ts | 20 +-
.../__tests__/useAgentConfigAutosave.test.ts | 24 +
.../components/AgentBuilderEditorColumn.vue | 4 +-
.../agents/components/AgentBuilderHeader.vue | 19 +
.../agents/components/AgentInfoPanel.vue | 26 +-
.../agents/components/AgentModelSelector.vue | 20 +-
.../composables/useAgentConfigAutosave.ts | 8 +
.../src/features/agents/constants.ts | 2 +
.../agents/views/AgentBuilderView.vue | 372 ++++++---
.../ai/instanceAi/InstanceAiThreadView.vue | 21 +-
.../__tests__/useInstanceAiHandoff.test.ts | 60 +-
.../components/InstanceAiConversation.vue | 21 +-
.../components/InstanceAiThreadList.vue | 101 ++-
.../components/InstanceAiViewHeader.vue | 35 +-
.../__tests__/InstanceAiConversation.test.ts | 82 ++
.../__tests__/InstanceAiThreadList.test.ts | 139 ++++
.../composables/useInstanceAiHandoff.ts | 82 +-
.../src/features/ai/instanceAi/constants.ts | 2 +
.../instanceAi/embed/InstanceAiChatPanel.vue | 482 ++++++++++++
.../__tests__/InstanceAiChatPanel.test.ts | 730 ++++++++++++++++++
.../__tests__/instanceAiEmbed.types.test.ts | 46 ++
.../useInstanceAiEmbedThreads.test.ts | 42 +
.../instanceAi/embed/instanceAiEmbed.types.ts | 43 ++
.../embed/useInstanceAiEmbedThreads.ts | 17 +
.../ai/instanceAi/instanceAi.threadRuntime.ts | 21 +
30 files changed, 2965 insertions(+), 258 deletions(-)
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiThreadList.test.ts
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/InstanceAiChatPanel.test.ts
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/instanceAiEmbed.types.test.ts
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/useInstanceAiEmbedThreads.test.ts
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/instanceAiEmbed.types.ts
create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/embed/useInstanceAiEmbedThreads.ts
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 26537fc7202..42b32e673b2 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -6930,6 +6930,8 @@
"focusedNodes.unconfirmedTooltip": "Add as context for the assistant",
"instanceAi.view.title": "n8n Assistant",
"instanceAi.view.subtitle": "Chat with your n8n instance",
+ "instanceAi.embed.openFullAssistant": "Open in n8n Assistant",
+ "instanceAi.embed.close": "Close assistant",
"instanceAi.askAiAssistant": "Ask n8n Assistant",
"instanceAi.canvasActionPopover.title": "Try new n8n Assistant",
"instanceAi.canvasActionPopover.body": "Building, questions, and debugging with AI now happen in the n8n Assistant.",
@@ -8272,6 +8274,7 @@
"agents.builder.header.switcher.ariaLabel": "Switch agent",
"agents.builder.header.saving": "Saving…",
"agents.builder.header.saved": "Saved",
+ "agents.builder.header.editWithAi": "Edit with n8n Assistant",
"agents.builder.preview.button": "Preview",
"agents.builder.preview.showChat": "Show chat",
"agents.builder.preview.disabledTooltip": "Can't preview agent yet",
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts
index 4d386f72f8e..5eebf699fe4 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts
@@ -196,6 +196,7 @@ async function mountColumn(
template:
'
',
props: ['showModel', 'showInstructions'],
+ emits: ['update:config'],
},
AgentAdvancedPanel: true,
AgentSessionsListView: true,
@@ -278,6 +279,29 @@ describe('AgentBuilderEditorColumn', () => {
expect(wrapper.emitted('generate-eval-cases')).toHaveLength(1);
});
+ it('forwards the auto-applied-default meta from AgentInfoPanel to the host', async () => {
+ const wrapper = await mountColumn();
+
+ wrapper
+ .getComponent({ name: 'AgentInfoPanel' })
+ .vm.$emit('update:config', { model: 'openai/gpt-5-mini' }, { source: 'auto' });
+
+ expect(wrapper.emitted('update:config')?.[0]).toEqual([
+ { model: 'openai/gpt-5-mini' },
+ { source: 'auto' },
+ ]);
+ });
+
+ it('forwards a user-driven AgentInfoPanel config change without meta', async () => {
+ const wrapper = await mountColumn();
+
+ wrapper
+ .getComponent({ name: 'AgentInfoPanel' })
+ .vm.$emit('update:config', { instructions: 'x' });
+
+ expect(wrapper.emitted('update:config')?.[0]).toEqual([{ instructions: 'x' }, undefined]);
+ });
+
it('disables the evals CTA for a read-only agent', async () => {
const wrapper = await mountColumn({ activeMainTab: 'evals', canEditAgent: false });
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
index 09e5dff0938..e0ce5538be0 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
@@ -34,6 +34,11 @@ vi.mock('vue-router', () => ({
}));
vi.mock('@n8n/design-system', () => ({
+ N8nAssistantIcon: {
+ name: 'N8nAssistantIcon',
+ template: '',
+ props: ['size'],
+ },
N8nIcon: { template: '', props: ['icon', 'size'] },
N8nButton: {
template:
@@ -135,6 +140,8 @@ function mountHeader(
mode: 'edit' | 'preview';
artifactMode: boolean;
isPreviewOpen: boolean;
+ instanceAiAvailable: boolean;
+ isAiPanelOpen: boolean;
currentSessionTitle: string;
sessionOptions: Array<{ id: string; label: string }>;
configValidationStatus: 'valid' | 'invalid' | null;
@@ -143,7 +150,7 @@ function mountHeader(
) {
return mount(AgentBuilderHeader, {
props: {
- agent: overrides.agent ?? baseAgent,
+ agent: 'agent' in overrides ? (overrides.agent ?? null) : baseAgent,
projectId: 'p1',
agentId: 'a1',
projectName: 'projectName' in overrides ? (overrides.projectName ?? null) : 'My project',
@@ -151,6 +158,8 @@ function mountHeader(
mode: overrides.mode,
artifactMode: overrides.artifactMode,
isPreviewOpen: overrides.isPreviewOpen,
+ instanceAiAvailable: overrides.instanceAiAvailable,
+ isAiPanelOpen: overrides.isAiPanelOpen,
currentSessionTitle: overrides.currentSessionTitle,
sessionOptions: overrides.sessionOptions,
configValidationStatus: overrides.configValidationStatus,
@@ -168,6 +177,53 @@ describe('AgentBuilderHeader', () => {
agentsListRef.value = null;
});
+ it('shows the Instance AI toggle when available', () => {
+ const wrapper = mountHeader({ instanceAiAvailable: true, isAiPanelOpen: false });
+ const button = wrapper.get('[data-testid="agent-builder-instance-ai-btn"]');
+
+ expect(button.attributes('aria-label')).toBe('agents.builder.header.editWithAi');
+ expect(button.attributes('aria-pressed')).toBe('false');
+ });
+
+ it('reflects isAiPanelOpen as aria-pressed', () => {
+ const wrapper = mountHeader({ instanceAiAvailable: true, isAiPanelOpen: true });
+
+ expect(
+ wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').attributes('aria-pressed'),
+ ).toBe('true');
+ });
+
+ it('emits toggle-instance-ai on click', async () => {
+ const wrapper = mountHeader({ instanceAiAvailable: true });
+
+ await wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').trigger('click');
+
+ expect(wrapper.emitted('toggle-instance-ai')).toEqual([[]]);
+ });
+
+ it.each([
+ { label: 'Instance AI is unavailable', instanceAiAvailable: false },
+ { label: 'artifact mode is active', instanceAiAvailable: true, artifactMode: true },
+ ])('hides the Instance AI toggle when $label', (overrides) => {
+ const wrapper = mountHeader(overrides);
+
+ expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
+ });
+
+ it('stays visible while the preview is open (both docks can coexist)', () => {
+ const wrapper = mountHeader({ instanceAiAvailable: true, isPreviewOpen: true });
+
+ expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true);
+ });
+
+ it('disables the Instance AI toggle when no agent is loaded', () => {
+ const wrapper = mountHeader({ agent: null, instanceAiAvailable: true });
+
+ expect(
+ wrapper.get('[data-testid="agent-builder-instance-ai-btn"]').attributes('disabled'),
+ ).toBeDefined();
+ });
+
it('renders breadcrumbs, publish and action dropdown', () => {
const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
expect(wrapper.find('[data-testid="stub-breadcrumbs"]').exists()).toBe(true);
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
index 840fd5e25db..97aad22f5e1 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
@@ -14,12 +14,25 @@ import type {
} from '../types';
import { getRandomAgentPersonalisationGradient } from '@n8n/api-types';
import { agentsEventBus } from '../agents.eventBus';
-import { NEW_SESSION_PARAM, OPEN_PREVIEW_PARAM } from '../constants';
+import {
+ AGENT_BUILDER_VIEW,
+ AGENT_PREVIEW_VIEW,
+ NEW_SESSION_PARAM,
+ OPEN_PREVIEW_PARAM,
+} from '../constants';
const routerPush = vi.fn();
const routerReplace = vi.fn();
const routeQuery = reactive>({});
const routeParams = reactive({ projectId: 'p1', agentId: 'a1' });
+// A single reactive `route` (rather than a fresh plain object per `useRoute()`
+// call) so mutating `.name` — e.g. to the standalone preview route — reacts
+// like `params`/`query` mutations already do.
+const routeState = reactive({
+ name: AGENT_BUILDER_VIEW as string,
+ params: routeParams,
+ query: routeQuery,
+});
type RouteGuard = (to: { params: Record }) => void | Promise;
const routeGuards: { leave?: RouteGuard; update?: RouteGuard } = {};
const openModalWithDataMock = vi.fn();
@@ -28,7 +41,7 @@ const showMessageMock = vi.fn();
const showErrorMock = vi.fn();
const pushConnectMock = vi.fn();
const pushListeners = new Set<(event: PushMessage) => void>();
-const sendPreviewSessionToInstanceAiMock = vi.fn();
+const handoffMock = vi.fn();
let createObjectURLSpy: ReturnType | undefined;
let revokeObjectURLSpy: ReturnType | undefined;
let anchorClickSpy: ReturnType | undefined;
@@ -55,12 +68,9 @@ vi.mock('vue-router', () => ({
useRouter: () => ({
push: routerPush,
replace: routerReplace,
+ resolve: () => ({ href: '#' }),
}),
- useRoute: () => ({
- name: 'AgentBuilderView',
- params: routeParams,
- query: routeQuery,
- }),
+ useRoute: () => routeState,
onBeforeRouteLeave: (guard: RouteGuard) => {
routeGuards.leave = guard;
},
@@ -100,7 +110,6 @@ vi.mock('@n8n/composables/useTelemetry', () => ({
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff', () => ({
useInstanceAiAgentPreviewHandoff: () => ({
canSendPreviewToInstanceAi: ref(true),
- sendPreviewSessionToInstanceAi: sendPreviewSessionToInstanceAiMock,
}),
}));
@@ -340,17 +349,15 @@ vi.mock('../composables/useProjectAgentsList', () => ({
}));
const instanceAiAvailableRef = ref(true);
+const instanceAiReadyRef = ref(true);
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () => ({
useInstanceAiAvailable: () => computed(() => instanceAiAvailableRef.value),
+ useInstanceAiReady: () => computed(() => instanceAiReadyRef.value),
}));
-const startInstanceAiThread = vi.fn();
-const openAgentArtifactThread = vi.fn();
-vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({
- useInstanceAiHandoff: () => ({
- startThread: startInstanceAiThread,
- openAgentArtifactThread,
- }),
+const persistPendingAgentMock = vi.fn();
+vi.mock('@/features/ai/instanceAi/instanceAi.memory.api', () => ({
+ persistPendingAgent: (...args: unknown[]) => persistPendingAgentMock(...args),
}));
const baseTextFn = (
@@ -500,7 +507,7 @@ const commonStubs = {
AgentBuilderHeader: {
name: 'AgentBuilderHeader',
template:
- '',
+ '',
props: [
'agent',
'projectId',
@@ -510,6 +517,8 @@ const commonStubs = {
'beforeRevertToPublished',
'artifactMode',
'isPreviewOpen',
+ 'instanceAiAvailable',
+ 'isAiPanelOpen',
'configValidationStatus',
'saveStatus',
'beforePublish',
@@ -523,6 +532,7 @@ const commonStubs = {
'reverted',
'switch-agent',
'toggle-version-history',
+ 'toggle-instance-ai',
],
},
AgentPreviewDock: {
@@ -556,6 +566,24 @@ const commonStubs = {
name: 'AgentVersionHistoryPanel',
template: '',
},
+ InstanceAiChatPanel: {
+ name: 'InstanceAiChatPanel',
+ template:
+ '' +
+ '' +
+ '' +
+ '' +
+ '
',
+ props: ['subject', 'launch', 'threadId', 'beforeNewThread', 'beforeSend'],
+ emits: ['update:threadId', 'update:building', 'close'],
+ // Stands in for the real `defineExpose`d `handoff` — the view calls this
+ // through a template ref, not a prop or emit.
+ methods: { handoff: handoffMock },
+ },
+ AgentBuildingIndicator: {
+ name: 'AgentBuildingIndicator',
+ template: '',
+ },
// Stub each panel that the editor column dispatches to. These panels pull
// in stores / composables (users, credentials, sessions list)
// that the view-level test isn't trying to exercise — leaving them real
@@ -648,7 +676,13 @@ function resetViewMocks() {
vi.clearAllMocks();
for (let index = localStorage.length - 1; index >= 0; index--) {
const key = localStorage.key(index);
- if (key?.startsWith('N8N_AGENT_PREVIEW_OPEN')) localStorage.removeItem(key);
+ if (
+ key?.startsWith('N8N_AGENT_PREVIEW_OPEN') ||
+ key?.startsWith('N8N_AGENT_AI_PANEL_OPEN') ||
+ key === 'N8N_AGENT_AI_PANEL_WIDTH'
+ ) {
+ localStorage.removeItem(key);
+ }
}
routerPush.mockReset();
routerReplace.mockReset();
@@ -665,6 +699,7 @@ function resetViewMocks() {
closeModalMock.mockReset();
routeParams.projectId = 'p1';
routeParams.agentId = 'a1';
+ routeState.name = AGENT_BUILDER_VIEW;
routeGuards.leave = undefined;
routeGuards.update = undefined;
agentEvalsFlagMock.enabled = false;
@@ -703,8 +738,7 @@ function resetViewMocks() {
builderTelemetryMock.fetchInitialTriggersBaseline.mockResolvedValue(null);
favoritesStoreMock.isFavorite.mockReturnValue(false);
instanceAiAvailableRef.value = true;
- startInstanceAiThread.mockReset();
- openAgentArtifactThread.mockReset();
+ instanceAiReadyRef.value = true;
}
// First Vite transform of this SFC + design-system deps can exceed the default
@@ -764,6 +798,194 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
expect(fetchSessionThreadsMock).toHaveBeenCalledWith('p1', 'a1');
});
+ it('does not persist a pending agent for an auto-applied default model', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ routeQuery.assistantThread = 'thread-abc';
+ persistPendingAgentMock.mockResolvedValueOnce({
+ agent: makeAgentResponse(),
+ thread: { id: 'thread-abc' },
+ });
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+
+ editor.vm.$emit('update:config', { model: 'openai/gpt-5-mini' }, { source: 'auto' });
+ await flushPromises();
+
+ expect(createAgentMock).not.toHaveBeenCalled();
+ expect(persistPendingAgentMock).not.toHaveBeenCalled();
+ expect(updateConfigMock).not.toHaveBeenCalled();
+ expect(editor.props('localConfig')).toEqual(
+ expect.objectContaining({ model: 'openai/gpt-5-mini' }),
+ );
+
+ editor.vm.$emit('update:config', { instructions: 'x' });
+ await vi.waitFor(() => expect(updateConfigMock).toHaveBeenCalled());
+
+ expect(createAgentMock).not.toHaveBeenCalled();
+ expect(persistPendingAgentMock).toHaveBeenCalledOnce();
+ expect(updateConfigMock).toHaveBeenCalledWith(
+ 'p1',
+ 'a1',
+ expect.objectContaining({ model: 'openai/gpt-5-mini', instructions: 'x' }),
+ expect.anything(),
+ );
+ });
+
+ it('merges a later auto-applied default model into an already-queued autosave snapshot', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+
+ // A real edit queues an autosave snapshot for the still-unsaved agent...
+ editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
+ // ...then the seeded default model arrives before that snapshot has fired.
+ // Without a reschedule the queued snapshot — already deep-copied — would
+ // save without the model.
+ editor.vm.$emit('update:config', { model: 'openai/gpt-5-mini' }, { source: 'auto' });
+
+ await vi.waitFor(() => expect(updateConfigMock).toHaveBeenCalled());
+
+ expect(createAgentMock).toHaveBeenCalledOnce();
+ expect(updateConfigMock).toHaveBeenCalledTimes(1);
+ expect(updateConfigMock).toHaveBeenCalledWith(
+ 'p1',
+ 'a1',
+ expect.objectContaining({
+ instructions: 'Answer support mail',
+ model: 'openai/gpt-5-mini',
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('chains a second save carrying a later auto-applied default model behind an in-flight save', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const pendingCreate = Promise.withResolvers>();
+ createAgentMock.mockReturnValueOnce(pendingCreate.promise);
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+
+ // A real edit fires the first autosave, which stalls on the pending-agent
+ // create — the agent is still unsaved while this is in flight.
+ editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
+ await vi.waitFor(() => expect(createAgentMock).toHaveBeenCalledOnce());
+
+ // The seeded default model arrives while that save is still in flight —
+ // with no snapshot queued (it's already sent), the "rides along with the
+ // draft" guard must still chain a second save rather than drop the model.
+ editor.vm.$emit('update:config', { model: 'openai/gpt-5-mini' }, { source: 'auto' });
+ await flushPromises();
+
+ pendingCreate.resolve(makeAgentResponse({ id: 'a1' }));
+ await vi.waitFor(() => expect(updateConfigMock).toHaveBeenCalledTimes(2));
+
+ expect(updateConfigMock).toHaveBeenLastCalledWith(
+ 'p1',
+ 'a1',
+ expect.objectContaining({
+ instructions: 'Answer support mail',
+ model: 'openai/gpt-5-mini',
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('adopts the agent through its bound assistant thread instead of a strict create', async () => {
+ // A thread is already bound (the embedded assistant panel minted one) —
+ // persist through it so a race with the assistant's own create converges
+ // on one row instead of failing with a conflict.
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ routeQuery.assistantThread = 'thread-abc';
+ persistPendingAgentMock.mockResolvedValueOnce({
+ agent: makeAgentResponse(),
+ thread: { id: 'thread-abc' },
+ });
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+
+ editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
+ await vi.waitFor(() => expect(updateConfigMock).toHaveBeenCalled());
+
+ expect(persistPendingAgentMock).toHaveBeenCalledWith(expect.anything(), 'thread-abc', {
+ projectId: 'p1',
+ agentId: 'a1',
+ name: expect.any(String),
+ });
+ expect(createAgentMock).not.toHaveBeenCalled();
+ expect(history.state.instanceAiPendingAgentId).toBeUndefined();
+ });
+
+ it('marks a route-pending agent persisted once the embedded assistant builds it externally', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+ expect(editor.props('agentUnsaved')).toBe(true);
+
+ // The assistant created the agent under our minted id — the refetch this
+ // triggers must confirm the row now exists and clear the pending marker,
+ // the same as `ensureAgentPersisted` does for a local edit.
+ vi.useFakeTimers();
+ try {
+ agentsEventBus.emit('agentUpdated', { agentId: 'a1', source: 'instance-ai' });
+ await vi.advanceTimersByTimeAsync(400);
+ } finally {
+ vi.useRealTimers();
+ }
+ await flushPromises();
+
+ expect(getAgentMock).toHaveBeenCalledWith(expect.anything(), 'p1', 'a1');
+ expect(editor.props('agentUnsaved')).toBe(false);
+ expect(history.state.instanceAiPendingAgentId).toBeUndefined();
+ expect(createAgentMock).not.toHaveBeenCalled();
+ });
+
+ it('discards a stale external refresh instead of marking a different pending agent persisted', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ expect(wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('agentUnsaved')).toBe(
+ true,
+ );
+
+ // Stall the refetch this refresh triggers, so the target can change while
+ // it is still in flight.
+ const pendingGetAgent = Promise.withResolvers>();
+ getAgentMock.mockReturnValueOnce(pendingGetAgent.promise);
+
+ vi.useFakeTimers();
+ try {
+ // Agent A's assistant-triggered refresh is scheduled and fires...
+ agentsEventBus.emit('agentUpdated', { agentId: 'a1', source: 'instance-ai' });
+ await vi.advanceTimersByTimeAsync(400);
+ } finally {
+ vi.useRealTimers();
+ }
+ await flushPromises();
+
+ // ...but before it resolves the user switches to a different, still-pending
+ // agent B (its own history-state marker, same as a fresh mount would read).
+ (wrapper.vm as unknown as { routePendingAgentId: string | null }).routePendingAgentId = 'a2';
+ history.replaceState({ instanceAiPendingAgentId: 'a2' }, '');
+ routeParams.agentId = 'a2';
+ await flushPromises();
+ expect(wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('agentUnsaved')).toBe(
+ true,
+ );
+
+ pendingGetAgent.resolve(makeAgentResponse({ id: 'a1' }));
+ await flushPromises();
+
+ // A's refresh continuation must not mark agent B persisted.
+ const editorB = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+ expect(editorB.props('agentUnsaved')).toBe(true);
+ expect(history.state.instanceAiPendingAgentId).toBe('a2');
+
+ editorB.vm.$emit('update:config', { instructions: 'Answer support mail' });
+ await vi.waitFor(() => expect(createAgentMock).toHaveBeenCalled());
+ expect(createAgentMock).toHaveBeenCalledWith(expect.anything(), 'p1', expect.any(String), {
+ id: 'a2',
+ });
+ });
+
it('settles a route-backed create before switching agents', async () => {
history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
const pendingCreate = Promise.withResolvers>();
@@ -950,62 +1172,66 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
it.each([
{ label: 'without execution context', event: undefined },
{ label: 'with execution context', event: fixEvent },
- ])('sends the active preview session to Instance AI $label', async ({ event }) => {
- localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
- routeQuery.continueSessionId = 'thread-1';
- fetchedSessionThreads.push({
- id: 'thread-1',
- updatedAt: '2026-01-01T00:00:00Z',
- title: 'Failed order lookup',
- sessionNumber: 7,
- });
+ ])(
+ 'opens the embedded assistant panel and hands it the active preview session $label',
+ async ({ event }) => {
+ handoffMock.mockReturnValueOnce(true);
+ localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
+ routeQuery.continueSessionId = 'thread-1';
+ fetchedSessionThreads.push({
+ id: 'thread-1',
+ updatedAt: '2026-01-01T00:00:00Z',
+ title: 'Failed order lookup',
+ sessionNumber: 7,
+ });
- const wrapper = await renderView();
- const preview = wrapper.findComponent({ name: 'AgentPreviewDock' });
+ const wrapper = await renderView();
+ const preview = wrapper.findComponent({ name: 'AgentPreviewDock' });
- expect(preview.props('canSendToAssistant')).toBe(true);
- preview.vm.$emit('send-to-assistant', event);
- await flushPromises();
+ expect(preview.props('canSendToAssistant')).toBe(true);
+ preview.vm.$emit('send-to-assistant', event);
+ await flushPromises();
- expect(sendPreviewSessionToInstanceAiMock).toHaveBeenCalledWith({
- projectId: 'p1',
- agentId: 'a1',
- threadId: 'thread-1',
- agentName: 'Agent One',
- agentIcon: 'bot',
- sessionTitle: 'Failed order lookup',
- ...(event
- ? {
- executionId: event.executionId,
- initialDraft: {
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ expect(handoffMock).toHaveBeenCalledWith(
+ {
+ source: 'agent-preview',
+ agentId: 'a1',
+ threadId: 'thread-1',
+ agentName: 'Agent One',
+ agentIcon: 'bot',
+ sessionTitle: 'Failed order lookup',
+ ...(event ? { executionId: event.executionId } : {}),
+ },
+ event
+ ? {
text: expect.any(String),
prefillType: 'handoff_agent_change_request',
- },
- }
- : {}),
- });
-
- if (event) {
- const initialDraft =
- sendPreviewSessionToInstanceAiMock.mock.calls[0]?.[0]?.initialDraft?.text;
- expect(initialDraft).toContain(
- 'Review these failed tool calls, identify the root cause, fix the agent, and verify the change.',
+ }
+ : undefined,
);
- expect(initialDraft).toContain('Agent One');
- expect(initialDraft).toContain('Failed order lookup');
- expect(initialDraft).toContain('thread-1');
- expect(initialDraft).toContain('exec-turn-1');
- expect(initialDraft).toContain('Get rows from Data Table');
- expect(initialDraft).toContain('Update row in Data Table');
- expect(initialDraft?.match(/Column \\"status\\" does not exist/g)).toHaveLength(1);
- }
- });
+
+ if (event) {
+ const initialDraft = handoffMock.mock.calls[0]?.[1]?.text as string | undefined;
+ expect(initialDraft).toContain(
+ 'Review these failed tool calls, identify the root cause, fix the agent, and verify the change.',
+ );
+ expect(initialDraft).toContain('Agent One');
+ expect(initialDraft).toContain('Failed order lookup');
+ expect(initialDraft).toContain('thread-1');
+ expect(initialDraft).toContain('exec-turn-1');
+ expect(initialDraft).toContain('Get rows from Data Table');
+ expect(initialDraft).toContain('Update row in Data Table');
+ expect(initialDraft?.match(/Column \\"status\\" does not exist/g)).toHaveLength(1);
+ }
+ },
+ );
it.each([
- ['closes the preview once the assistant has it', true, 'false'],
- ['leaves the preview open when the assistant did not open', false, 'true'],
- ])('%s', async (_label, opened, expectedStored) => {
- sendPreviewSessionToInstanceAiMock.mockResolvedValueOnce(opened);
+ ['closes the preview once the panel accepts the hand-off', true, 'false'],
+ ['leaves the preview open when the panel refuses the hand-off', false, 'true'],
+ ])('%s', async (_label, handed, expectedStored) => {
+ handoffMock.mockReturnValueOnce(handed);
localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
routeQuery.continueSessionId = 'thread-1';
fetchedSessionThreads.push({ id: 'thread-1', updatedAt: '2026-01-01T00:00:00Z' });
@@ -1017,6 +1243,62 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
expect(localStorage.getItem('N8N_AGENT_PREVIEW_OPEN:p1:a1')).toBe(expectedStored);
});
+ it('routes to the assistant setup instead of handing off the preview session when Instance AI is not ready', async () => {
+ instanceAiReadyRef.value = false;
+ localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true');
+ routeQuery.continueSessionId = 'thread-1';
+ fetchedSessionThreads.push({ id: 'thread-1', updatedAt: '2026-01-01T00:00:00Z' });
+
+ const wrapper = await renderView();
+ wrapper.findComponent({ name: 'AgentPreviewDock' }).vm.$emit('send-to-assistant');
+ await flushPromises();
+
+ expect(routerPush).toHaveBeenCalledWith({ name: 'InstanceAi' });
+ expect(handoffMock).not.toHaveBeenCalled();
+ expect(localStorage.getItem('N8N_AGENT_PREVIEW_OPEN:p1:a1')).toBe('true');
+ });
+
+ it('queues a hand-off requested from the standalone preview route and applies it once the assistant panel mounts', async () => {
+ // The AI dock isn't rendered on the standalone preview route, so
+ // `aiPanelRef` never resolves there — the hand-off must wait for the
+ // panel to mount once the dock close navigates back to the builder.
+ routeState.name = AGENT_PREVIEW_VIEW;
+ handoffMock.mockReturnValueOnce(true);
+ routeQuery.continueSessionId = 'thread-1';
+ fetchedSessionThreads.push({
+ id: 'thread-1',
+ updatedAt: '2026-01-01T00:00:00Z',
+ title: 'Failed order lookup',
+ });
+
+ const wrapper = await renderView();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+
+ wrapper.findComponent({ name: 'AgentChatPanel' }).vm.$emit('send-to-assistant');
+ await flushPromises();
+
+ expect(routerPush).toHaveBeenCalledWith(expect.objectContaining({ name: AGENT_BUILDER_VIEW }));
+ expect(handoffMock).not.toHaveBeenCalled();
+
+ // Simulate that navigation landing: the builder route renders the panel,
+ // and the queued watcher applies the hand-off once it mounts.
+ routeState.name = AGENT_BUILDER_VIEW;
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ expect(handoffMock).toHaveBeenCalledWith(
+ {
+ source: 'agent-preview',
+ agentId: 'a1',
+ threadId: 'thread-1',
+ agentName: 'Agent One',
+ agentIcon: 'bot',
+ sessionTitle: 'Failed order lookup',
+ },
+ undefined,
+ );
+ });
+
it('keeps an artifact on the selected preview session and stages the handoff in its Assistant thread', async () => {
fetchedSessionThreads.push(
{
@@ -1045,7 +1327,7 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
preview.vm.$emit('send-to-assistant', fixEvent);
await flushPromises();
- expect(sendPreviewSessionToInstanceAiMock).not.toHaveBeenCalled();
+ expect(handoffMock).not.toHaveBeenCalled();
expect(wrapper.emitted('assistant-handoff')).toEqual([
[
expect.objectContaining({
@@ -2228,12 +2510,90 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find('[data-testid="stub-agent-builder-header"]').exists()).toBe(true);
});
- it('renders the floating Instance AI button in builder mode', async () => {
+ it('opens the embedded AI panel by default for a pending agent', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
const wrapper = await renderView();
- expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true);
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ expect(
+ wrapper.find('[data-testid="stub-agent-builder-header"]').attributes('data-ai-panel-open'),
+ ).toBe('true');
});
- it('hides the floating Instance AI button in artifact mode', async () => {
+ it('keeps the AI panel open after a pending agent persists', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ createAgentMock.mockResolvedValueOnce(makeAgentResponse());
+ const wrapper = await renderView();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+ editor.vm.$emit('update:config', { instructions: 'Answer support mail' });
+ await vi.waitFor(() => expect(createAgentMock).toHaveBeenCalled());
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ });
+
+ it('keeps the AI panel open after the assistant builds a pending agent', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+
+ vi.useFakeTimers();
+ try {
+ agentsEventBus.emit('agentUpdated', { agentId: 'a1', source: 'instance-ai' });
+ await vi.advanceTimersByTimeAsync(400);
+ } finally {
+ vi.useRealTimers();
+ }
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ });
+
+ it('keeps the embedded AI panel closed by default for an existing agent', async () => {
+ const wrapper = await renderView();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+ expect(
+ wrapper.find('[data-testid="stub-agent-builder-header"]').attributes('data-ai-panel-open'),
+ ).toBe('false');
+ });
+
+ it('keeps the embedded AI panel closed by default for a pending agent when Instance AI is not ready, and writes nothing to storage', async () => {
+ instanceAiReadyRef.value = false;
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+ expect(localStorage.getItem('N8N_AGENT_AI_PANEL_OPEN:p1:a1')).toBeNull();
+ });
+
+ it('persists an explicit toggle of the AI panel', async () => {
+ const wrapper = await renderView();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+
+ await wrapper.find('[data-testid="stub-toggle-instance-ai"]').trigger('click');
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+ expect(localStorage.getItem('N8N_AGENT_AI_PANEL_OPEN:p1:a1')).toBe('true');
+ });
+
+ it('closes the AI panel by default after switching to a different, non-pending agent', async () => {
+ // `useStorage`'s default is captured once and reused for every later key
+ // this computed storage key produces — a non-constant default would leak
+ // "open" from the pending agent onto every agent visited afterward.
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+
+ routeParams.agentId = 'a2';
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+ });
+
+ it('hides the embedded AI panel dock in artifact mode', async () => {
const wrapper = await renderView({
props: {
artifactMode: true,
@@ -2242,13 +2602,18 @@ describe('AgentBuilderView — three-column shell', () => {
},
});
- expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
});
- it('hides the floating Instance AI button when Instance AI is unavailable', async () => {
+ it('reports Instance AI availability to the header', async () => {
instanceAiAvailableRef.value = false;
const wrapper = await renderView();
- expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
+
+ expect(
+ wrapper
+ .find('[data-testid="stub-agent-builder-header"]')
+ .attributes('data-instance-ai-available'),
+ ).toBe('false');
});
it.each([
@@ -2419,25 +2784,94 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find(externalUpdateSelector).exists()).toBe(false);
});
- it('opens the agent artifact without sending an opening message', async () => {
+ it('toggles the AI panel from the header', async () => {
const wrapper = await renderView();
- await wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').trigger('click');
- await flushPromises();
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
- expect(openAgentArtifactThread).toHaveBeenCalledWith(
- {
- type: 'agent',
- id: 'a1',
- name: 'Agent One',
- projectId: 'p1',
- },
- {
- source: 'agent_builder_page',
- origin: 'internal',
- sourceContext: { agentId: 'a1' },
- },
+ await wrapper.find('[data-testid="stub-toggle-instance-ai"]').trigger('click');
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+
+ await wrapper.find('[data-testid="stub-toggle-instance-ai"]').trigger('click');
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+ });
+
+ it('routes to the assistant setup instead of opening the panel when setup is unfinished', async () => {
+ instanceAiReadyRef.value = false;
+ const wrapper = await renderView();
+
+ await wrapper.find('[data-testid="stub-toggle-instance-ai"]').trigger('click');
+
+ expect(routerPush).toHaveBeenCalledWith({ name: 'InstanceAi' });
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
+ });
+
+ it('keys the embedded AI panel by agent id so an in-place switch remounts it', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ const panel = wrapper.findComponent({ name: 'InstanceAiChatPanel' });
+
+ expect((panel.vm as unknown as { $: { vnode: { key: string | null } } }).$.vnode.key).toBe(
+ 'a1',
);
- expect(startInstanceAiThread).not.toHaveBeenCalled();
+ });
+
+ it('passes flushAutosave as before-send to the embedded AI panel', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ const panel = wrapper.findComponent({ name: 'InstanceAiChatPanel' });
+
+ expect(panel.props('beforeSend')).toBe(
+ (wrapper.vm as unknown as { flushAutosave: () => Promise }).flushAutosave,
+ );
+ });
+
+ it('locks editing and shows the building indicator while the embedded assistant builds', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+ const editor = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+ expect(editor.props('canEditAgent')).toBe(true);
+ expect(wrapper.find('[data-testid="stub-agent-building-indicator"]').exists()).toBe(false);
+
+ await wrapper.find('[data-testid="ai-panel-emit-building"]').trigger('click');
+
+ expect(editor.props('canEditAgent')).toBe(false);
+ expect(wrapper.find('[data-testid="stub-agent-building-indicator"]').exists()).toBe(true);
+ });
+
+ it('writes the panel thread id to the assistantThread query param', async () => {
+ history.replaceState({ instanceAiPendingAgentId: 'a1' }, '');
+ const wrapper = await renderView();
+
+ await wrapper.find('[data-testid="ai-panel-emit-thread-id"]').trigger('click');
+
+ expect(routerReplace).toHaveBeenCalledWith({
+ query: expect.objectContaining({ assistantThread: 'thread-99' }),
+ });
+ });
+
+ it('drops the assistantThread query param when switching to another agent', async () => {
+ routeQuery.assistantThread = 'thread-99';
+ routeQuery.section = 'knowledge';
+ const wrapper = await renderView();
+
+ await wrapper.find('[data-testid="stub-switch-agent"]').trigger('click');
+
+ expect(routerPush).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: { projectId: 'p1', agentId: 'a2' },
+ query: { section: 'knowledge' },
+ }),
+ );
+ });
+
+ it('closes the AI panel when the embedded panel emits close', async () => {
+ const wrapper = await renderView();
+ await wrapper.find('[data-testid="stub-toggle-instance-ai"]').trigger('click');
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(true);
+
+ await wrapper.find('[data-testid="ai-panel-emit-close"]').trigger('click');
+
+ expect(wrapper.find('[data-testid="agent-ai-dock"]').exists()).toBe(false);
});
it('renders artifact mode with the editor and without the build chat', async () => {
@@ -2486,6 +2920,84 @@ describe('AgentBuilderView — three-column shell', () => {
expect(updateConfigMock).not.toHaveBeenCalled();
});
+ it('drops a queued MCP toggle when the artifact lock engages instead of persisting it', async () => {
+ const wrapper = await renderView({
+ props: {
+ artifactMode: true,
+ artifactProjectId: 'p2',
+ artifactAgentId: 'a2',
+ artifactEditingLocked: false,
+ },
+ });
+ const { useMCPStore } = await import('@/features/ai/mcpAccess/mcp.store');
+ const toggleAgentMcpAccess = vi
+ .spyOn(useMCPStore(), 'toggleAgentMcpAccess')
+ .mockResolvedValue({ updatedCount: 1, updatedIds: ['a2'], unchangedIds: [] });
+
+ // See the sibling MCP test below: a failed initialize() drops the toggle
+ // silently, so guard the mount before emitting.
+ const editorColumn = wrapper.findComponent({ name: 'AgentBuilderEditorColumn' });
+ expect(editorColumn.props('agent')).toBeTruthy();
+
+ vi.useFakeTimers();
+ try {
+ editorColumn.vm.$emit('toggle-mcp-access', true);
+
+ await wrapper.setProps({ artifactEditingLocked: true });
+ await vi.advanceTimersByTimeAsync(500);
+ } finally {
+ vi.useRealTimers();
+ }
+ await flushPromises();
+
+ expect(toggleAgentMcpAccess).not.toHaveBeenCalled();
+ });
+
+ it('resets the optimistic MCP override when a queued toggle is dropped by the artifact lock', async () => {
+ const wrapper = await renderView({
+ props: {
+ artifactMode: true,
+ artifactProjectId: 'p2',
+ artifactAgentId: 'a2',
+ artifactEditingLocked: false,
+ },
+ });
+ const { useMCPStore } = await import('@/features/ai/mcpAccess/mcp.store');
+ vi.spyOn(useMCPStore(), 'toggleAgentMcpAccess').mockResolvedValue({
+ updatedCount: 1,
+ updatedIds: ['a2'],
+ unchangedIds: [],
+ });
+
+ expect(
+ wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('agentAvailableInMcp'),
+ ).toBe(false);
+
+ vi.useFakeTimers();
+ try {
+ wrapper
+ .findComponent({ name: 'AgentBuilderEditorColumn' })
+ .vm.$emit('toggle-mcp-access', true);
+ await nextTick();
+ // The optimistic toggle shows immediately, before the lock drops it.
+ expect(
+ wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('agentAvailableInMcp'),
+ ).toBe(true);
+
+ await wrapper.setProps({ artifactEditingLocked: true });
+ await vi.advanceTimersByTimeAsync(500);
+ } finally {
+ vi.useRealTimers();
+ }
+ await flushPromises();
+
+ // The queued save was dropped — the override must not keep showing an
+ // unsaved value after the lock releases.
+ expect(
+ wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('agentAvailableInMcp'),
+ ).toBe(false);
+ });
+
it('flushes a pending MCP toggle before switching agents', async () => {
const wrapper = await renderView({
props: {
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
index 823e89285d5..8ee247ff910 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
@@ -307,6 +307,30 @@ describe('AgentInfoPanel', () => {
toolCallConcurrency: 2,
promptCaching: { enabled: true },
});
+ // A user-driven pick carries no meta — only an auto-applied default does.
+ expect(events.at(-1)?.[1]).toBeUndefined();
+ });
+
+ it('forwards the model selector\'s own "auto" source through to update:config', async () => {
+ const config: AgentJsonConfig = {
+ name: 'Support agent',
+ model: 'anthropic/claude-sonnet-4-5',
+ credential: 'credential-1',
+ instructions: 'Help users.',
+ };
+ const wrapper = mountModelPanel(config);
+
+ // The selector resolves its own verified default after a credential
+ // selection and tags it 'auto' — the panel must forward that tag, not
+ // treat it like a direct user pick.
+ wrapper
+ .findComponent({ name: 'AgentModelSelector' })
+ .vm.$emit('change', { provider: 'anthropic', model: 'claude-3-haiku' }, 'auto');
+ await wrapper.vm.$nextTick();
+
+ const events = wrapper.emitted('update:config') ?? [];
+ expect(events).toHaveLength(1);
+ expect(events[0][1]).toEqual({ source: 'auto' });
});
it('preserves reasoning when selecting a model that supports it', async () => {
@@ -382,6 +406,7 @@ describe('AgentInfoPanel', () => {
model: 'anthropic/claude-sonnet-4-5',
credential: 'credential-1',
}),
+ { source: 'auto' },
]);
});
@@ -409,6 +434,7 @@ describe('AgentInfoPanel', () => {
model: 'anthropic/claude-sonnet-4-5',
credential: 'credential-1',
}),
+ { source: 'auto' },
]);
expect(wrapper.find('[data-testid="agent-default-model-hint"]').exists()).toBe(true);
});
@@ -437,6 +463,7 @@ describe('AgentInfoPanel', () => {
model: 'openai/gpt-5-mini',
credential: AI_GATEWAY_MANAGED_TAG,
}),
+ { source: 'auto' },
]);
});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts
index c0233803094..eff78dc8f46 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts
@@ -645,6 +645,16 @@ describe('AgentModelSelector', () => {
expect(wrapper.emitted('change')).toBeUndefined();
});
+ it('emits change with no auto source for a direct model pick', async () => {
+ const wrapper = await mountSelector({ anthropic: 'anthropic-cred' });
+
+ getDropdown(wrapper).vm.$emit('select', 'anthropic::model::claude-sonnet-4-5');
+
+ expect(wrapper.emitted('change')).toEqual([
+ [{ provider: 'anthropic', model: 'claude-sonnet-4-5' }],
+ ]);
+ });
+
it('selects gpt-5-mini when an existing free OpenAI credits credential is selected', async () => {
credentialsByType.value = {
openAiApi: [
@@ -662,7 +672,10 @@ describe('AgentModelSelector', () => {
await wrapper.vm.$nextTick();
expect(wrapper.emitted('selectCredential')).toEqual([['openai', 'free-openai-credential']]);
- expect(wrapper.emitted('change')).toEqual([[{ provider: 'openai', model: 'gpt-5-mini' }]]);
+ // Resolved after a credential selection, not a direct model pick — 'auto'.
+ expect(wrapper.emitted('change')).toEqual([
+ [{ provider: 'openai', model: 'gpt-5-mini' }, 'auto'],
+ ]);
});
it('groups the submenu with "Connect to " and "Models" section headers', async () => {
@@ -858,6 +871,9 @@ describe('AgentModelSelector', () => {
'project-1',
);
expect(wrapper.emitted('selectCredential')).toEqual([['openai', 'free-openai-credential']]);
- expect(wrapper.emitted('change')).toEqual([[{ provider: 'openai', model: 'gpt-5-mini' }]]);
+ // Resolved after a credential selection, not a direct model pick — 'auto'.
+ expect(wrapper.emitted('change')).toEqual([
+ [{ provider: 'openai', model: 'gpt-5-mini' }, 'auto'],
+ ]);
});
});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentConfigAutosave.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentConfigAutosave.test.ts
index b2d0bfcdc2c..1e8c3e318a2 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentConfigAutosave.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentConfigAutosave.test.ts
@@ -27,6 +27,30 @@ describe('useAgentConfigAutosave', () => {
expect(save).toHaveBeenCalledTimes(1);
});
+ it("hasQueuedSnapshot is true only while a debounced snapshot hasn't fired yet", async () => {
+ vi.useFakeTimers();
+ const pending = Promise.withResolvers();
+ const save = vi.fn().mockReturnValue(pending.promise);
+ const autosave = useAgentConfigAutosave<{ value: string }>({
+ save,
+ debounceMs: 500,
+ });
+
+ expect(autosave.hasQueuedSnapshot.value).toBe(false);
+
+ autosave.scheduleAutosave({ value: 'latest' });
+ expect(autosave.hasQueuedSnapshot.value).toBe(true);
+
+ // Once the debounce fires the snapshot is in flight, not merely queued.
+ await vi.advanceTimersByTimeAsync(500);
+ expect(autosave.hasQueuedSnapshot.value).toBe(false);
+ expect(autosave.hasPendingSave.value).toBe(true);
+
+ pending.resolve(undefined);
+ await autosave.flushAutosave();
+ expect(autosave.hasPendingSave.value).toBe(false);
+ });
+
it('flushAutosave rejects when the immediate save fails', async () => {
vi.useFakeTimers();
const error = new Error('save failed');
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue
index e7824160bbc..03b897c7f52 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue
@@ -67,7 +67,7 @@ const isMcpAvailable = computed(
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
- 'update:config': [updates: Partial];
+ 'update:config': [updates: Partial, meta?: { source: 'auto' }];
'open-tool': [target: ToolOpenTarget];
'open-skill': [id: string];
'add-tool': [mode: ToolPickerMode];
@@ -124,7 +124,7 @@ const i18n = useI18n();
:config="localConfig"
:disabled="childrenDisabled"
:project-id="projectId"
- @update:config="emit('update:config', $event)"
+ @update:config="(changes, meta) => emit('update:config', changes, meta)"
/>
Promise | void;
artifactMode?: boolean;
isPreviewOpen?: boolean;
+ /** Whether the embedded n8n Assistant panel toggle is available at all. */
+ instanceAiAvailable?: boolean;
+ /** Whether the embedded n8n Assistant panel is currently open. */
+ isAiPanelOpen?: boolean;
/** True while the AI is actively building/mutating this agent in artifact mode — disables publish/revert/unpublish without hiding them. */
editingLocked?: boolean;
configValidationStatus?: 'valid' | 'invalid' | null;
@@ -54,6 +60,7 @@ const emit = defineEmits<{
reverted: [agent: AgentResource];
'switch-agent': [agentId: string];
'toggle-version-history': [];
+ 'toggle-instance-ai': [];
}>();
const i18n = useI18n();
@@ -146,6 +153,18 @@ function onMenuSelect(id: string) {
+
+
+
] }>();
+const emit = defineEmits<{
+ 'update:config': [changes: Partial, meta?: { source: 'auto' }];
+}>();
const i18n = useI18n();
const instructionsEditorId = useId();
@@ -278,14 +280,20 @@ function onModelChange(selection: AgentModelSelection, source: 'user' | 'auto' =
if (deploymentNameChange.modelDeploymentName !== undefined) {
deploymentName.value = deploymentNameChange.modelDeploymentName;
}
- emit('update:config', {
- model,
- credential: credentialId,
- ...webSearchChanges,
- ...promptCachingChanges,
- ...reasoningChanges,
- ...deploymentNameChange,
- });
+ emit(
+ 'update:config',
+ {
+ model,
+ credential: credentialId,
+ ...webSearchChanges,
+ ...promptCachingChanges,
+ ...reasoningChanges,
+ ...deploymentNameChange,
+ },
+ // A pending agent must not be persisted just because a default model was
+ // auto-applied — let the host apply it to the draft without autosaving.
+ source === 'auto' ? { source: 'auto' } : undefined,
+ );
}
watch(
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue
index 046cfa17e40..10104fd8683 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue
@@ -71,7 +71,13 @@ const {
}>();
const emit = defineEmits<{
- change: [AgentModelSelection];
+ /**
+ * `source` distinguishes a picker default resolved for the user (e.g. after
+ * a credential selection) from a model the user picked directly, so the
+ * host can skip persisting a pending agent for the former — mirrors
+ * `AgentInfoPanel`'s own auto-applied default.
+ */
+ change: [selection: AgentModelSelection, source?: 'user' | 'auto'];
selectCredential: [provider: AgentModelProvider, credentialId: string | null];
configureCredential: [provider: AgentModelProvider];
}>();
@@ -553,10 +559,14 @@ function handleDefaultModelResolution(result: ReturnType {
export function useAgentConfigAutosave(params: UseAgentConfigAutosaveParams) {
const saveStatus = ref('idle');
const hasPendingSave = ref(false);
+ /** A debounced snapshot is queued but hasn't fired yet — distinct from `hasPendingSave`,
+ * which is also true once that snapshot moves in flight. Lets a caller detect "there's
+ * a queued edit I can still merge into" before it's sent. */
+ const hasQueuedSnapshot = ref(false);
const debounceMs = params.debounceMs ?? 500;
const savedHoldMs = params.savedHoldMs ?? 2000;
@@ -70,6 +74,7 @@ export function useAgentConfigAutosave(params: UseAgentConfigAutosave
function syncPendingState() {
hasPendingSave.value = pendingSnapshot !== null || autosaveInFlight !== null;
+ hasQueuedSnapshot.value = pendingSnapshot !== null;
}
async function runSave(
@@ -188,6 +193,7 @@ export function useAgentConfigAutosave(params: UseAgentConfigAutosave
pendingSnapshot = null;
pendingSnapshotRevision = 0;
pendingSnapshotGeneration = 0;
+ syncPendingState();
void chainSave(target, targetRevision, targetGeneration, false);
}, getDebounceTime(debounceMs));
}
@@ -212,6 +218,7 @@ export function useAgentConfigAutosave(params: UseAgentConfigAutosave
pendingSnapshot = null;
pendingSnapshotRevision = 0;
pendingSnapshotGeneration = 0;
+ syncPendingState();
if (target !== null) {
try {
@@ -276,6 +283,7 @@ export function useAgentConfigAutosave(params: UseAgentConfigAutosave
return {
saveStatus,
hasPendingSave,
+ hasQueuedSnapshot,
scheduleAutosave,
settleAutosave,
flushAutosave,
diff --git a/packages/frontend/editor-ui/src/features/agents/constants.ts b/packages/frontend/editor-ui/src/features/agents/constants.ts
index d11d205d52b..05336f69107 100644
--- a/packages/frontend/editor-ui/src/features/agents/constants.ts
+++ b/packages/frontend/editor-ui/src/features/agents/constants.ts
@@ -50,3 +50,5 @@ export {
export const CONTINUE_SESSION_ID_PARAM = 'continueSessionId';
export const NEW_SESSION_PARAM = 'newSession';
export const OPEN_PREVIEW_PARAM = 'openPreview';
+/** Query-string key holding the embedded n8n Assistant panel's active thread id. */
+export const ASSISTANT_THREAD_PARAM = 'assistantThread';
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
index 19e219eab23..1be6c6afdcc 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
@@ -1,13 +1,12 @@
@@ -2131,11 +2312,13 @@ function onSwitchAgent(nextAgentId: string) {
:save-status="saveStatus"
:before-revert-to-published="settleAutosave"
:artifact-mode="isArtifactMode"
- :editing-locked="props.artifactEditingLocked"
+ :editing-locked="isEditingLocked"
:config-validation-status="configValidation?.status ?? null"
:config-validation-issues="configValidation?.issues ?? []"
:before-publish="refreshValidationBeforePublish"
:is-preview-open="isPreviewDockOpen"
+ :instance-ai-available="instanceAiAvailable"
+ :is-ai-panel-open="isAiPanelOpen"
@header-action="onHeaderAction"
@open-preview="onOpenPreview"
@close-preview="closePreviewDock"
@@ -2143,6 +2326,7 @@ function onSwitchAgent(nextAgentId: string) {
@unpublished="onUnpublished"
@reverted="onReverted"
@switch-agent="onSwitchAgent"
+ @toggle-instance-ai="toggleAiPanel"
/>
-
+
+
+
+
@@ -2333,6 +2522,11 @@ function onSwitchAgent(nextAgentId: string) {
transition: padding-right var(--duration--snappy) var(--easing--ease-out);
}
+ &.aiPanelOpen {
+ padding-left: var(--agent-ai-panel-width);
+ transition: padding-left var(--duration--snappy) var(--easing--ease-out);
+ }
+
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@@ -2357,14 +2551,14 @@ function onSwitchAgent(nextAgentId: string) {
min-width: 0;
}
-.aiButtonWrapper {
+.aiDock {
position: absolute;
top: 0;
- right: 0;
- display: flex;
- flex-direction: column;
- gap: var(--spacing--2xs);
- padding: var(--spacing--sm);
+ left: 0;
+ bottom: 0;
+ width: var(--agent-ai-panel-width);
+ border-right: var(--border);
+ background: var(--background--surface);
z-index: 1;
}
@@ -2405,14 +2599,4 @@ function onSwitchAgent(nextAgentId: string) {
color: var(--color--neutral-white);
pointer-events: auto;
}
-
-.aiButtonIcon {
- display: inline-flex;
- justify-content: center;
- align-items: center;
-
- svg {
- display: block;
- }
-}
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 c5bf8dc0d22..2d65b6a0f86 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue
@@ -34,13 +34,14 @@ import {
getAgentBuilderTargetFromThreadMetadata,
getAgentPreviewSessionFromThreadMetadata,
getAgentPreviewViewFromThreadMetadata,
+ getThreadDisplayTitle,
} from './instanceAi.threadRuntime';
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
import { useCanvasPreview } from './useCanvasPreview';
import { buildInstanceAiAgentPreviewHandoffContext } from './composables/useInstanceAiHandoff';
import type { AgentPreviewHandoffParams } from './composables/useInstanceAiAgentPreviewHandoff';
import { useTransitionGate } from './useTransitionGate';
-import { INSTANCE_AI_VIEW, NEW_CONVERSATION_TITLE } from './constants';
+import { INSTANCE_AI_VIEW } from './constants';
import { getDismissedContextKeys } from './instanceAi.handoffContext';
import InstanceAiDebugPanel from './components/InstanceAiDebugPanel.vue';
import InstanceAiArtifactsPanel from './components/InstanceAiArtifactsPanel.vue';
@@ -177,18 +178,12 @@ const activeTestAgentOffer = computed(() => {
// Returns the resolved title once we have one, or undefined while we're still
// figuring out which thread to show. Rendering only on a defined value avoids
// the "New conversation" → real title flash when resuming a recent thread.
-const currentThreadTitle = computed(() => {
- const threadSummary = store.threads.find((t) => t.id === props.threadId);
- if (threadSummary?.title && threadSummary.title !== NEW_CONVERSATION_TITLE) {
- return threadSummary.title;
- }
- const firstUserMsg = thread.messages.find((m) => m.role === 'user');
- if (firstUserMsg?.content) {
- const text = firstUserMsg.content.trim();
- return text.length > 60 ? text.slice(0, 60) + '…' : text;
- }
- return undefined;
-});
+const currentThreadTitle = computed(() =>
+ getThreadDisplayTitle(
+ store.threads.find((t) => t.id === props.threadId),
+ thread.messages,
+ ),
+);
// The tab names the conversation, not the workflow previewed inside it — the
// parent view claims the title so the embedded canvas can't overwrite this.
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts
index 63f120dd076..d215377aada 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts
@@ -51,6 +51,7 @@ import {
getPendingComposerDraft,
getPendingHandoffContext,
provisionContextOnlyThread,
+ provisionSubjectThread,
stashPendingAgentAttachment,
stashPendingComposerDraft,
stashPendingFirstMessage,
@@ -342,6 +343,59 @@ describe('useInstanceAiHandoff', () => {
expect(consumePendingFirstMessage('thread-1')).toEqual(payload);
});
+ it('mints a thread bound to a non-pending agent subject', async () => {
+ const threadId = await provisionSubjectThread(
+ { type: 'agent', id: 'agent-1', projectId: 'project-1', name: 'Support agent' },
+ { source: 'agent_builder_page', origin: 'internal' },
+ );
+
+ expect(threadId).toBe('thread-1');
+ expect(mocks.syncThread).toHaveBeenCalledWith('thread-1', 'project-1', {
+ source: 'agent_builder_page',
+ origin: 'internal',
+ });
+ expect(mocks.updateThreadMetadata).toHaveBeenCalledWith('thread-1', {
+ instanceAiAgentBuilderTarget: {
+ agentId: 'agent-1',
+ projectId: 'project-1',
+ name: 'Support agent',
+ },
+ });
+ expect(getPendingAgentAttachment('thread-1')).toEqual({
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'project-1',
+ name: 'Support agent',
+ });
+ });
+
+ it('mints a thread with a pending marker for a pending agent subject, merging extra metadata into the same write', async () => {
+ const threadId = await provisionSubjectThread(
+ { type: 'agent', id: 'agent-1', projectId: 'project-1', pending: true },
+ { source: 'agent_builder_page', origin: 'internal' },
+ { instanceAiAgentPreviewView: { agentId: 'agent-1', threadId: 'preview-1' } },
+ );
+
+ expect(threadId).toBe('thread-1');
+ expect(mocks.updateThreadMetadata).toHaveBeenCalledExactlyOnceWith('thread-1', {
+ instanceAiPendingAgentTarget: { projectId: 'project-1', agentId: 'agent-1' },
+ instanceAiAgentPreviewView: { agentId: 'agent-1', threadId: 'preview-1' },
+ });
+ });
+
+ it('deletes the thread and rethrows when the target metadata write fails, without a second toast', async () => {
+ mocks.updateThreadMetadata.mockRejectedValueOnce(new Error('Save failed'));
+
+ await expect(
+ provisionSubjectThread(
+ { type: 'agent', id: 'agent-1', projectId: 'project-1' },
+ { source: 'agent_builder_page', origin: 'internal' },
+ ),
+ ).rejects.toThrow('Save failed');
+ expect(mocks.deleteThread).toHaveBeenCalledWith('thread-1', { silent: true });
+ expect(mocks.showError).not.toHaveBeenCalled();
+ });
+
it('opens an agent artifact thread without sending a message', async () => {
const { openAgentArtifactThread } = useInstanceAiHandoff();
const context = buildInstanceAiAgentPreviewHandoffContext({
@@ -377,7 +431,9 @@ describe('useInstanceAiHandoff', () => {
origin: 'internal',
sourceContext: { agentId: 'agent-1' },
});
- expect(mocks.updateThreadMetadata).toHaveBeenCalledWith('thread-1', {
+ // One merged write from `provisionSubjectThread`: the target plus this
+ // handoff's own agent-preview context, in a single round trip.
+ expect(mocks.updateThreadMetadata).toHaveBeenCalledExactlyOnceWith('thread-1', {
instanceAiAgentBuilderTarget: {
agentId: 'agent-1',
projectId: 'project-1',
@@ -445,7 +501,7 @@ describe('useInstanceAiHandoff', () => {
);
expect(opened).toBe(false);
- expect(mocks.deleteThread).toHaveBeenCalledWith('thread-1');
+ expect(mocks.deleteThread).toHaveBeenCalledWith('thread-1', { silent: true });
expect(mocks.routerPush).not.toHaveBeenCalled();
});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue
index e048fc1c060..1c48e2dbf5f 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue
@@ -65,6 +65,11 @@ import AgentSection from './AgentSection.vue';
import { collectActiveBuilderAgents, messageHasVisibleContent } from '../builderAgents';
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
+const props = defineProps<{
+ /** Runs before every send (e.g. flush a pending autosave). Rejecting cancels the send. */
+ beforeSend?: () => Promise;
+}>();
+
const emit = defineEmits<{
'thread-missing': [];
'agent-attachment-restored': [attachment: InstanceAiAgentAttachment];
@@ -412,7 +417,7 @@ const isPlanChangeInFlight = computed(() => {
});
// --- Message handlers ---
-function handleSubmit(
+async function handleSubmit(
message: string,
attachments: InstanceAiAttachment[] | undefined,
restoreDraft: () => boolean,
@@ -422,6 +427,20 @@ function handleSubmit(
return;
}
+ if (props.beforeSend) {
+ try {
+ await props.beforeSend();
+ } catch {
+ // The caller's own flow (e.g. a failed autosave) already surfaced its
+ // error — put the draft back and stop, same as a refused send below.
+ restoreDraft?.();
+ return;
+ }
+ // The host may have disposed this runtime (e.g. closed the panel) while
+ // `beforeSend` was pending — don't send into a thread that's gone.
+ if (!isCurrentThreadRuntime()) return;
+ }
+
// Reset scroll on new user message
userScrolledUp.value = false;
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue
index b41447d4bd6..2fa8d9bcbef 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiThreadList.vue
@@ -10,6 +10,7 @@ import {
} from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
+import type { InstanceAiThreadSummary } from '@n8n/api-types';
import {
ComboboxContent,
ComboboxGroup,
@@ -29,15 +30,30 @@ import { useToast } from '@n8n/composables/useToast';
const props = withDefaults(
defineProps<{
maxHeight?: string;
+ /** Scope the history shown — applied to `history.threads` before grouping. */
+ filter?: (thread: InstanceAiThreadSummary) => boolean;
+ /** False → rows are buttons emitting `select` instead of `RouterLink`s, "View all" is hidden,
+ * and deleting the active thread emits `deleted(true)` instead of navigating. For an
+ * embedding host that has no thread route of its own. */
+ navigate?: boolean;
+ /** Falls back to the route param when omitted (the page's own use). */
+ activeThreadId?: string;
+ /** The assistant is actively building — rows stop being interactive so a click can't race it. */
+ disabled?: boolean;
}>(),
{
maxHeight: undefined,
+ filter: undefined,
+ navigate: true,
+ activeThreadId: undefined,
+ disabled: false,
},
);
const emit = defineEmits<{
close: [];
select: [threadId: string];
+ deleted: [wasActive: boolean];
}>();
const store = useInstanceAiStore();
@@ -53,8 +69,10 @@ const renameInput = ref(null);
const comboboxRef = ref<{
highlightFirstItem?: () => void;
}>();
-const activeThreadId = computed(() =>
- typeof route.params.threadId === 'string' ? route.params.threadId : undefined,
+const activeThreadId = computed(
+ () =>
+ props.activeThreadId ??
+ (typeof route.params.threadId === 'string' ? route.params.threadId : undefined),
);
const threadActions: Array> = [
@@ -79,6 +97,12 @@ const dateGroupI18nMap: Record = {
const groupOrder = ['Today', 'Yesterday', 'This week', 'Older'] as const;
+// Scoped to the embedding host's subject (e.g. one agent) before grouping —
+// the underlying history stays the shared, server-paged list.
+const filteredThreads = computed(() =>
+ props.filter ? history.value.threads.filter(props.filter) : history.value.threads,
+);
+
const groupedThreads = computed(() => {
const now = new Date();
const groups = new Map();
@@ -87,7 +111,7 @@ const groupedThreads = computed(() => {
// but messaged today belongs under "Today", matching the backend ordering
// (memory.service returns threads sorted by updatedAt desc) and the
// chatHub sidebar's `groupConversationsByDate` behaviour.
- for (const thread of history.value.threads) {
+ for (const thread of filteredThreads.value) {
const group = getRelativeDate(now, thread.updatedAt ?? thread.createdAt);
let threads = groups.get(group);
if (!threads) {
@@ -122,20 +146,20 @@ async function handleDeleteThread(threadId: string) {
if (!deleted) return;
clearPendingThreadHandoff(threadId);
- if (wasActive) {
- if (store.threads.length > 0) {
- void router.push({
- name: INSTANCE_AI_THREAD_VIEW,
- params: { threadId: store.threads[0].id },
- });
- } else {
- void router.push({ name: INSTANCE_AI_VIEW });
- }
+ if (!wasActive) return;
+ if (!props.navigate) {
+ // No thread route of our own to land on — let the host decide.
+ emit('deleted', true);
+ return;
+ }
+ if (store.threads.length > 0) {
+ void router.push({
+ name: INSTANCE_AI_THREAD_VIEW,
+ params: { threadId: store.threads[0].id },
+ });
+ } else {
+ void router.push({ name: INSTANCE_AI_VIEW });
}
-}
-
-function handleThreadSelect(threadId: string) {
- emit('select', threadId);
}
function openAllThreads() {
@@ -173,7 +197,19 @@ function cancelRename() {
editingThreadId.value = null;
}
+function handleThreadSelect(threadId: string) {
+ // The `disabled` attribute already blocks real user interaction; this guards
+ // the emit itself too, since a disabled element still receives a
+ // programmatically dispatched click.
+ if (props.disabled) return;
+ emit('select', threadId);
+}
+
function handleThreadAction(action: string, threadId: string) {
+ // N8nActionDropdown has no `disabled` slot for a custom `#activator`, so the
+ // trigger button below is disabled directly — this is the second guard for
+ // whatever reaches here anyway (e.g. an already-open menu).
+ if (props.disabled) return;
if (action === 'delete') {
void handleDeleteThread(threadId);
} else if (action === 'rename') {
@@ -200,6 +236,7 @@ function handleThreadAction(action: string, threadId: string) {
{{ i18n.baseText('instanceAi.sidebar.chatHistory') }}
@@ -267,6 +304,7 @@ function handleThreadAction(action: string, threadId: string) {
{{ thread.title }}
+
+
@@ -290,6 +341,7 @@ function handleThreadAction(action: string, threadId: string) {
variant="ghost"
icon="ellipsis-vertical"
:class="$style.actionTrigger"
+ :disabled="disabled"
:aria-label="i18n.baseText('instanceAi.threads.actions')"
/>
@@ -311,7 +363,7 @@ function handleThreadAction(action: string, threadId: string) {
-
+
{{
i18n.baseText(
@@ -486,6 +538,11 @@ function handleThreadAction(action: string, threadId: string) {
background-color: var(--background--hover);
}
}
+
+ &.disabled {
+ pointer-events: none;
+ opacity: var(--opacity--disabled, 0.5);
+ }
}
.threadLink {
@@ -514,6 +571,14 @@ function handleThreadAction(action: string, threadId: string) {
}
}
+.threadLinkButton {
+ width: 100%;
+ background: none;
+ border: none;
+ font: inherit;
+ text-align: left;
+}
+
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiViewHeader.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiViewHeader.vue
index 9f604df726e..53058316fb3 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiViewHeader.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiViewHeader.vue
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { N8nButton, N8nCallout, N8nIcon, N8nPopover } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
+import type { InstanceAiThreadSummary } from '@n8n/api-types';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useInstanceAiStore } from '../instanceAi.store';
@@ -12,12 +13,28 @@ import InstanceAiThreadList from './InstanceAiThreadList.vue';
const props = withDefaults(
defineProps<{
showThreadHistoryLabel?: boolean;
+ /** Falls back to the route param when omitted (the full assistant page's own use). */
+ threadId?: string;
+ /** Passed through to the popover's `InstanceAiThreadList` — an embedding host scopes
+ * and disables the shared history instead of routing through it. */
+ threadList?: {
+ filter?: (thread: InstanceAiThreadSummary) => boolean;
+ navigate?: boolean;
+ disabled?: boolean;
+ };
}>(),
{
showThreadHistoryLabel: false,
+ threadId: undefined,
+ threadList: undefined,
},
);
+const emit = defineEmits<{
+ select: [threadId: string];
+ deleted: [wasActive: boolean];
+}>();
+
const store = useInstanceAiStore();
const sourceControlStore = useSourceControlStore();
const i18n = useI18n();
@@ -27,9 +44,11 @@ const threadMenuOpen = ref(false);
const isReadOnlyEnvironment = computed(() => sourceControlStore.preferences.branchReadOnly);
-// The active thread comes from the `:threadId` route param (INSTANCE_AI_THREAD_VIEW);
-// undefined on the empty/new-conversation view, in which case no per-thread total shows.
+// The active thread comes from the `threadId` prop, falling back to the
+// `:threadId` route param (INSTANCE_AI_THREAD_VIEW); undefined on the
+// empty/new-conversation view, in which case no per-thread total shows.
const activeThreadId = computed(() => {
+ if (props.threadId) return props.threadId;
const id = route.params?.threadId;
return typeof id === 'string' ? id : undefined;
});
@@ -37,6 +56,11 @@ const activeThreadId = computed(() => {
const threadCreditsUsed = computed(() =>
activeThreadId.value ? store.threadCreditsUsed(activeThreadId.value) : undefined,
);
+
+function handleThreadSelect(threadId: string) {
+ threadMenuOpen.value = false;
+ emit('select', threadId);
+}
@@ -72,8 +96,13 @@ const threadCreditsUsed = computed(() =>
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts
index e0327c380a0..634cee40bc1 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
+import { fireEvent } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { mockedStore } from '@/__tests__/utils';
@@ -144,6 +145,87 @@ describe('InstanceAiConversation', () => {
]);
});
+ it('awaits beforeSend before sending, restoring the draft if it rejects', async () => {
+ const beforeSend = vi.fn().mockRejectedValueOnce(new Error('flush failed'));
+ const renderer = createThreadComponentRenderer(
+ InstanceAiConversation,
+ {
+ props: { beforeSend },
+ global: { stubs: { InstanceAiInput: InstanceAiInputStub } },
+ },
+ () => thread,
+ );
+ const { getByTestId } = renderer();
+
+ // The stub only passes a `restoreDraft` callback with an attachment queued.
+ await fireEvent.click(getByTestId('instance-ai-input-add-attachment'));
+ await fireEvent.click(getByTestId('instance-ai-input-submit'));
+ await vi.waitFor(() => expect(beforeSend).toHaveBeenCalled());
+
+ expect(thread.sendMessage).not.toHaveBeenCalled();
+ expect(getByTestId('instance-ai-input-draft').textContent).toBe('Normal message');
+ expect(getByTestId('instance-ai-input-attachments').textContent).toBe('attached');
+ });
+
+ it('sends the message once beforeSend resolves', async () => {
+ let resolveBeforeSend: () => void = () => {};
+ const beforeSend = vi.fn(
+ async () =>
+ await new Promise((resolve) => {
+ resolveBeforeSend = resolve;
+ }),
+ );
+ const renderer = createThreadComponentRenderer(
+ InstanceAiConversation,
+ {
+ props: { beforeSend },
+ global: { stubs: { InstanceAiInput: InstanceAiInputStub } },
+ },
+ () => thread,
+ );
+ const { getByTestId } = renderer();
+
+ await fireEvent.click(getByTestId('instance-ai-input-submit'));
+ await vi.waitFor(() => expect(beforeSend).toHaveBeenCalled());
+
+ expect(thread.sendMessage).not.toHaveBeenCalled();
+
+ resolveBeforeSend();
+ await vi.waitFor(() => expect(thread.sendMessage).toHaveBeenCalledTimes(1));
+ expect(thread.sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not send once beforeSend resolves if the panel disposed this runtime meanwhile', async () => {
+ let resolveBeforeSend: () => void = () => {};
+ const beforeSend = vi.fn(
+ async () =>
+ await new Promise((resolve) => {
+ resolveBeforeSend = resolve;
+ }),
+ );
+ const renderer = createThreadComponentRenderer(
+ InstanceAiConversation,
+ {
+ props: { beforeSend },
+ global: { stubs: { InstanceAiInput: InstanceAiInputStub } },
+ },
+ () => thread,
+ );
+ const { getByTestId } = renderer();
+
+ await fireEvent.click(getByTestId('instance-ai-input-submit'));
+ await vi.waitFor(() => expect(beforeSend).toHaveBeenCalled());
+
+ // Simulate the host disposing/replacing the runtime while `beforeSend` was pending.
+ const replacementThread = makeThread();
+ store.getRuntime.mockReturnValue(replacementThread);
+ resolveBeforeSend();
+
+ await vi.waitFor(() => expect(beforeSend).toHaveResolved());
+ expect(thread.sendMessage).not.toHaveBeenCalled();
+ expect(replacementThread.sendMessage).not.toHaveBeenCalled();
+ });
+
it('exposes pendingComposerContext for panels beside the conversation', () => {
const wrapper = mountConversation();
const conversation = wrapper.findComponent(InstanceAiConversation);
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiThreadList.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiThreadList.test.ts
new file mode 100644
index 00000000000..4a8cd1f242b
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiThreadList.test.ts
@@ -0,0 +1,139 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+import type { InstanceAiThreadSummary } from '@n8n/api-types';
+import { createComponentRenderer } from '@/__tests__/render';
+import { mockedStore } from '@/__tests__/utils';
+import InstanceAiThreadList from '../InstanceAiThreadList.vue';
+import { useInstanceAiStore } from '../../instanceAi.store';
+
+// This suite covers only the props added for embedding the list outside the
+// full assistant's popover (`filter`, `navigate`, `activeThreadId`, `disabled`)
+// — the search/pagination/rename behaviour they leave untouched has its own
+// coverage via `InstanceAiThreadsView.test.ts` and manual testing of the page.
+
+vi.mock('vue-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRoute: () => ({ params: {} }),
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+vi.mock('@n8n/composables/useToast', () => ({
+ useToast: () => ({ showError: vi.fn() }),
+}));
+
+// The composable creates a real IntersectionObserver, which jsdom doesn't implement.
+vi.mock('@/app/composables/useIntersectionObserver', () => ({
+ useIntersectionObserver: () => ({ observe: vi.fn() }),
+}));
+
+function thread(id: string, metadata?: Record): InstanceAiThreadSummary {
+ return {
+ id,
+ title: `Thread ${id}`,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ metadata,
+ };
+}
+
+const actionDropdownStub = {
+ name: 'ActionDropdown',
+ template:
+ '',
+ props: ['items', 'disabled', 'placement'],
+ emits: ['select'],
+};
+
+const renderList = createComponentRenderer(InstanceAiThreadList, {
+ global: { stubs: { ActionDropdown: actionDropdownStub, N8nActionDropdown: actionDropdownStub } },
+});
+
+describe('InstanceAiThreadList', () => {
+ beforeEach(() => {
+ const pinia = createTestingPinia();
+ setActivePinia(pinia);
+ const store = mockedStore(useInstanceAiStore);
+ store.threadHistory = {
+ search: '',
+ threads: [thread('a', { agentId: 'agent-1' }), thread('b', { agentId: 'agent-2' })],
+ hasMore: false,
+ loading: false,
+ error: false,
+ };
+ });
+
+ it('scopes the rows to threads the filter accepts', () => {
+ const { getAllByTestId } = renderList({
+ props: { filter: (t: InstanceAiThreadSummary) => t.metadata?.agentId === 'agent-1' },
+ });
+
+ const rows = getAllByTestId('instance-ai-thread-item');
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toHaveTextContent('Thread a');
+ });
+
+ it('renders every row when no filter is given', () => {
+ const { getAllByTestId } = renderList();
+ expect(getAllByTestId('instance-ai-thread-item')).toHaveLength(2);
+ });
+
+ it('renders rows as buttons instead of links when navigate is false, and hides "View all"', () => {
+ const { getAllByTestId, queryByTestId } = renderList({ props: { navigate: false } });
+
+ const rows = getAllByTestId('instance-ai-thread-item');
+ expect(rows[0].querySelector('a')).toBeNull();
+ expect(rows[0].querySelector('button')).not.toBeNull();
+ expect(queryByTestId('instance-ai-view-all-threads')).not.toBeInTheDocument();
+ });
+
+ it('emits select instead of navigating when a row is clicked with navigate false', async () => {
+ const { getAllByTestId, emitted } = renderList({ props: { navigate: false } });
+
+ const [row] = getAllByTestId('instance-ai-thread-item');
+ await userEvent.click(row.querySelector('button')!);
+
+ expect(emitted().select).toEqual([['a']]);
+ });
+
+ it('disables rows and the action dropdown, and ignores select while disabled', async () => {
+ const { getAllByTestId, emitted } = renderList({ props: { navigate: false, disabled: true } });
+
+ const [row] = getAllByTestId('instance-ai-thread-item');
+ expect(row.className).toMatch(/disabled/);
+ const button = row.querySelector('button');
+ expect(button).toBeDisabled();
+
+ await userEvent.click(button!);
+ expect(emitted().select).toBeUndefined();
+ });
+
+ it('emits deleted(true) instead of navigating when the active thread is deleted and navigate is false', async () => {
+ const store = mockedStore(useInstanceAiStore);
+ store.deleteThread.mockResolvedValue(true);
+
+ const { getAllByTestId, emitted } = renderList({
+ props: { navigate: false, activeThreadId: 'a' },
+ });
+
+ await userEvent.click(getAllByTestId('thread-actions')[0]);
+
+ expect(store.deleteThread).toHaveBeenCalledWith('a');
+ await vi.waitFor(() => {
+ expect(emitted().deleted).toEqual([[true]]);
+ });
+ });
+
+ it('ignores the delete action while disabled', async () => {
+ const store = mockedStore(useInstanceAiStore);
+
+ const { getAllByTestId } = renderList({
+ props: { navigate: false, activeThreadId: 'a', disabled: true },
+ });
+
+ await userEvent.click(getAllByTestId('thread-actions')[0]);
+
+ expect(store.deleteThread).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts
index 7890c2ce892..1002b6fe272 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts
@@ -23,9 +23,11 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import {
INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY,
INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY,
+ INSTANCE_AI_PENDING_AGENT_METADATA_KEY,
INSTANCE_AI_THREAD_VIEW,
INSTANCE_AI_VIEW,
} from '../constants';
+import type { InstanceAiEmbedSubject } from '../embed/instanceAiEmbed.types';
import { useInstanceAiStore } from '../instanceAi.store';
import { useInstanceAiReady } from './useInstanceAiAvailability';
import {
@@ -349,6 +351,58 @@ export async function provisionLaunchedThread(
return threadId;
}
+/**
+ * Mint a thread bound to a subject: the id, the target metadata (a pending
+ * marker or a bound target), and — for the agent variant — the stashed
+ * attachment the destination view resolves it with. `extraMetadata` merges
+ * into the same write so binding a subject and recording where it opened from
+ * (e.g. the agent-preview view) costs one round trip, not two.
+ *
+ * Shared by `InstanceAiChatPanel` (embed/) and `openAgentArtifactThread` below,
+ * which used to run this in two separate steps.
+ */
+export async function provisionSubjectThread(
+ subject: InstanceAiEmbedSubject,
+ launch: InstanceAiThreadLaunch,
+ extraMetadata?: Record,
+): Promise {
+ const store = useInstanceAiStore();
+ const threadId = uuidv4();
+ await store.syncThread(threadId, subject.projectId, launch);
+
+ const targetMetadata =
+ subject.type === 'agent'
+ ? subject.pending
+ ? {
+ [INSTANCE_AI_PENDING_AGENT_METADATA_KEY]: {
+ projectId: subject.projectId,
+ agentId: subject.id,
+ },
+ }
+ : {
+ [INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY]: {
+ agentId: subject.id,
+ projectId: subject.projectId,
+ ...(subject.name ? { name: subject.name } : {}),
+ },
+ }
+ : {}; // workflow: no target metadata yet — see instanceAiEmbed.types.ts.
+
+ try {
+ await store.updateThreadMetadata(threadId, { ...targetMetadata, ...extraMetadata });
+ } catch (error) {
+ // The thread now exists server-side — leaving it target-less would strand
+ // an unbound conversation the user never asked to start. Silent: the
+ // caller already surfaces its own failure toast.
+ await store.deleteThread(threadId, { silent: true });
+ throw error;
+ }
+
+ if (subject.type === 'agent') stashPendingAgentAttachment(threadId, subject);
+
+ return threadId;
+}
+
export async function provisionContextOnlyThread(
projectId: string,
context: InstanceAiHandoffContext,
@@ -414,35 +468,27 @@ export function useInstanceAiHandoff() {
if (handoffInFlight) return false;
handoffInFlight = true;
try {
- const threadId = uuidv4();
+ let threadId: string;
try {
- await instanceAiStore.syncThread(threadId, attachment.projectId, launch);
- } catch {
- showOpenFailed();
- return false;
- }
- try {
- await instanceAiStore.updateThreadMetadata(threadId, {
- [INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY]: {
- agentId: attachment.id,
- projectId: attachment.projectId,
- ...(attachment.name ? { name: attachment.name } : {}),
- },
- ...(options?.context?.source === 'agent-preview'
+ // Mints the thread, binds it to the agent, and stashes the attachment —
+ // the agent-preview view metadata rides along as extra metadata so this
+ // is one merged write, not two. Same primitive `InstanceAiChatPanel` uses.
+ threadId = await provisionSubjectThread(
+ attachment,
+ launch,
+ options?.context?.source === 'agent-preview'
? {
[INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY]: {
agentId: options.context.agentId,
threadId: options.context.threadId,
},
}
- : {}),
- });
+ : undefined,
+ );
} catch {
- await instanceAiStore.deleteThread(threadId);
showOpenFailed();
return false;
}
- stashPendingAgentAttachment(threadId, attachment);
if (options?.context) stashPendingHandoffContext(threadId, options.context);
if (options?.initialDraft) stashPendingComposerDraft(threadId, options.initialDraft);
try {
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
index 619560919c8..f95cc48d081 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
@@ -41,6 +41,8 @@ export const INSTANCE_AI_AGENT_PREVIEW_SESSION_METADATA_KEY = 'instanceAiAgentPr
* `@n8n/instance-ai`.
*/
export const INSTANCE_AI_PENDING_AGENT_METADATA_KEY = 'instanceAiPendingAgentTarget';
+/** Every agent the model has addressed in this conversation. Mirrors `REGISTRY_METADATA_KEY` in `agent-target-binding.ts` (backend-only). */
+export const INSTANCE_AI_AGENT_BUILDER_TARGETS_METADATA_KEY = 'instanceAiAgentBuilderTargets';
export const NEW_CONVERSATION_TITLE = 'New conversation';
export { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
export const BROWSER_USE_CONNECTION_TYPE = 'browser-use';
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue
new file mode 100644
index 00000000000..a97158185c8
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue
@@ -0,0 +1,482 @@
+
+
+
+
+
+
+
+ {{ currentThreadTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/InstanceAiChatPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/InstanceAiChatPanel.test.ts
new file mode 100644
index 00000000000..79ffbaf48ec
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/InstanceAiChatPanel.test.ts
@@ -0,0 +1,730 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { computed, defineComponent, ref } from 'vue';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+import { fireEvent } from '@testing-library/vue';
+import { flushPromises, mount } from '@vue/test-utils';
+import type { InstanceAiHandoffContext } from '@n8n/api-types';
+
+import { mockedStore } from '@/__tests__/utils';
+import { createComponentRenderer } from '@/__tests__/render';
+import { useInstanceAiStore } from '../../instanceAi.store';
+import { makeThread } from '../../__tests__/createThreadComponentRenderer';
+import {
+ clearPendingComposerDraft,
+ clearPendingHandoffContext,
+ getPendingComposerDraft,
+ getPendingHandoffContext,
+ type PendingComposerDraft,
+} from '../../composables/useInstanceAiHandoff';
+import InstanceAiChatPanel from '../InstanceAiChatPanel.vue';
+import type { InstanceAiEmbedSubject } from '../instanceAiEmbed.types';
+
+vi.mock('uuid', () => ({ v4: () => 'thread-2' }));
+
+const routerPush = vi.hoisted(() => vi.fn());
+vi.mock('vue-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRouter: () => ({ push: routerPush }),
+}));
+
+const showError = vi.hoisted(() => vi.fn());
+const showMessage = vi.hoisted(() => vi.fn());
+vi.mock('@n8n/composables/useToast', () => ({
+ useToast: () => ({ showError, showMessage }),
+}));
+
+const buildingIds = ref(new Set());
+vi.mock('../../composables/useBuildingArtifactIds', () => ({
+ useBuildingArtifactIds: () => computed(() => buildingIds.value),
+}));
+
+const useAgentMutationRefreshMock = vi.hoisted(() => vi.fn());
+vi.mock('../../composables/useAgentMutationRefresh', () => ({
+ useAgentMutationRefresh: useAgentMutationRefreshMock,
+}));
+
+// Minimal stand-ins: exercise the panel's own wiring, not the real children's
+// internals (each has its own suite). The panel's history now lives inside the
+// header's popover, so this stub applies `threadList.filter`/`disabled` the same
+// way the real `InstanceAiThreadList` would, against the mocked store's threads.
+const InstanceAiViewHeaderStub = defineComponent({
+ name: 'InstanceAiViewHeader',
+ props: ['threadId', 'threadList'],
+ emits: ['select', 'deleted'],
+ setup(props) {
+ const store = useInstanceAiStore();
+ const count = computed(() => {
+ const filter = props.threadList?.filter as ((t: unknown) => boolean) | undefined;
+ return filter ? store.threads.filter(filter).length : store.threads.length;
+ });
+ return { count };
+ },
+ template: `
+ {{ count }}
+
+
+
+
`,
+});
+
+// `handoff()` reaches the conversation through a template ref, not props/emits
+// — these two stand in for the real `defineExpose`d `isDirty`/`applyHandoff`.
+const isDirtyMock = vi.hoisted(() => vi.fn(() => false));
+const applyHandoffMock = vi.hoisted(() => vi.fn());
+
+const InstanceAiConversationStub = defineComponent({
+ name: 'InstanceAiConversation',
+ props: ['beforeSend'],
+ emits: ['thread-missing', 'agent-attachment-restored'],
+ methods: {
+ isDirty: isDirtyMock,
+ applyHandoff: applyHandoffMock,
+ },
+ template: `
+
+
`,
+});
+
+const panelStubs = {
+ InstanceAiViewHeader: InstanceAiViewHeaderStub,
+ InstanceAiConversation: InstanceAiConversationStub,
+};
+
+const renderPanel = createComponentRenderer(InstanceAiChatPanel, {
+ global: { stubs: panelStubs },
+});
+
+// `handoff()` is an imperative method on the panel's public instance
+// (`defineExpose`), which testing-library's `render()` doesn't expose —
+// `@vue/test-utils`'s `mount()` does, via `wrapper.vm`.
+function mountPanel(props: {
+ subject: InstanceAiEmbedSubject;
+ launch: typeof launch;
+ threadId?: string;
+}) {
+ return mount(InstanceAiChatPanel, { props, global: { stubs: panelStubs } });
+}
+
+const subject: InstanceAiEmbedSubject = { type: 'agent', id: 'agent-1', projectId: 'p1' };
+const launch = { source: 'agent_builder_page' as const, origin: 'internal' as const };
+
+describe('InstanceAiChatPanel', () => {
+ let store: ReturnType>;
+
+ beforeEach(() => {
+ setActivePinia(createTestingPinia());
+ store = mockedStore(useInstanceAiStore);
+ store.threads = [];
+ store.loadThreads.mockResolvedValue(true);
+ store.syncThread.mockResolvedValue(undefined);
+ store.updateThreadMetadata.mockResolvedValue(undefined);
+ store.deleteThread.mockResolvedValue(true);
+ store.getOrCreateRuntime.mockReturnValue(makeThread());
+ store.getRuntime.mockReturnValue(makeThread());
+ // A thread id already given to the panel is trusted by default — tests that
+ // exercise the mismatch path override this to return other (or no) metadata.
+ store.getThreadMetadata.mockReturnValue({
+ instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' },
+ });
+ store.loadThread.mockResolvedValue(undefined);
+ buildingIds.value = new Set();
+ routerPush.mockClear();
+ useAgentMutationRefreshMock.mockClear();
+ showError.mockClear();
+ showMessage.mockClear();
+ isDirtyMock.mockReset().mockReturnValue(false);
+ applyHandoffMock.mockClear();
+ clearPendingHandoffContext('thread-2');
+ clearPendingComposerDraft('thread-2');
+ });
+
+ it('mints a thread when none is given, calling beforeNewThread first', async () => {
+ const order: string[] = [];
+ const beforeNewThread = vi.fn().mockImplementation(async () => {
+ order.push('before');
+ });
+ store.syncThread.mockImplementation(async () => {
+ order.push('synced');
+ });
+
+ const { emitted } = renderPanel({ props: { subject, launch, beforeNewThread } });
+ await vi.waitFor(() => expect(emitted('update:threadId')).toBeTruthy());
+
+ expect(order).toEqual(['before', 'synced']);
+ expect(emitted('update:threadId')?.[0]).toEqual(['thread-2']);
+ });
+
+ it('shows an error toast and leaves the panel without a thread when minting fails', async () => {
+ store.updateThreadMetadata.mockRejectedValue(new Error('nope'));
+
+ const { emitted } = renderPanel({ props: { subject, launch } });
+
+ await vi.waitFor(() => expect(showError).toHaveBeenCalled());
+
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), "Couldn't open n8n Assistant");
+ expect(emitted('update:threadId')).toBeFalsy();
+ });
+
+ it('does not throw and leaves the panel without a thread when beforeNewThread rejects', async () => {
+ const beforeNewThread = vi.fn().mockRejectedValueOnce(new Error('flush failed'));
+
+ const { emitted } = renderPanel({ props: { subject, launch, beforeNewThread } });
+
+ await vi.waitFor(() => expect(beforeNewThread).toHaveBeenCalled());
+ await flushPromises();
+
+ expect(store.syncThread).not.toHaveBeenCalled();
+ expect(emitted('update:threadId')).toBeFalsy();
+ // The host's own flow already surfaced the error — the panel doesn't add its own.
+ expect(showError).not.toHaveBeenCalled();
+ });
+
+ it('shows the open-failed toast and does not mint when loadThreads fails while resuming', async () => {
+ store.loadThreads.mockResolvedValue(false);
+
+ const { emitted } = renderPanel({ props: { subject, launch } });
+
+ await vi.waitFor(() => expect(showError).toHaveBeenCalled());
+
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), "Couldn't open n8n Assistant");
+ expect(store.syncThread).not.toHaveBeenCalled();
+ expect(emitted('update:threadId')).toBeFalsy();
+ });
+
+ it('mints immediately for a pending agent instead of waiting for loadThreads to resolve', async () => {
+ const pending = Promise.withResolvers();
+ store.loadThreads.mockReturnValue(pending.promise);
+ const pendingSubject: InstanceAiEmbedSubject = {
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'p1',
+ pending: true,
+ };
+
+ const { emitted } = renderPanel({ props: { subject: pendingSubject, launch } });
+
+ // loadThreads is deliberately left unresolved: a mint that waited on it
+ // first would never emit here.
+ await vi.waitFor(() => expect(emitted('update:threadId')).toBeTruthy());
+ expect(emitted('update:threadId')?.[0]).toEqual(['thread-2']);
+
+ pending.resolve(true);
+ });
+
+ it('resumes the most recent thread for the subject instead of minting one', async () => {
+ store.threads = [
+ {
+ id: 't-newest',
+ title: 'Newest',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } },
+ },
+ {
+ id: 't-match',
+ title: 'Older',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } },
+ },
+ ] as typeof store.threads;
+
+ const { emitted } = renderPanel({ props: { subject, launch } });
+ await vi.waitFor(() => expect(store.loadThreads).toHaveBeenCalled());
+ await vi.waitFor(() => expect(emitted('update:threadId')).toBeTruthy());
+
+ expect(emitted('update:threadId')?.[0]).toEqual(['t-newest']);
+ expect(store.syncThread).not.toHaveBeenCalled();
+ });
+
+ it('skips a non-matching thread when resuming the most recent for the subject', async () => {
+ store.threads = [
+ {
+ id: 't-other-agent',
+ title: 'Someone else',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-2', projectId: 'p1' } },
+ },
+ {
+ id: 't-match',
+ title: 'Existing',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } },
+ },
+ ] as typeof store.threads;
+
+ const { emitted } = renderPanel({ props: { subject, launch } });
+ await vi.waitFor(() => expect(emitted('update:threadId')).toBeTruthy());
+
+ expect(emitted('update:threadId')?.[0]).toEqual(['t-match']);
+ });
+
+ it('loads threads without blocking when a thread id is already given', async () => {
+ renderPanel({ props: { subject, launch, threadId: 't-match' } });
+ await vi.waitFor(() => expect(store.loadThreads).toHaveBeenCalled());
+ });
+
+ it('replaces an incoming threadId that targets a different agent and never mounts it', async () => {
+ store.getThreadMetadata.mockReturnValue({
+ instanceAiAgentBuilderTarget: { agentId: 'agent-99', projectId: 'p1' },
+ });
+ store.threads = [
+ {
+ id: 't-foreign',
+ title: 'Someone else',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-99', projectId: 'p1' } },
+ },
+ ] as typeof store.threads;
+
+ const { emitted, queryByTestId } = renderPanel({
+ props: { subject, launch, threadId: 't-foreign' },
+ });
+
+ await vi.waitFor(() => expect(emitted('update:threadId')).toBeTruthy());
+
+ expect(emitted('update:threadId')?.[0]).toEqual(['thread-2']);
+ // The panel wasn't told the replacement id (no real host rerender here), so
+ // the foreign thread must never have mounted a conversation in the meantime.
+ expect(queryByTestId('conversation-stub')).toBeNull();
+ });
+
+ it('resumes a thread it has to fetch first when validating an incoming threadId', async () => {
+ store.getThreadMetadata.mockReturnValue(undefined);
+ store.loadThread.mockImplementation(async () => {
+ store.getThreadMetadata.mockReturnValue({
+ instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' },
+ });
+ });
+
+ const { queryByTestId } = renderPanel({ props: { subject, launch, threadId: 't-remote' } });
+
+ await vi.waitFor(() => expect(store.loadThread).toHaveBeenCalledWith('t-remote'));
+ await vi.waitFor(() => expect(queryByTestId('conversation-stub')).not.toBeNull());
+ expect(store.syncThread).not.toHaveBeenCalled();
+ });
+
+ it('resumes or mints when the host clears the thread id while mounted', async () => {
+ const { rerender, emitted } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+ store.loadThreads.mockClear();
+
+ await rerender({ subject, launch, threadId: undefined });
+
+ await vi.waitFor(() => expect(store.loadThreads).toHaveBeenCalled());
+ await vi.waitFor(() => expect(emitted('update:threadId')?.at(-1)).toEqual(['thread-2']));
+ });
+
+ it('drops an orphaned mint that resolves after the panel unmounts', async () => {
+ const pending = Promise.withResolvers();
+ store.syncThread.mockImplementation(async () => {
+ await pending.promise;
+ });
+ const { getByTestId, emitted, unmount } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+ await fireEvent.click(getByTestId('instance-ai-embed-new-thread'));
+
+ unmount();
+ pending.resolve();
+ await flushPromises();
+
+ expect(emitted('update:threadId')).toBeFalsy();
+ expect(store.deleteThread).toHaveBeenCalledWith('thread-2', { silent: true });
+ });
+
+ it('drops an orphaned mint that resolves after the subject changes', async () => {
+ const pending = Promise.withResolvers();
+ store.syncThread.mockImplementation(async () => {
+ await pending.promise;
+ });
+ const { getByTestId, rerender, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+ await fireEvent.click(getByTestId('instance-ai-embed-new-thread'));
+
+ const otherSubject: InstanceAiEmbedSubject = { type: 'agent', id: 'agent-2', projectId: 'p1' };
+ await rerender({ subject: otherSubject, launch, threadId: 't-match' });
+ pending.resolve();
+ await flushPromises();
+
+ expect(emitted('update:threadId')).toBeFalsy();
+ expect(store.deleteThread).toHaveBeenCalledWith('thread-2', { silent: true });
+ });
+
+ it('drops an orphaned mint that resolves after the user picks a different thread', async () => {
+ const pending = Promise.withResolvers();
+ store.syncThread.mockImplementation(async () => {
+ await pending.promise;
+ });
+ const { getByTestId, rerender, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+ await fireEvent.click(getByTestId('instance-ai-embed-new-thread'));
+
+ // The user picked a different row while the mint was still in flight.
+ await rerender({ subject, launch, threadId: 't-picked' });
+ pending.resolve();
+ await flushPromises();
+
+ expect(emitted('update:threadId')).toBeFalsy();
+ expect(store.deleteThread).toHaveBeenCalledWith('thread-2', { silent: true });
+ });
+
+ it('filters the thread list by the subject', async () => {
+ store.threads = [
+ {
+ id: 't-match',
+ title: 'Matches',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } },
+ },
+ {
+ id: 't-other',
+ title: 'Other agent',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-2', projectId: 'p1' } },
+ },
+ ] as typeof store.threads;
+
+ const { getByTestId } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+
+ expect(getByTestId('list-count').textContent).toBe('1');
+ });
+
+ it('emits update:building for the subject', async () => {
+ const { emitted } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+ await vi.waitFor(() => expect(emitted('update:building')).toBeTruthy());
+ expect(emitted('update:building')?.[0]).toEqual([false]);
+
+ buildingIds.value = new Set(['agent-1']);
+ await vi.waitFor(() => expect(emitted('update:building')?.at(-1)).toEqual([true]));
+ expect(useAgentMutationRefreshMock).toHaveBeenCalled();
+ });
+
+ it('emits update:building false on unmount so a host closing the panel mid-build unlocks', async () => {
+ const { emitted, unmount } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+ await vi.waitFor(() => expect(emitted('update:building')).toBeTruthy());
+ buildingIds.value = new Set(['agent-1']);
+ await vi.waitFor(() => expect(emitted('update:building')?.at(-1)).toEqual([true]));
+
+ unmount();
+
+ expect(emitted('update:building')?.at(-1)).toEqual([false]);
+ });
+
+ it('marks the thread list disabled and ignores select/new from it while building', async () => {
+ const { getByTestId, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+ buildingIds.value = new Set(['agent-1']);
+ await vi.waitFor(() => expect(emitted('update:building')?.at(-1)).toEqual([true]));
+
+ expect(getByTestId('header-stub').dataset.disabled).toBe('true');
+
+ await fireEvent.click(getByTestId('instance-ai-embed-new-thread'));
+ await fireEvent.click(getByTestId('list-select'));
+
+ expect(emitted('update:threadId')).toBeFalsy();
+ });
+
+ it('mints a new thread when the list emits new', async () => {
+ const { getByTestId, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+ await fireEvent.click(getByTestId('instance-ai-embed-new-thread'));
+
+ await vi.waitFor(() => expect(emitted('update:threadId')?.at(-1)).toEqual(['thread-2']));
+ });
+
+ it('mints a new thread when the conversation reports thread-missing', async () => {
+ const { getByTestId, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+
+ await vi.waitFor(() => getByTestId('conversation-thread-missing'));
+ await fireEvent.click(getByTestId('conversation-thread-missing'));
+
+ await vi.waitFor(() => expect(emitted('update:threadId')?.at(-1)).toEqual(['thread-2']));
+ });
+
+ it('passes beforeSend through to the conversation', async () => {
+ const beforeSend = vi.fn();
+ const { getByTestId } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await vi.waitFor(() =>
+ expect(getByTestId('conversation-stub').dataset.hasBeforeSend).toBe('true'),
+ );
+ });
+
+ it('navigates to the full assistant thread view', async () => {
+ const { getByTestId } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+
+ await vi.waitFor(() =>
+ expect(routerPush).toHaveBeenCalledWith({
+ name: 'InstanceAiThread',
+ params: { threadId: 't-match' },
+ }),
+ );
+ });
+
+ it('awaits beforeSend before opening the full assistant view', async () => {
+ const order: string[] = [];
+ const beforeSend = vi.fn().mockImplementation(async () => {
+ order.push('beforeSend');
+ });
+ routerPush.mockImplementation(() => {
+ order.push('push');
+ });
+ const { getByTestId } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+
+ await vi.waitFor(() => expect(order).toEqual(['beforeSend', 'push']));
+ });
+
+ it('opens the full assistant view even if beforeSend rejects', async () => {
+ const beforeSend = vi.fn().mockRejectedValueOnce(new Error('flush failed'));
+ const { getByTestId } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+
+ await vi.waitFor(() => expect(routerPush).toHaveBeenCalled());
+ });
+
+ it('does not navigate if the panel unmounts while beforeSend is pending', async () => {
+ const pending = Promise.withResolvers();
+ const beforeSend = vi.fn().mockReturnValue(pending.promise);
+ const { getByTestId, unmount } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+ unmount();
+ pending.resolve();
+ await flushPromises();
+
+ expect(routerPush).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate if the subject changes while beforeSend is pending', async () => {
+ const pending = Promise.withResolvers();
+ const beforeSend = vi.fn().mockReturnValue(pending.promise);
+ const { getByTestId, rerender } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+ await rerender({ subject: { ...subject, id: 'agent-2' }, launch, threadId: 't-match' });
+ pending.resolve();
+ await flushPromises();
+
+ expect(routerPush).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate if the active thread changes while beforeSend is pending', async () => {
+ const pending = Promise.withResolvers();
+ const beforeSend = vi.fn().mockReturnValue(pending.promise);
+ const { getByTestId, rerender } = renderPanel({
+ props: { subject, launch, threadId: 't-match', beforeSend },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+ await rerender({ subject, launch, threadId: 't-other' });
+ await flushPromises();
+ pending.resolve();
+ await flushPromises();
+
+ expect(routerPush).not.toHaveBeenCalled();
+ });
+
+ it('emits close', async () => {
+ const { getByTestId, emitted } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-close'));
+
+ expect(emitted('close')).toEqual([[]]);
+ });
+
+ it('disposes the runtime on thread change and unmount', async () => {
+ const { rerender, unmount } = renderPanel({ props: { subject, launch, threadId: 't-match' } });
+
+ await rerender({ subject, launch, threadId: 't-next' });
+ expect(store.disposeRuntime).toHaveBeenCalledWith('t-match');
+
+ unmount();
+ expect(store.disposeRuntime).toHaveBeenCalledWith('t-next');
+ });
+
+ it('does not dispose the runtime handed off to the full assistant view', async () => {
+ const { getByTestId, rerender, unmount } = renderPanel({
+ props: { subject, launch, threadId: 't-match' },
+ });
+
+ await fireEvent.click(getByTestId('instance-ai-embed-open-full'));
+ await vi.waitFor(() => expect(routerPush).toHaveBeenCalled());
+ await rerender({ subject, launch, threadId: 't-next' });
+ unmount();
+
+ expect(store.disposeRuntime).not.toHaveBeenCalledWith('t-match');
+ expect(store.disposeRuntime).toHaveBeenCalledWith('t-next');
+ });
+
+ describe('handoff', () => {
+ const context: InstanceAiHandoffContext = {
+ source: 'agent-preview',
+ agentId: 'agent-1',
+ threadId: 'preview-1',
+ };
+ const draft: PendingComposerDraft = {
+ text: 'Change the system prompt',
+ prefillType: 'handoff_agent_change_request',
+ };
+
+ it('applies the handoff directly when the conversation is mounted and clean', async () => {
+ const wrapper = mountPanel({ subject, launch, threadId: 't-match' });
+ await flushPromises();
+
+ const result = wrapper.vm.handoff(context, draft);
+
+ expect(result).toBe(true);
+ expect(applyHandoffMock).toHaveBeenCalledWith(context, draft);
+ });
+
+ it('shows the finish-draft toast and skips applying when the conversation is dirty', async () => {
+ isDirtyMock.mockReturnValue(true);
+ const wrapper = mountPanel({ subject, launch, threadId: 't-match' });
+ await flushPromises();
+
+ const result = wrapper.vm.handoff(context);
+
+ expect(result).toBe(false);
+ expect(applyHandoffMock).not.toHaveBeenCalled();
+ expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
+ });
+
+ it('queues the handoff until the thread being minted gets an id, then stashes it there', async () => {
+ const pendingSubject: InstanceAiEmbedSubject = {
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'p1',
+ pending: true,
+ };
+ const syncing = Promise.withResolvers();
+ store.syncThread.mockImplementation(async () => {
+ await syncing.promise;
+ });
+
+ // No thread-id prop yet: the panel is minting one for the pending agent.
+ const wrapper = mountPanel({ subject: pendingSubject, launch });
+ const result = wrapper.vm.handoff(context, draft);
+
+ expect(result).toBe(true);
+ expect(applyHandoffMock).not.toHaveBeenCalled();
+ expect(getPendingHandoffContext('thread-2')).toBeNull();
+
+ syncing.resolve();
+ await vi.waitFor(() => expect(wrapper.emitted('update:threadId')).toBeTruthy());
+
+ expect(getPendingHandoffContext('thread-2')).toEqual(context);
+ expect(getPendingComposerDraft('thread-2')).toEqual(draft);
+ expect(store.updateThreadMetadata).toHaveBeenCalledWith('thread-2', {
+ instanceAiAgentPreviewView: { agentId: 'agent-1', threadId: 'preview-1' },
+ });
+ });
+
+ it('shows an error toast when the queued handoff metadata write fails', async () => {
+ const pendingSubject: InstanceAiEmbedSubject = {
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'p1',
+ pending: true,
+ };
+ // First call binds the pending-agent target (provisionSubjectThread) and must
+ // succeed so minting completes; the second is the queued handoff's own
+ // stash, which this test fails.
+ store.updateThreadMetadata
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error('nope'));
+
+ const wrapper = mountPanel({ subject: pendingSubject, launch });
+ wrapper.vm.handoff(context, draft);
+
+ await vi.waitFor(() => expect(wrapper.emitted('update:threadId')).toBeTruthy());
+ await vi.waitFor(() => expect(showError).toHaveBeenCalled());
+
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Something went wrong');
+ });
+
+ it('does not show an error toast if the subject changed before the queued handoff write settles', async () => {
+ const pendingSubject: InstanceAiEmbedSubject = {
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'p1',
+ pending: true,
+ };
+ const writeGate = Promise.withResolvers();
+ let writeCount = 0;
+ store.updateThreadMetadata.mockImplementation(async () => {
+ writeCount += 1;
+ // 1st call binds the pending-agent target (provisionSubjectThread); the
+ // 2nd is the queued handoff's own stash, held open until the gate fires.
+ if (writeCount === 1) return undefined;
+ await writeGate.promise;
+ throw new Error('nope');
+ });
+
+ const wrapper = mountPanel({ subject: pendingSubject, launch });
+ wrapper.vm.handoff(context, draft);
+ await vi.waitFor(() => expect(wrapper.emitted('update:threadId')).toBeTruthy());
+
+ await wrapper.setProps({ subject: { ...pendingSubject, id: 'agent-2' } });
+ writeGate.resolve();
+ await flushPromises();
+
+ expect(showError).not.toHaveBeenCalled();
+ });
+
+ it('does not show an error toast if the panel unmounted before the queued handoff write settles', async () => {
+ const pendingSubject: InstanceAiEmbedSubject = {
+ type: 'agent',
+ id: 'agent-1',
+ projectId: 'p1',
+ pending: true,
+ };
+ const writeGate = Promise.withResolvers();
+ let writeCount = 0;
+ store.updateThreadMetadata.mockImplementation(async () => {
+ writeCount += 1;
+ if (writeCount === 1) return undefined;
+ await writeGate.promise;
+ throw new Error('nope');
+ });
+
+ const wrapper = mountPanel({ subject: pendingSubject, launch });
+ wrapper.vm.handoff(context, draft);
+ await vi.waitFor(() => expect(wrapper.emitted('update:threadId')).toBeTruthy());
+
+ wrapper.unmount();
+ writeGate.resolve();
+ await flushPromises();
+
+ expect(showError).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/instanceAiEmbed.types.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/instanceAiEmbed.types.test.ts
new file mode 100644
index 00000000000..38297a2edd7
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/instanceAiEmbed.types.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+
+import { threadTargetsSubject, type InstanceAiEmbedSubject } from '../instanceAiEmbed.types';
+
+const agentSubject: InstanceAiEmbedSubject = { type: 'agent', id: 'agent-1', projectId: 'p1' };
+
+describe('threadTargetsSubject', () => {
+ it('matches the thread bound target', () => {
+ const metadata = { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } };
+ expect(threadTargetsSubject(metadata, agentSubject)).toBe(true);
+ });
+
+ it('matches the thread pending target', () => {
+ const metadata = { instanceAiPendingAgentTarget: { agentId: 'agent-1', projectId: 'p1' } };
+ expect(threadTargetsSubject(metadata, agentSubject)).toBe(true);
+ });
+
+ it('matches any entry of the target registry', () => {
+ const metadata = {
+ instanceAiAgentBuilderTargets: {
+ billing: { agentId: 'agent-2', projectId: 'p1', ref: 'billing' },
+ support: { agentId: 'agent-1', projectId: 'p1', ref: 'support' },
+ },
+ };
+ expect(threadTargetsSubject(metadata, agentSubject)).toBe(true);
+ });
+
+ it('does not match a different agent', () => {
+ const metadata = { instanceAiAgentBuilderTarget: { agentId: 'agent-2', projectId: 'p1' } };
+ expect(threadTargetsSubject(metadata, agentSubject)).toBe(false);
+ });
+
+ it('does not match undefined or malformed metadata', () => {
+ expect(threadTargetsSubject(undefined, agentSubject)).toBe(false);
+ expect(
+ threadTargetsSubject({ instanceAiAgentBuilderTargets: 'not-a-record' }, agentSubject),
+ ).toBe(false);
+ });
+
+ it('always returns false for a workflow subject', () => {
+ const metadata = { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } };
+ expect(
+ threadTargetsSubject(metadata, { type: 'workflow', id: 'agent-1', projectId: 'p1' }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/useInstanceAiEmbedThreads.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/useInstanceAiEmbedThreads.test.ts
new file mode 100644
index 00000000000..0e9352f1904
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/__tests__/useInstanceAiEmbedThreads.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { ref } from 'vue';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+
+import { mockedStore } from '@/__tests__/utils';
+import { useInstanceAiStore } from '../../instanceAi.store';
+import { useInstanceAiEmbedThreads } from '../useInstanceAiEmbedThreads';
+import type { InstanceAiEmbedSubject } from '../instanceAiEmbed.types';
+
+describe('useInstanceAiEmbedThreads', () => {
+ let store: ReturnType>;
+
+ beforeEach(() => {
+ setActivePinia(createTestingPinia());
+ store = mockedStore(useInstanceAiStore);
+ });
+
+ it('filters store.threads by the subject', () => {
+ const subject = ref({ type: 'agent', id: 'agent-1', projectId: 'p1' });
+ store.threads = [
+ {
+ id: 'thread-1',
+ title: 'Targets agent-1',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-1', projectId: 'p1' } },
+ },
+ {
+ id: 'thread-2',
+ title: 'Targets agent-2',
+ createdAt: '',
+ updatedAt: '',
+ metadata: { instanceAiAgentBuilderTarget: { agentId: 'agent-2', projectId: 'p1' } },
+ },
+ ] as typeof store.threads;
+
+ const { threads } = useInstanceAiEmbedThreads(subject);
+
+ expect(threads.value.map((t) => t.id)).toEqual(['thread-1']);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/instanceAiEmbed.types.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/instanceAiEmbed.types.ts
new file mode 100644
index 00000000000..22c27fdc08d
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/instanceAiEmbed.types.ts
@@ -0,0 +1,43 @@
+import { isRecord } from '@n8n/utils/is-record';
+import type { InstanceAiAgentAttachment } from '@n8n/api-types';
+
+import { INSTANCE_AI_AGENT_BUILDER_TARGETS_METADATA_KEY } from '../constants';
+import {
+ getAgentBuilderTargetFromThreadMetadata,
+ getPendingAgentTargetFromThreadMetadata,
+} from '../instanceAi.threadRuntime';
+
+/**
+ * A resource an embedded `InstanceAiChatPanel` is scoped to: which thread(s) it
+ * shows in its history list, and (for a pending agent) what a newly minted
+ * thread should bind to. The agent variant IS the api-types agent attachment —
+ * `InstanceAiChatPanel` passes it straight through to `stashPendingAgentAttachment`.
+ * `workflow` is the follow-up host (`NodeView`) — see `threadTargetsSubject` below.
+ */
+export type InstanceAiEmbedSubject =
+ | InstanceAiAgentAttachment
+ | { type: 'workflow'; id: string; projectId: string; name?: string };
+
+/**
+ * Does this thread belong to the subject's history list? A thread targets an
+ * agent when it's the thread's bound target, its pending target, or any entry
+ * of the session's target registry (every agent the model has addressed in
+ * this conversation).
+ */
+export function threadTargetsSubject(
+ metadata: Record | undefined,
+ subject: InstanceAiEmbedSubject,
+): boolean {
+ if (subject.type === 'workflow') {
+ // TODO(AGENT-810 follow-up): workflows have no thread-metadata binding key
+ // yet (see plan §7 / NodeView host) — always excluded until that lands.
+ return false;
+ }
+
+ if (getAgentBuilderTargetFromThreadMetadata(metadata)?.agentId === subject.id) return true;
+ if (getPendingAgentTargetFromThreadMetadata(metadata)?.agentId === subject.id) return true;
+
+ const registry = metadata?.[INSTANCE_AI_AGENT_BUILDER_TARGETS_METADATA_KEY];
+ if (!isRecord(registry)) return false;
+ return Object.values(registry).some((entry) => isRecord(entry) && entry.agentId === subject.id);
+}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/useInstanceAiEmbedThreads.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/useInstanceAiEmbedThreads.ts
new file mode 100644
index 00000000000..4a1c71515b8
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/useInstanceAiEmbedThreads.ts
@@ -0,0 +1,17 @@
+import { computed, type ComputedRef, type Ref } from 'vue';
+
+import { useInstanceAiStore } from '../instanceAi.store';
+import { threadTargetsSubject, type InstanceAiEmbedSubject } from './instanceAiEmbed.types';
+
+/** The threads an embedded panel's history list shows, filtered by subject. */
+export function useInstanceAiEmbedThreads(
+ subject: Ref | ComputedRef,
+) {
+ const store = useInstanceAiStore();
+
+ const threads = computed(() =>
+ store.threads.filter((thread) => threadTargetsSubject(thread.metadata, subject.value)),
+ );
+
+ return { threads };
+}
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 29dc3e2f782..9609fc3ab6e 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
@@ -16,6 +16,7 @@ import {
type InstanceAiAttachment,
type InstanceAiEvent,
type InstanceAiMessage,
+ type InstanceAiThreadSummary,
type InstanceAiAgentNode,
type InstanceAiToolCallState,
type InstanceAiSSEConnectionState,
@@ -55,6 +56,7 @@ import {
INSTANCE_AI_AGENT_PREVIEW_SESSION_METADATA_KEY,
INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY,
INSTANCE_AI_PENDING_AGENT_METADATA_KEY,
+ NEW_CONVERSATION_TITLE,
} from './constants';
import {
findToolCallInTree,
@@ -116,6 +118,25 @@ export interface ThreadRuntimeHooks {
getThreadMetadata?: (threadId: string) => Record | undefined;
}
+/**
+ * The title a thread shows in a header: the summary title once the server has
+ * generated one, else the first user message (truncated), else undefined —
+ * rendering only on a defined value avoids a "New conversation" → real title
+ * flash. Shared by `InstanceAiThreadView` and the embedded `InstanceAiChatPanel`.
+ */
+export function getThreadDisplayTitle(
+ summary: InstanceAiThreadSummary | undefined,
+ messages: InstanceAiMessage[],
+): string | undefined {
+ if (summary?.title && summary.title !== NEW_CONVERSATION_TITLE) return summary.title;
+ const firstUserMessage = messages.find((message) => message.role === 'user');
+ if (firstUserMessage?.content) {
+ const text = firstUserMessage.content.trim();
+ return text.length > 60 ? text.slice(0, 60) + '…' : text;
+ }
+ return undefined;
+}
+
export function getAgentBuilderTargetFromThreadMetadata(
metadata: Record | undefined,
) {