feat(editor): Add the n8n Assistant panel to the agent builder (#38726)

This commit is contained in:
Benjamin Schroth 2026-09-16 13:49:17 +00:00 committed by GitHub
parent 7867844fbb
commit 9f0e653c5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2965 additions and 258 deletions

View File

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

View File

@ -196,6 +196,7 @@ async function mountColumn(
template:
'<div><div v-if="showModel !== false" data-testid="agent-model-panel" /><div v-if="showInstructions !== false" data-testid="agent-instructions-panel" /></div>',
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 });

View File

@ -34,6 +34,11 @@ vi.mock('vue-router', () => ({
}));
vi.mock('@n8n/design-system', () => ({
N8nAssistantIcon: {
name: 'N8nAssistantIcon',
template: '<i data-testid="stub-assistant-icon" />',
props: ['size'],
},
N8nIcon: { template: '<i v-bind="$attrs"></i>', 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);

View File

@ -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<Record<string, string | undefined>>({});
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<string, string> }) => void | Promise<void>;
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<typeof vi.spyOn> | undefined;
let revokeObjectURLSpy: ReturnType<typeof vi.spyOn> | undefined;
let anchorClickSpy: ReturnType<typeof vi.spyOn> | 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:
'<div data-testid="stub-agent-builder-header" :data-project-name="projectName" :data-artifact-mode="String(artifactMode)" :data-config-validation-status="String(configValidationStatus)" :data-save-status="String(saveStatus)"></div>',
'<div data-testid="stub-agent-builder-header" :data-project-name="projectName" :data-artifact-mode="String(artifactMode)" :data-config-validation-status="String(configValidationStatus)" :data-save-status="String(saveStatus)" :data-instance-ai-available="String(instanceAiAvailable)" :data-ai-panel-open="String(isAiPanelOpen)"><button data-testid="stub-toggle-instance-ai" @click="$emit(\'toggle-instance-ai\')" /><button data-testid="stub-switch-agent" @click="$emit(\'switch-agent\', \'a2\')" /></div>',
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: '<aside />',
},
InstanceAiChatPanel: {
name: 'InstanceAiChatPanel',
template:
'<div data-testid="stub-ai-chat-panel" :data-subject-id="subject?.id" :data-thread-id="threadId">' +
'<button data-testid="ai-panel-emit-thread-id" @click="$emit(\'update:threadId\', \'thread-99\')" />' +
'<button data-testid="ai-panel-emit-building" @click="$emit(\'update:building\', true)" />' +
'<button data-testid="ai-panel-emit-close" @click="$emit(\'close\')" />' +
'</div>',
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: '<div data-testid="stub-agent-building-indicator" />',
},
// 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<ReturnType<typeof makeAgentResponse>>();
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<ReturnType<typeof makeAgentResponse>>();
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<ReturnType<typeof makeAgentResponse>>();
@ -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<void> }).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: {

View File

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

View File

@ -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 <provider>" 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'],
]);
});
});

View File

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

View File

