mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
feat(editor): Add the n8n Assistant panel to the agent builder (#38726)
This commit is contained in:
parent
7867844fbb
commit
9f0e653c5a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user