@ -67,7 +67,7 @@ const isMcpAvailable = computed(
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
'update:config': [updates: Partial<AgentJsonConfig>];
'update:config': [updates: Partial<AgentJsonConfig>, 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)"
/>
<AgentPanel

View File

@ -10,11 +10,13 @@ import { computed, onMounted, useCssModule } from 'vue';
import { useRouter, type RouteLocationRaw } from 'vue-router';
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import {
N8nAssistantIcon,
N8nBreadcrumbs,
N8nButton,
N8nDropdownMenu,
N8nDropdownMenuItem,
N8nIcon,
N8nToggle,
} from '@n8n/design-system';
import type { PathItem } from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
@ -38,6 +40,10 @@ const props = defineProps<{
beforeRevertToPublished?: () => Promise<void> | 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) {
<template>
<header :class="$style.header" data-testid="agent-builder-header">
<div :class="$style.left">
<N8nToggle
v-if="!props.artifactMode && props.instanceAiAvailable"
:model-value="props.isAiPanelOpen"
variant="ghost"
size="medium"
:label="i18n.baseText('agents.builder.header.editWithAi')"
:disabled="!props.agent"
data-testid="agent-builder-instance-ai-btn"
@click="emit('toggle-instance-ai')"
>
<N8nAssistantIcon size="large" />
</N8nToggle>
<N8nBreadcrumbs
v-if="!props.artifactMode"
:items="breadcrumbItems"

View File

@ -65,7 +65,9 @@ const props = withDefaults(
immediateUpdates: false,
},
);
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
const emit = defineEmits<{
'update:config': [changes: Partial<AgentJsonConfig>, 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(

View File

@ -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<typeof getPendingDefaul
pendingDefaultCredential.value = null;
if (result.status === 'resolved' && result.defaultModel) {
emit('change', {
provider: result.defaultModel.provider,
model: result.defaultModel.model,
});
emit(
'change',
{
provider: result.defaultModel.provider,
model: result.defaultModel.model,
},
'auto',
);
}
}

View File

@ -43,6 +43,10 @@ export interface UseAgentConfigAutosaveParams<TSnapshot> {
export function useAgentConfigAutosave<TSnapshot>(params: UseAgentConfigAutosaveParams<TSnapshot>) {
const saveStatus = ref<SaveStatus>('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<TSnapshot>(params: UseAgentConfigAutosave
function syncPendingState() {
hasPendingSave.value = pendingSnapshot !== null || autosaveInFlight !== null;
hasQueuedSnapshot.value = pendingSnapshot !== null;
}
async function runSave(
@ -188,6 +193,7 @@ export function useAgentConfigAutosave<TSnapshot>(params: UseAgentConfigAutosave
pendingSnapshot = null;
pendingSnapshotRevision = 0;
pendingSnapshotGeneration = 0;
syncPendingState();
void chainSave(target, targetRevision, targetGeneration, false);
}, getDebounceTime(debounceMs));
}
@ -212,6 +218,7 @@ export function useAgentConfigAutosave<TSnapshot>(params: UseAgentConfigAutosave
pendingSnapshot = null;
pendingSnapshotRevision = 0;
pendingSnapshotGeneration = 0;
syncPendingState();
if (target !== null) {
try {
@ -276,6 +283,7 @@ export function useAgentConfigAutosave<TSnapshot>(params: UseAgentConfigAutosave
return {
saveStatus,
hasPendingSave,
hasQueuedSnapshot,
scheduleAutosave,
settleAutosave,
flushAutosave,

View File

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

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue';
import { useEventListener, useStorage } from '@vueuse/core';
import { StorageSerializers, useEventListener, useLocalStorage, useStorage } from '@vueuse/core';
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
import {
N8nAssistantIcon,
N8nButton,
N8nCanvasPill,
N8nIcon,
N8nIconButton,
N8nResizeWrapper,
type ActionDropdownItem,
} from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
@ -19,6 +18,7 @@ import {
MAX_AGENT_KNOWLEDGE_BASE_SIZE_GB,
addMissingAgentPersonalisation,
type AgentFileDto,
type InstanceAiHandoffContext,
type PushMessage,
type PushPayload,
} from '@n8n/api-types';
@ -82,6 +82,7 @@ import {
AGENT_SESSION_DETAIL_VIEW,
AGENT_JSON_IMPORT_MODAL_KEY,
AGENT_VECTOR_STORES_MODAL_KEY,
ASSISTANT_THREAD_PARAM,
CONTINUE_SESSION_ID_PARAM,
NEW_SESSION_PARAM,
OPEN_PREVIEW_PARAM,
@ -94,9 +95,23 @@ import AgentPreviewHeader from '../components/AgentPreviewHeader.vue';
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
import AgentPreviewDock from '../components/AgentPreviewDock.vue';
import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue';
import { useInstanceAiHandoff } from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
import { useInstanceAiAvailable } from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import { INSTANCE_AI_PENDING_AGENT_ID_STATE } from '@/features/ai/instanceAi/constants';
import {
buildInstanceAiAgentPreviewHandoffContext,
type InstanceAiThreadLaunch,
type PendingComposerDraft,
} from '@/features/ai/instanceAi/composables/useInstanceAiHandoff';
import {
useInstanceAiAvailable,
useInstanceAiReady,
} from '@/features/ai/instanceAi/composables/useInstanceAiAvailability';
import {
INSTANCE_AI_PENDING_AGENT_ID_STATE,
INSTANCE_AI_VIEW,
} from '@/features/ai/instanceAi/constants';
import InstanceAiChatPanel from '@/features/ai/instanceAi/embed/InstanceAiChatPanel.vue';
import { persistPendingAgent } from '@/features/ai/instanceAi/instanceAi.memory.api';
import type { InstanceAiEmbedSubject } from '@/features/ai/instanceAi/embed/instanceAiEmbed.types';
import AgentBuildingIndicator from '@/features/ai/instanceAi/components/AgentBuildingIndicator.vue';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { buildAgentChangeRequestPrompt } from '../utils/agent-change-request';
@ -153,10 +168,9 @@ const rootStore = useRootStore();
const pushConnectionStore = usePushConnectionStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const { openAgentArtifactThread } = useInstanceAiHandoff();
const instanceAiAvailable = useInstanceAiAvailable();
const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } =
useInstanceAiAgentPreviewHandoff();
const instanceAiReady = useInstanceAiReady();
const { canSendPreviewToInstanceAi } = useInstanceAiAgentPreviewHandoff();
const sessionsStore = useAgentSessionsStore();
const agentEvalsStore = useAgentEvalsStore();
const credentialsStore = useCredentialsStore();
@ -215,6 +229,90 @@ const isPreviewDockOpen = computed(function isPreviewDockOpen() {
const isPreviewActive = computed(function isPreviewActive() {
return isStandalonePreview.value || isPreviewDockOpen.value;
});
// Embedded n8n Assistant panel (left dock, mirrors the preview dock on the right).
// Default open for a pending agent so a new agent lands with the assistant
// already showing; closed otherwise. Persisted per agent, like the preview dock.
// null = no preference yet, so the default is derived instead of stored — see
// `InstanceAiThreadView`'s `persistedArtifactPreviewOpen` for the same pattern.
const aiPanelOpenStorageKey = computed(function getAiPanelOpenStorageKey() {
return `N8N_AGENT_AI_PANEL_OPEN:${projectId.value}:${agentId.value}`;
});
const storedAiPanelOpen = useLocalStorage<boolean | null>(aiPanelOpenStorageKey, null, {
serializer: StorageSerializers.boolean,
writeDefaults: false,
flush: 'sync',
});
// The pending-agent default is decided once, when the agent opens. Persisting
// the agent later (a real edit, or the assistant finishing a build) flips
// `isRouteAgentPending` to false, which must not close the panel out from
// under the user — so the default is snapshotted per agent instead of reread live.
const openedForPendingAgent = ref(isRouteAgentPending.value);
watch(agentId, () => {
openedForPendingAgent.value = isRouteAgentPending.value;
});
const isAiPanelOpen = computed({
get: () => storedAiPanelOpen.value ?? (openedForPendingAgent.value && instanceAiReady.value),
set: (value: boolean) => {
storedAiPanelOpen.value = value;
},
});
const showAiPanel = computed(
() =>
!isArtifactMode.value &&
!isStandalonePreview.value &&
instanceAiReady.value &&
isAiPanelOpen.value,
);
const aiThreadId = computed(() =>
typeof route.query[ASSISTANT_THREAD_PARAM] === 'string'
? route.query[ASSISTANT_THREAD_PARAM]
: undefined,
);
function onAiThreadIdChange(threadId: string) {
void router.replace({ query: { ...route.query, [ASSISTANT_THREAD_PARAM]: threadId } });
}
/** True while the embedded assistant is actively mutating this agent. */
const embeddedAiBuilding = ref(false);
const aiPanelRef = useTemplateRef<InstanceType<typeof InstanceAiChatPanel>>('aiPanelRef');
// The standalone preview route doesn't render the AI dock (`showAiPanel`
// requires the builder route), so a hand-off requested from there has nowhere
// to land yet. Queue it and apply it once the panel actually mounts — closing
// the preview navigates back to the builder, which renders the panel.
const queuedAiHandoff = ref<{
context: InstanceAiHandoffContext;
initialDraft?: PendingComposerDraft;
} | null>(null);
watch(aiPanelRef, (panel) => {
if (!panel || !queuedAiHandoff.value) return;
const { context, initialDraft } = queuedAiHandoff.value;
queuedAiHandoff.value = null;
panel.handoff(context, initialDraft);
});
const isEditingLocked = computed(() => props.artifactEditingLocked || embeddedAiBuilding.value);
const aiPanelWidth = useStorage('N8N_AGENT_AI_PANEL_WIDTH', 400);
function onAiPanelResize({ width }: { width: number }) {
aiPanelWidth.value = width;
}
function toggleAiPanel() {
// Setup isn't finished — send the user to the assistant, where onboarding
// takes over, instead of opening a panel no model can answer in.
if (!instanceAiReady.value) {
void router.push({ name: INSTANCE_AI_VIEW });
return;
}
const opening = !isAiPanelOpen.value;
if (opening) {
telemetry.track('Instance AI opened from editor', {
source: 'agent_builder_page',
agent_id: agentId.value,
workflow_id: null,
execution_id: null,
});
}
isAiPanelOpen.value = opening;
}
const agentBuilderHref = computed(function getAgentBuilderHref() {
return router.resolve({
name: AGENT_BUILDER_VIEW,
@ -229,11 +327,11 @@ const {
canDelete: canDeleteAgent,
canExecute: canExecuteAgent,
} = useAgentPermissions(projectId);
// Combines permission with the artifact-mode build lock: while the AI is
// actively building/mutating this agent, editing is disabled even for a user
// who otherwise has permission — mirrors the workflow artifact's read-only
// lock during a build.
const effectiveCanEditAgent = computed(() => canEditAgent.value && !props.artifactEditingLocked);
// Combines permission with the build lock: while the AI (the artifact-mode
// host or the embedded panel) is actively building/mutating this agent,
// editing is disabled even for a user who otherwise has permission — mirrors
// the workflow artifact's read-only lock during a build.
const effectiveCanEditAgent = computed(() => canEditAgent.value && !isEditingLocked.value);
const isVersionHistoryOpen = ref(false);
@ -305,10 +403,38 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) {
return;
}
// Close the preview once the assistant has the request: coming back to an
// open preview chat beside the assistant reads as two places to ask.
// No route push — the hand-off already navigated to the assistant.
if (await sendPreviewSessionToInstanceAi(params)) persistedPreviewOpen.value = false;
// Setup isn't finished — send the user to the assistant, where onboarding
// takes over, instead of opening a panel no model can answer in.
if (!instanceAiReady.value) {
void router.push({ name: INSTANCE_AI_VIEW });
return;
}
// Open the dock (it's `v-if`) and wait a tick so `aiPanelRef` resolves
// before the hand-off is attempted.
isAiPanelOpen.value = true;
await nextTick();
const context = buildInstanceAiAgentPreviewHandoffContext(params);
if (aiPanelRef.value) {
const handed = aiPanelRef.value.handoff(context, params.initialDraft);
if (!handed) return;
// Close the preview once the assistant has the request: coming back to an
// open preview chat beside the assistant reads as two places to ask.
closePreviewDock();
} else {
// Standalone preview route: the panel isn't mounted here. Queue the
// hand-off and close the dock — on this route that navigates back to
// the builder, which mounts the panel and applies the queue.
queuedAiHandoff.value = { context, initialDraft: params.initialDraft };
closePreviewDock();
}
telemetry.track(TELEMETRY_EVENT.AGENTS.INSTANCE_AI_OPENED_FROM_AGENT_PREVIEW, {
agent_id: params.agentId,
preview_thread_id: params.threadId,
...(params.executionId ? { preview_execution_id: params.executionId } : {}),
});
}
/**
@ -330,8 +456,24 @@ let latestSessionsFetchRequestId = 0;
const isUnsaved = ref(false);
/** Queues `agentUpdated` bus events that land mid-initialize for replay (see `onExternalAgentUpdated`). */
const pendingExternalRefresh = ref(false);
/** The queued event's `source`, consumed by `onAgentUpdateRefreshed` once the replay resolves. */
let pendingExternalRefreshSource: string | undefined;
const agentName = ref('');
const agent = ref<AgentResource | null>(null);
/** Scopes the embedded n8n Assistant panel to this agent's thread history. */
const instanceAiEmbedSubject = computed<InstanceAiEmbedSubject>(() => ({
type: 'agent',
id: agentId.value,
projectId: projectId.value,
name: agent.value?.name,
// The attachment's `pending` is a `true`-only literal (absent means "not pending").
...((isRouteAgentPending.value || isUnsaved.value) && { pending: true as const }),
}));
const instanceAiEmbedLaunch = computed<InstanceAiThreadLaunch>(() => ({
source: 'agent_builder_page',
origin: 'internal',
sourceContext: { agentId: agentId.value },
}));
const agentFiles = ref<AgentFileDto[]>([]);
const agentFilesLoading = ref(false);
const agentFilesUploading = ref(false);
@ -895,11 +1037,22 @@ async function ensureAgentPersisted(): Promise<void> {
if (!flight) {
flight = (async () => {
const name = localConfig.value?.name ?? locale.baseText('agents.new.defaultName');
// A thread is bound (the embedded assistant may be building the same
// agent right now): adopt-or-create through the thread so a race with
// its own create converges on one row instead of a 409.
const created = props.artifactPersistAgent
? await props.artifactPersistAgent(name)
: await createAgent(rootStore.restApiContext, targetProjectId, name, {
id: targetAgentId,
});
: aiThreadId.value
? (
await persistPendingAgent(rootStore.restApiContext, aiThreadId.value, {
projectId: targetProjectId,
agentId: targetAgentId,
name,
})
).agent
: await createAgent(rootStore.restApiContext, targetProjectId, name, {
id: targetAgentId,
});
persistedAgentsByTarget.set(targetKey, created);
upsertProjectAgentsListCache(targetProjectId, created);
clearRoutePendingState(targetAgentId);
@ -937,7 +1090,7 @@ async function handleAutosaveConflict(snapshot: {
async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<AutosaveResult> {
// The AI may be mutating this agent right now — a save queued just before
// the lock engaged must not persist its now-stale full config over it.
if (props.artifactEditingLocked) return 'skipped';
if (isEditingLocked.value) return 'skipped';
await ensureAgentPersisted();
let result;
try {
@ -981,7 +1134,7 @@ async function saveConfig(snapshot: ConfigAutosaveSnapshot): Promise<AutosaveRes
}
async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<AutosaveResult> {
if (props.artifactEditingLocked) return 'skipped';
if (isEditingLocked.value) return 'skipped';
await ensureAgentPersisted();
let result;
try {
@ -1054,6 +1207,8 @@ const agentAvailableInMcp = computed(
async function saveMcpAvailability(
snapshot: McpAvailabilitySnapshot,
): Promise<'skipped' | undefined> {
// The AI may be mutating this agent right now — mirrors `saveConfig`/`saveSkill`.
if (isEditingLocked.value) return 'skipped';
await ensureAgentPersisted();
await mcpStore.toggleAgentMcpAccess(snapshot.agentId, snapshot.enabled);
if (snapshot.enabled) {
@ -1118,7 +1273,7 @@ async function settleAutosave() {
async function flushAutosave() {
// Locked means the AI is mutating this agent right now — flushing a
// pending edit here would persist a stale full config over its writes.
if (props.artifactEditingLocked) {
if (isEditingLocked.value) {
configAutosave.cancelPendingAutosave();
skillAutosave.cancelPendingAutosave();
mcpAutosave.cancelPendingAutosave();
@ -1166,11 +1321,15 @@ async function beforePreviewSend() {
// Makes the lock a write boundary rather than only a disabled UI state: drop
// any autosave queued before the AI started mutating this agent.
watch(
() => props.artifactEditingLocked,
() => isEditingLocked.value,
(locked) => {
if (!locked) return;
configAutosave.cancelPendingAutosave();
skillAutosave.cancelPendingAutosave();
mcpAutosave.cancelPendingAutosave();
// The dropped toggle never persisted — don't leave its optimistic value
// showing once the lock releases.
mcpAvailabilityOverride.value = null;
},
);
@ -1191,31 +1350,6 @@ async function refreshValidationBeforePublish(): Promise<boolean> {
return configValidation.value !== null && !hasBlockingIssues(configValidation.value.issues);
}
/** Open the current agent in Instance AI without sending an opening message. */
async function onOpenInstanceAi() {
// Flush pending edits first so the assistant sees the latest config.
await flushAutosave();
telemetry.track('Instance AI opened from editor', {
source: 'agent_builder_page',
agent_id: agentId.value,
workflow_id: null,
execution_id: null,
});
await openAgentArtifactThread(
{
type: 'agent',
id: agentId.value,
name: agent.value?.name,
projectId: projectId.value,
},
{
source: 'agent_builder_page',
origin: 'internal',
sourceContext: { agentId: agentId.value },
},
);
}
function normalizeAgentMemoryConfig(config: AgentJsonConfig): AgentJsonConfig {
return {
...config,
@ -1227,7 +1361,7 @@ function normalizeAgentMemoryConfig(config: AgentJsonConfig): AgentJsonConfig {
};
}
function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>, meta?: { source: 'auto' }) {
if (!localConfig.value) return;
// The persisted validation result no longer reflects the working copy —
// Publish must not stay enabled against a result that predates this edit.
@ -1238,6 +1372,14 @@ function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
if (updates.name !== undefined) {
syncAgentIdentityFromConfig(localConfig.value);
}
// An auto-applied default (e.g. the seeded model) must not persist a pending
// agent on its own — it rides along with the draft and is saved by the first
// real edit or an assistant build instead. Except when a real edit's save is
// already queued OR in flight: that snapshot was deep-copied before this
// update landed, so it must be rescheduled (chained behind an in-flight one
// if needed) to pick up the merged config.
const ridesAlongWithDraft = meta?.source === 'auto' && isUnsaved.value;
if (ridesAlongWithDraft && !configAutosave.hasPendingSave.value) return;
configAutosave.scheduleAutosave({
projectId: projectId.value,
agentId: agentId.value,
@ -1336,6 +1478,18 @@ function handleArtifactRefreshError(error: unknown) {
showError(error, locale.baseText('agents.builder.loadError'));
}
/**
* The assistant can build a route-pending agent under the id this view minted.
* Once the refetch above confirms the row exists, treat it as persisted the same
* way `ensureAgentPersisted` does, so a reload never re-enters pending mode.
*/
function onAgentUpdateRefreshed(source?: string) {
if (source !== 'instance-ai') return;
if (!isRouteAgentPending.value || !isUnsaved.value || !agent.value) return;
isUnsaved.value = false;
clearRoutePendingState(agentId.value);
}
let externalRefreshTimer: ReturnType<typeof setTimeout> | undefined;
let externalUpdateTimer: ReturnType<typeof setTimeout> | undefined;
let externalUpdateAgeTimer: ReturnType<typeof setInterval> | undefined;
@ -1384,23 +1538,45 @@ watch([projectId, agentId], clearExternalUpdate);
const canApplyPushedAgentUpdate = computed(
() =>
!props.artifactEditingLocked &&
!isEditingLocked.value &&
!configAutosave.hasPendingSave.value &&
!skillAutosave.hasPendingSave.value &&
!mcpAutosave.hasPendingSave.value,
);
function scheduleExternalRefresh(onlyWhenIdle = false) {
/**
* Refetch, then run the "mark pending agent persisted" continuation — but only
* against the agent the refresh was scheduled for. Both the timer callback and
* the idle replay below capture their target up front and race an `await`, so
* a target switch in between must not let A's refresh mark pending agent B
* persisted.
*/
async function refreshAndMarkUpdated(
targetProjectId: string,
targetAgentId: string,
source?: string,
) {
await refreshArtifactShell();
if (isStaleAgentTarget(targetProjectId, targetAgentId)) return;
onAgentUpdateRefreshed(source);
}
function scheduleExternalRefresh(onlyWhenIdle = false, source?: string) {
clearTimeout(externalRefreshTimer);
const targetProjectId = projectId.value;
const targetAgentId = agentId.value;
externalRefreshTimer = setTimeout(() => {
// A push that lands mid-autosave is deferred, not dropped: if the local
// write was accepted first there is no 409 to catch the newer remote
// state, so this tab would keep the older config. Replayed once idle.
if (onlyWhenIdle && !canApplyPushedAgentUpdate.value) {
pendingExternalRefresh.value = true;
pendingExternalRefreshSource = source;
return;
}
void refreshArtifactShell().catch(handleArtifactRefreshError);
void refreshAndMarkUpdated(targetProjectId, targetAgentId, source).catch(
handleArtifactRefreshError,
);
}, getDebounceTime(400));
}
@ -1417,9 +1593,10 @@ function onExternalAgentUpdated(event?: AgentUpdatedEvent) {
// fetch already resolved, so queue a replay instead of dropping the event.
if (!initialized.value) {
pendingExternalRefresh.value = true;
pendingExternalRefreshSource = event.source;
return;
}
scheduleExternalRefresh(event?.source === 'push');
scheduleExternalRefresh(event.source === 'push', event.source);
}
function onAgentPushMessage(event: PushMessage) {
@ -1445,7 +1622,9 @@ function onAgentPushMessage(event: PushMessage) {
async function replayPendingExternalRefresh() {
if (!pendingExternalRefresh.value) return;
pendingExternalRefresh.value = false;
await refreshArtifactShell();
const source = pendingExternalRefreshSource;
pendingExternalRefreshSource = undefined;
await refreshAndMarkUpdated(projectId.value, agentId.value, source);
}
agentsEventBus.on('agentUpdated', onExternalAgentUpdated);
@ -2099,10 +2278,12 @@ function onContinueLoaded({ sessionId, count }: AgentContinueLoadedEvent) {
function onSwitchAgent(nextAgentId: string) {
if (!nextAgentId || nextAgentId === agentId.value) return;
// The assistant thread is bound to this agent; the next agent gets its own.
const { [ASSISTANT_THREAD_PARAM]: _assistantThread, ...query } = route.query;
void router.push({
name: isStandalonePreview.value ? AGENT_PREVIEW_VIEW : AGENT_BUILDER_VIEW,
params: { projectId: projectId.value, agentId: nextAgentId },
query: isStandalonePreview.value ? {} : route.query,
query: isStandalonePreview.value ? {} : query,
});
}
</script>
@ -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"
/>
<div :class="$style.externalUpdateNotice" role="status" aria-live="polite" aria-atomic="true">
<N8nCanvasPill
@ -2174,30 +2358,35 @@ function onSwitchAgent(nextAgentId: string) {
$style.builder,
{
[$style.previewOpen]: isPreviewDockOpen,
[$style.aiPanelOpen]: showAiPanel,
},
]"
:style="{ '--agent-ai-panel-width': `${aiPanelWidth}px` }"
>
<div
v-if="!isPreviewActive && !isArtifactMode && instanceAiAvailable"
:class="$style.aiButtonWrapper"
>
<N8nButton
variant="subtle"
icon-only
size="large"
:disabled="!agent"
:aria-label="locale.baseText('aiAssistant.tooltip')"
:class="$style.aiButtonIcon"
data-testid="agent-builder-instance-ai-btn"
@click="onOpenInstanceAi"
<aside v-if="showAiPanel" :class="$style.aiDock" data-testid="agent-ai-dock">
<N8nResizeWrapper
:width="aiPanelWidth"
:min-width="320"
:max-width="720"
:supported-directions="['right']"
@resize="onAiPanelResize"
>
<template #default>
<div>
<N8nAssistantIcon size="large" />
</div>
</template>
</N8nButton>
</div>
<InstanceAiChatPanel
ref="aiPanelRef"
:key="agentId"
:subject="instanceAiEmbedSubject"
:launch="instanceAiEmbedLaunch"
:thread-id="aiThreadId"
:before-new-thread="flushAutosave"
:before-send="flushAutosave"
data-testid="agent-ai-chat-panel"
@update:thread-id="onAiThreadIdChange"
@update:building="embeddedAiBuilding = $event"
@close="isAiPanelOpen = false"
/>
</N8nResizeWrapper>
</aside>
<AgentBuildingIndicator v-if="embeddedAiBuilding" />
<div v-if="showBuilderLoading" :class="$style.loading">
<N8nIcon icon="spinner" spin />
</div>
@ -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;
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -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<HTMLInputElement | null>(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<ActionDropdownItem<'rename' | 'delete'>> = [
@ -79,6 +97,12 @@ const dateGroupI18nMap: Record<string, string> = {
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<string, typeof history.value.threads>();
@ -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') }}
</N8nText>
<N8nButton
v-if="navigate"
variant="ghost"
size="xsmall"
:class="$style.viewAll"
@ -246,7 +283,7 @@ function handleThreadAction(action: string, threadId: string) {
<div
v-for="thread in group.threads"
:key="thread.id"
:class="$style.threadItem"
:class="[$style.threadItem, { [$style.disabled]: disabled }]"
data-test-id="instance-ai-thread-item"
>
<!-- Inline rename mode -->
@ -267,6 +304,7 @@ function handleThreadAction(action: string, threadId: string) {
<template v-else>
<ComboboxItem as-child :value="thread.id" :text-value="thread.title">
<RouterLink
v-if="navigate"
:to="{ name: INSTANCE_AI_THREAD_VIEW, params: { threadId: thread.id } }"
:class="$style.threadLink"
:title="thread.title"
@ -277,11 +315,24 @@ function handleThreadAction(action: string, threadId: string) {
>
<span :class="$style.threadTitle">{{ thread.title }}</span>
</RouterLink>
<!-- No thread route to land on (an embedding host) — emit `select` instead. -->
<button
v-else
type="button"
:class="[$style.threadLink, $style.threadLinkButton]"
:title="thread.title"
:disabled="disabled"
@click="handleThreadSelect(thread.id)"
@dblclick.prevent="startRename(thread.id, thread.title)"
>
<span :class="$style.threadTitle">{{ thread.title }}</span>
</button>
</ComboboxItem>
<N8nActionDropdown
:items="threadActions"
:class="$style.actionDropdown"
placement="bottom-start"
:disabled="disabled"
@select="handleThreadAction($event, thread.id)"
@click.stop
>
@ -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')"
/>
</template>
@ -311,7 +363,7 @@ function handleThreadAction(action: string, threadId: string) {
</N8nButton>
</div>
<div v-else-if="history.hasMore" ref="sentinelRef" :class="$style.sentinel" />
<div v-else-if="history.threads.length === 0" :class="$style.empty" role="status">
<div v-else-if="filteredThreads.length === 0" :class="$style.empty" role="status">
<N8nText size="small" color="text-light">
{{
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;

View File

@ -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);
}
</script>
<template>
@ -72,8 +96,13 @@ const threadCreditsUsed = computed(() =>
<template #content>
<InstanceAiThreadList
max-height="calc(var(--spacing--5xl) + var(--spacing--4xl) + var(--spacing--3xl))"
:filter="threadList?.filter"
:navigate="threadList?.navigate"
:disabled="threadList?.disabled"
:active-thread-id="threadId"
@close="threadMenuOpen = false"
@select="threadMenuOpen = false"
@select="handleThreadSelect"
@deleted="emit('deleted', $event)"
/>
</template>
</N8nPopover>

View File

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

View File

@ -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<string, unknown>): 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:
'<button data-test-id="thread-actions" @click="$emit(\'select\', \'delete\')"><slot name="activator" /></button>',
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();
});
});

View File

@ -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<string, unknown>,
): Promise<string> {
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 {

View File

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

View File

@ -0,0 +1,482 @@
<script lang="ts" setup>
/**
* Generic embeddable n8n Assistant panel: a chat scoped to one subject (an
* agent today, a workflow later — see `InstanceAiEmbedSubject`), with its own
* filtered thread history. It never renders artifact/preview panels — the
* host's own surface (the agent builder, a future node editor) is the
* artifact; "open in n8n Assistant" is the escape hatch to the full UI.
*
* Router-free except for that one hand-off. Provides the thread runtime
* itself, so `InstanceAiConversation` works unchanged. Its history lives in
* `InstanceAiViewHeader`'s popover — scoped and non-navigating via `threadList` —
* rather than a sidebar.
*/
import {
computed,
defineComponent,
h,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import type { InstanceAiHandoffContext, InstanceAiThreadSummary } from '@n8n/api-types';
import { N8nHeading, N8nIconButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY, INSTANCE_AI_THREAD_VIEW } from '../constants';
import { getThreadDisplayTitle } from '../instanceAi.threadRuntime';
import { provideThread, useInstanceAiStore } from '../instanceAi.store';
import { useAgentMutationRefresh } from '../composables/useAgentMutationRefresh';
import { useBuildingArtifactIds } from '../composables/useBuildingArtifactIds';
import {
provisionSubjectThread,
stashPendingComposerDraft,
stashPendingHandoffContext,
type InstanceAiThreadLaunch,
type PendingComposerDraft,
} from '../composables/useInstanceAiHandoff';
import InstanceAiViewHeader from '../components/InstanceAiViewHeader.vue';
import InstanceAiConversation from '../components/InstanceAiConversation.vue';
import { useInstanceAiEmbedThreads } from './useInstanceAiEmbedThreads';
import { threadTargetsSubject, type InstanceAiEmbedSubject } from './instanceAiEmbed.types';
const props = defineProps<{
subject: InstanceAiEmbedSubject;
launch: InstanceAiThreadLaunch;
threadId?: string;
/** Runs before a new thread is minted (e.g. flush a pending autosave). */
beforeNewThread?: () => Promise<void>;
/** Runs before every message send (e.g. flush a pending autosave). */
beforeSend?: () => Promise<void>;
}>();
const emit = defineEmits<{
'update:threadId': [threadId: string];
'update:building': [building: boolean];
close: [];
}>();
const i18n = useI18n();
const toast = useToast();
const router = useRouter();
const store = useInstanceAiStore();
const subject = computed(() => props.subject);
const { threads } = useInstanceAiEmbedThreads(subject);
// Scopes the header's popover history to this panel's subject — a stable
// function reference so the list's `filter` prop doesn't re-run on every render.
function threadFilter(thread: InstanceAiThreadSummary): boolean {
return threadTargetsSubject(thread.metadata, props.subject);
}
/** The template ref onto the mounted `InstanceAiConversation` — wired through
* `ThreadScope`'s render (it's created with `h()`, so the ref is passed as a
* vnode prop rather than a template attribute), null while no thread is mounted. */
const conversationRef = ref<InstanceType<typeof InstanceAiConversation> | null>(null);
/**
* A hand-off requested before the conversation for the target thread exists
* yet (still minting, or resuming). Flushed into the thread's stash the
* moment its id is known, right before `update:threadId` is emitted — the
* conversation picks the stash up itself on mount (`syncThread`).
*/
const queuedHandoff = ref<{
context: InstanceAiHandoffContext;
draft?: PendingComposerDraft;
} | null>(null);
function stashHandoff(
threadId: string,
queued: { context: InstanceAiHandoffContext; draft?: PendingComposerDraft },
) {
stashPendingHandoffContext(threadId, queued.context);
if (queued.draft) stashPendingComposerDraft(threadId, queued.draft);
if (queued.context.source === 'agent-preview') {
const subjectIdAtQueue = props.subject.id;
void store
.updateThreadMetadata(threadId, {
[INSTANCE_AI_AGENT_PREVIEW_VIEW_METADATA_KEY]: {
agentId: queued.context.agentId,
threadId: queued.context.threadId,
},
})
.catch((error: unknown) => {
// The panel may have unmounted or moved on to another subject while
// the write was in flight — nothing left here for the toast to warn.
if (!mounted || props.subject.id !== subjectIdAtQueue) return;
toast.showError(error, i18n.baseText('generic.error'));
});
}
}
function flushQueuedHandoff(threadId: string) {
const queued = queuedHandoff.value;
if (!queued) return;
queuedHandoff.value = null;
stashHandoff(threadId, queued);
}
/** Every path that discovers a thread id routes through here so a queued
* hand-off always lands before the host (and the conversation) see the id. */
function emitThreadId(threadId: string) {
flushQueuedHandoff(threadId);
emit('update:threadId', threadId);
}
/**
* Puts a preview-chat hand-off into this panel: applies it directly when the
* conversation is already mounted and clean, otherwise queues it for whichever
* thread the panel is about to resume or mint. Mirrors
* `InstanceAiThreadView`'s `handleAgentPreviewAssistantHandoff`.
*/
function handoff(context: InstanceAiHandoffContext, initialDraft?: PendingComposerDraft): boolean {
const conversation = conversationRef.value;
if (conversation) {
if (conversation.isDirty()) {
toast.showMessage({
title: i18n.baseText('instanceAi.input.finishDraftBeforeHandoff.title'),
message: i18n.baseText('instanceAi.input.finishDraftBeforeHandoff.message'),
type: 'warning',
});
return false;
}
conversation.applyHandoff(context, initialDraft);
return true;
}
const queued = { context, draft: initialDraft };
if (activeThreadId.value) {
stashHandoff(activeThreadId.value, queued);
} else {
queuedHandoff.value = queued;
}
return true;
}
defineExpose({ handoff });
/** The assistant is actively mutating the subject — the thread list stops
* accepting select/new while that's true, so a click can't race it. */
const building = ref(false);
function onBuildingChange(value: boolean) {
building.value = value;
emit('update:building', value);
}
// A mint or resume in flight can outlive this component, or the subject it
// was minting for — both must be checked before the result is adopted.
let mounted = true;
onBeforeUnmount(() => {
mounted = false;
});
/**
* `openFullAssistant` hands this thread's runtime off to the full assistant
* view, which adopts it via its own `provideThread` — disposing it here (on
* unmount or on a thread change) would tear down a runtime the thread view is
* about to reuse.
*/
let handedOffThreadId: string | undefined;
let minting = false;
async function mintThread() {
if (minting) return;
minting = true;
const mintedForSubjectId = props.subject.id;
const threadIdAtStart = props.threadId;
try {
try {
await props.beforeNewThread?.();
} catch {
// The host's own flow (e.g. a failed autosave) already surfaced its
// error — the hook owns its error UI, so just stop here.
return;
}
let threadId: string;
try {
threadId = await provisionSubjectThread(props.subject, props.launch);
} catch (error) {
// Leave the panel without a thread — the "+" in the list is the retry.
toast.showError(error, i18n.baseText('instanceAi.handoff.openFailed.title'));
return;
}
if (!mounted || props.subject.id !== mintedForSubjectId || props.threadId !== threadIdAtStart) {
// Torn down, switched subjects, or the user picked a different thread
// (row click / another mint) while this one was in flight — drop the
// orphaned thread rather than adopt it under a stale selection.
void store.deleteThread(threadId, { silent: true });
return;
}
emitThreadId(threadId);
} finally {
minting = false;
}
}
/** Resume the subject's most recent thread, or mint a fresh one. */
async function resumeOrMintThread() {
if (props.subject.type === 'agent' && props.subject.pending) {
// A freshly minted agent id can't already have threads — mint straight
// away instead of waiting on the thread list round trip.
void store.loadThreads();
await mintThread();
return;
}
const loaded = await store.loadThreads();
if (!mounted) return;
if (!loaded) {
// Leave the panel without a thread rather than mint a possibly-duplicate
// one against a list we couldn't confirm — the "+" in the list is the retry.
toast.showError(
new Error(i18n.baseText('instanceAi.handoff.openFailed.message')),
i18n.baseText('instanceAi.handoff.openFailed.title'),
);
return;
}
const existing = threads.value[0];
if (existing) {
emitThreadId(existing.id);
} else {
await mintThread();
}
}
/**
* True once `threadId` (the current `props.threadId`) is confirmed to target
* this subject, so `ThreadScope` never mounts a conversation for a thread the
* host handed in that turns out to belong to someone else — see
* `verifyThreadId`.
*/
const threadIdVerified = ref(!props.threadId);
const activeThreadId = computed(() => (threadIdVerified.value ? props.threadId : undefined));
/**
* Confirm an incoming `threadId` actually targets this subject before
* `ThreadScope` is allowed to mount it: the host may hand in a stale
* `?assistantThread=` (or similar) that now belongs to a different agent, or
* to nothing at all. A thread already known locally is checked as-is;
* otherwise it's fetched once. Anything that doesn't match is treated as
* absent, same as no `threadId` at all.
*/
async function verifyThreadId(threadId: string): Promise<void> {
threadIdVerified.value = false;
let metadata = store.getThreadMetadata(threadId);
if (metadata === undefined && !store.threads.some((t) => t.id === threadId)) {
try {
await store.loadThread(threadId);
} catch {
// Not found (or forbidden) — falls through to the mismatch branch below.
}
metadata = store.getThreadMetadata(threadId);
}
if (!mounted || props.threadId !== threadId) return; // superseded meanwhile
if (threadTargetsSubject(metadata, props.subject)) {
threadIdVerified.value = true;
return;
}
await resumeOrMintThread();
}
onMounted(() => {
if (props.threadId) {
void store.loadThreads();
void verifyThreadId(props.threadId);
} else {
void resumeOrMintThread();
}
});
watch(
() => props.threadId,
(next, previous) => {
if (previous && previous !== next && previous !== handedOffThreadId) {
store.disposeRuntime(previous);
}
// The host cleared the thread id (e.g. deleted elsewhere) — resume or mint,
// same as the initial mount with no thread id.
if (!next) {
void resumeOrMintThread();
return;
}
void verifyThreadId(next);
},
);
onUnmounted(() => {
// A host closing the panel mid-build must not stay locked forever.
emit('update:building', false);
if (props.threadId && props.threadId !== handedOffThreadId) store.disposeRuntime(props.threadId);
});
function onThreadSelect(threadId: string) {
if (building.value) return;
emit('update:threadId', threadId);
}
function onNewThreadRequested() {
if (building.value) return;
void mintThread();
}
function onThreadDeleted(wasActive: boolean) {
if (!wasActive) return;
const next = threads.value[0];
if (next) {
emit('update:threadId', next.id);
} else {
void mintThread();
}
}
async function openFullAssistant() {
const threadId = activeThreadId.value;
if (!threadId) return;
const subjectIdAtStart = props.subject.id;
// The full view reads the same agent config, so flush pending edits first.
try {
await props.beforeSend?.();
} catch {
// The host already surfaced its own error — open the full view regardless.
}
if (!mounted || props.subject.id !== subjectIdAtStart || activeThreadId.value !== threadId) {
// Torn down, switched subjects, or the selected thread moved on while
// beforeSend was in flight — nothing left here to hand off.
return;
}
handedOffThreadId = threadId;
void router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } });
}
const currentThreadTitle = computed<string | undefined>(() => {
const threadId = activeThreadId.value;
if (!threadId) return undefined;
const summary = store.threads.find((thread) => thread.id === threadId);
const runtime = store.getRuntime(threadId);
return getThreadDisplayTitle(summary, runtime?.messages ?? []);
});
/**
* Owns the provide-boundary for the thread runtime: `provide()` only takes
* effect for descendants created after it runs, so re-providing on a thread
* switch needs a component that itself remounts (keyed by thread id) rather
* than a plain watcher. Also the one place that can pass the runtime to
* `useAgentMutationRefresh` / `useBuildingArtifactIds`, which need it handed
* in by whichever scope provides it.
*/
const ThreadScope = defineComponent({
name: 'InstanceAiChatPanelThreadScope',
props: { threadId: { type: String, required: true } },
emits: ['thread-missing', 'update:building'],
setup(scopeProps, { emit: scopeEmit }) {
const runtime = provideThread(scopeProps.threadId);
useAgentMutationRefresh(runtime);
const buildingArtifactIds = useBuildingArtifactIds(runtime);
watch(
() => buildingArtifactIds.value.has(props.subject.id),
(value) => scopeEmit('update:building', value),
{ immediate: true },
);
return () =>
h(InstanceAiConversation, {
// Closes over the outer scope's ref directly — `ThreadScope` is
// defined inside the panel's own `<script setup>`, and this is the
// only place that can reach the mounted conversation for `handoff()`.
ref: conversationRef,
beforeSend: props.beforeSend,
onThreadMissing: () => scopeEmit('thread-missing'),
});
},
});
</script>
<template>
<div :class="$style.panel" data-test-id="instance-ai-embed-panel">
<InstanceAiViewHeader
:thread-id="activeThreadId"
:thread-list="{ filter: threadFilter, navigate: false, disabled: building }"
@select="onThreadSelect"
@deleted="onThreadDeleted"
>
<template #title>
<N8nHeading v-if="currentThreadTitle" tag="h2" size="small" :class="$style.title">
{{ currentThreadTitle }}
</N8nHeading>
</template>
<template #actions>
<N8nIconButton
icon="plus"
variant="ghost"
size="small"
icon-size="large"
:disabled="building"
:aria-label="i18n.baseText('instanceAi.thread.new')"
data-test-id="instance-ai-embed-new-thread"
@click="onNewThreadRequested"
/>
<N8nIconButton
icon="external-link"
variant="ghost"
size="small"
icon-size="large"
:aria-label="i18n.baseText('instanceAi.embed.openFullAssistant')"
data-test-id="instance-ai-embed-open-full"
@click="openFullAssistant"
/>
<N8nIconButton
icon="x"
variant="ghost"
size="small"
icon-size="large"
:aria-label="i18n.baseText('instanceAi.embed.close')"
data-test-id="instance-ai-embed-close"
@click="emit('close')"
/>
</template>
</InstanceAiViewHeader>
<div :class="$style.body">
<ThreadScope
v-if="activeThreadId"
:key="activeThreadId"
:thread-id="activeThreadId"
:class="$style.conversation"
@thread-missing="mintThread"
@update:building="onBuildingChange"
/>
</div>
</div>
</template>
<style lang="scss" module>
.panel {
// The host dock paints this surface; the conversation's input-dock fade
// must end in the same colour or it shows as a band.
--instance-ai-conversation-background: var(--background--surface);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
.conversation {
flex: 1;
min-width: 0;
}
</style>

View File

@ -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<typeof import('vue-router')>()),
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<string>());
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: `<div data-test-id="header-stub" :data-disabled="String(Boolean(threadList?.disabled))">
<span data-test-id="list-count">{{ count }}</span>
<slot name="title" />
<slot name="actions" />
<button data-test-id="list-select" type="button" @click="$emit('select', 't-other')" />
</div>`,
});
// `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: `<div data-test-id="conversation-stub" :data-has-before-send="String(typeof beforeSend === 'function')">
<button data-test-id="conversation-thread-missing" type="button" @click="$emit('thread-missing')" />
</div>`,
});
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<typeof mockedStore<typeof useInstanceAiStore>>;
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<boolean>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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();
});
});
});

View File

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

View File

@ -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<typeof mockedStore<typeof useInstanceAiStore>>;
beforeEach(() => {
setActivePinia(createTestingPinia());
store = mockedStore(useInstanceAiStore);
});
it('filters store.threads by the subject', () => {
const subject = ref<InstanceAiEmbedSubject>({ 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']);
});
});

View File

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

View File

@ -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<InstanceAiEmbedSubject> | ComputedRef<InstanceAiEmbedSubject>,
) {
const store = useInstanceAiStore();
const threads = computed(() =>
store.threads.filter((thread) => threadTargetsSubject(thread.metadata, subject.value)),
);
return { threads };
}

View File

@ -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<string, unknown> | 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<string, unknown> | undefined,
) {