From 0f11660414aa34e0792c102d174f59d0dda126cd Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Wed, 30 Sep 2026 13:10:54 +0000 Subject: [PATCH] feat(core): Vanish Fix with Assistant banner when AIA fixes error (#39977) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../agents/__tests__/AgentBuilderView.test.ts | 50 ++++++++ .../__tests__/AgentChatToolSteps.test.ts | 68 ++++++++++- .../components/AgentChatMessageList.vue | 3 + .../agents/components/AgentChatPanel.vue | 3 + .../agents/components/AgentChatToolSteps.vue | 63 ++++++---- .../components/AgentPreviewChatPage.vue | 4 +- .../agents/components/AgentPreviewDock.vue | 9 +- ...seFixWithAssistantCalloutDismissal.test.ts | 109 ++++++++++++++++++ .../useFixWithAssistantCalloutDismissal.ts | 108 +++++++++++++++++ .../agents/views/AgentBuilderView.vue | 37 +++++- .../ai/instanceAi/InstanceAiThreadView.vue | 4 +- .../useInstanceAiAgentPreviewHandoff.ts | 2 + 12 files changed, 429 insertions(+), 31 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/__tests__/useFixWithAssistantCalloutDismissal.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useFixWithAssistantCalloutDismissal.ts diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index e2e3166aa1b..49c149cf7af 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -517,6 +517,12 @@ afterEach(async () => { for (const wrapper of mountedWrappers.splice(0)) { wrapper.unmount(); } + const fixCalloutKeys: string[] = []; + for (let index = 0; index < sessionStorage.length; index++) { + const key = sessionStorage.key(index); + if (key?.startsWith('N8N_AGENT_PREVIEW_FIX_CALLOUT:')) fixCalloutKeys.push(key); + } + for (const key of fixCalloutKeys) sessionStorage.removeItem(key); await flushPromises(); }); @@ -620,6 +626,7 @@ const commonStubs = { 'effectiveSessionId', 'initialPrompt', 'canSendToAssistant', + 'dismissedFixToolCallIds', 'beforeSend', 'canDeleteSession', 'isDeletingSession', @@ -1458,6 +1465,49 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => { ); }); + it('dismisses handed-off tool calls after the assistant writes the agent', async () => { + 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:00.000Z' }); + + const wrapper = await renderView(); + const preview = wrapper.findComponent({ name: 'AgentPreviewDock' }); + expect(preview.props('dismissedFixToolCallIds')).toEqual([]); + + preview.vm.$emit('send-to-assistant', fixEvent); + await flushPromises(); + expect( + wrapper.findComponent({ name: 'AgentPreviewDock' }).props('dismissedFixToolCallIds'), + ).toEqual([]); + + agentsEventBus.emit('agentUpdated', { agentId: 'a1', source: 'instance-ai' }); + await nextTick(); + + expect( + wrapper.findComponent({ name: 'AgentPreviewDock' }).props('dismissedFixToolCallIds'), + ).toEqual(['call-1', 'call-2']); + }); + + it('keeps handed-off tool calls when the assistant refuses the hand-off', async () => { + handoffMock.mockReturnValueOnce(false); + localStorage.setItem('N8N_AGENT_PREVIEW_OPEN:p1:a1', 'true'); + routeQuery.continueSessionId = 'thread-1'; + fetchedSessionThreads.push({ id: 'thread-1', updatedAt: '2026-01-01T00:00:00.000Z' }); + + const wrapper = await renderView(); + wrapper.findComponent({ name: 'AgentPreviewDock' }).vm.$emit('send-to-assistant', fixEvent); + await flushPromises(); + expect(handoffMock).toHaveBeenCalled(); + + agentsEventBus.emit('agentUpdated', { agentId: 'a1', source: 'instance-ai' }); + await nextTick(); + + expect( + wrapper.findComponent({ name: 'AgentPreviewDock' }).props('dismissedFixToolCallIds'), + ).toEqual([]); + }); + it('keeps an artifact on the selected preview session and stages the handoff in its Assistant thread', async () => { fetchedSessionThreads.push( { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts index e7d954eeb5a..ea6e8b58468 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatToolSteps.test.ts @@ -100,7 +100,11 @@ vi.mock('../composables/useSubAgentNames', () => ({ function mountSteps( toolCalls: ToolCall[], - extra: { canFixWithAssistant?: boolean; executionId?: string } = {}, + extra: { + canFixWithAssistant?: boolean; + executionId?: string; + dismissedToolCallIds?: string[]; + } = {}, ) { return mount(AgentChatToolSteps, { props: { toolCalls, projectId: 'project-1', ...extra }, @@ -304,6 +308,68 @@ describe('AgentChatToolSteps', () => { ]); }); + it('hides dismissed tool errors and emits only the ones still shown', async () => { + const stillShown = mountSteps( + [ + { + tool: 'search_nodes', + toolCallId: 'tc-gone', + state: TOOL_CALL_STATE.ERROR, + output: 'Old failure', + }, + { + tool: 'http_request', + toolCallId: 'tc-stay', + state: TOOL_CALL_STATE.ERROR, + output: 'Still broken', + }, + ], + { + canFixWithAssistant: true, + executionId: 'exec-1', + dismissedToolCallIds: ['tc-gone'], + }, + ); + + const callout = stillShown.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]'); + expect(callout.exists()).toBe(true); + expect(callout.text()).toContain('Still broken'); + expect(callout.text()).not.toContain('Old failure'); + + await stillShown.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').trigger('click'); + expect(stillShown.emitted('fixWithAssistant')).toEqual([ + [ + [ + { + toolCallId: 'tc-stay', + toolName: 'http_request', + toolDisplayName: 'Http request', + error: 'Still broken', + }, + ], + ], + ]); + + const allDismissed = mountSteps( + [ + { + tool: 'search_nodes', + toolCallId: 'tc-gone', + state: TOOL_CALL_STATE.ERROR, + output: 'Old failure', + }, + ], + { + canFixWithAssistant: true, + executionId: 'exec-1', + dismissedToolCallIds: ['tc-gone'], + }, + ); + expect( + allDismissed.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists(), + ).toBe(false); + }); + it('shows a generic error when the failed tool output is empty', () => { const wrapper = mountSteps([ { diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index fca4f35f657..1ae49e5ce45 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -43,6 +43,7 @@ const props = defineProps<{ agentId?: string; sessionId?: string; canSendToAssistant?: boolean; + dismissedFixToolCallIds?: string[]; }>(); const emit = defineEmits<{ @@ -461,6 +462,7 @@ watch( :tool-calls="group.toolCalls" :project-id="projectId" :can-fix-with-assistant="canSendToAssistant" + :dismissed-tool-call-ids="dismissedFixToolCallIds" :execution-id="group.executionId" @fix-with-assistant="onFixWithAssistant(group, $event)" /> @@ -551,6 +553,7 @@ watch( :tool-calls="group.message.toolCalls" :project-id="projectId" :can-fix-with-assistant="canSendToAssistant" + :dismissed-tool-call-ids="dismissedFixToolCallIds" :execution-id="group.message.executionId" @fix-with-assistant="onFixWithAssistant(group, $event)" /> diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue index 71cbe2bb79c..52ba97bd415 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue @@ -66,6 +66,7 @@ const props = withDefaults( connectedTriggers: string[]; canEditAgent?: boolean; canSendToAssistant?: boolean; + dismissedFixToolCallIds?: string[]; beforeSend?: () => Promise | void; inputDraft?: string; backgroundJobsActive?: boolean; @@ -77,6 +78,7 @@ const props = withDefaults( newSession: false, canEditAgent: true, canSendToAssistant: false, + dismissedFixToolCallIds: () => [], beforeSend: undefined, inputDraft: undefined, backgroundJobsActive: false, @@ -761,6 +763,7 @@ onBeforeUnmount(() => { :agent-id="agentId" :session-id="continueSessionId" :can-send-to-assistant="canSendToAssistant" + :dismissed-fix-tool-call-ids="dismissedFixToolCallIds" @resume="resume" @send-to-assistant="emit('send-to-assistant', $event)" /> diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue index 30f3eba0003..c761831d820 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue @@ -33,6 +33,7 @@ const props = defineProps<{ toolCalls: ToolCall[]; projectId?: string; canFixWithAssistant?: boolean; + dismissedToolCallIds?: string[]; executionId?: string; }>(); @@ -44,11 +45,15 @@ const i18n = useI18n(); const showFix = computed(() => Boolean(props.canFixWithAssistant && props.executionId)); +const dismissedToolCallIds = computed(() => new Set(props.dismissedToolCallIds ?? [])); + const fixableFailures = computed(() => { if (!showFix.value) return []; + const dismissed = dismissedToolCallIds.value; const failures: AgentFixWithAssistantFailure[] = []; for (const toolCall of props.toolCalls) { + if (dismissed.has(toolCall.toolCallId)) continue; if (toolCall.state !== TOOL_CALL_STATE.ERROR) continue; const error = toolStepError(toolCall)?.trim(); @@ -334,29 +339,31 @@ function hasActiveToolCall(): boolean { - - -
    -
  • {{ error }}
  • -
- -
+ + + +
    +
  • {{ error }}
  • +
+ +
+
@@ -432,3 +439,13 @@ function hasActiveToolCall(): boolean { user-select: text; } + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue index 77ca671774e..6e5d99fd108 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewChatPage.vue @@ -23,10 +23,11 @@ const props = withDefaults( newSession?: boolean; initialPrompt?: string; canSendToAssistant?: boolean; + dismissedFixToolCallIds?: string[]; beforeSend?: () => Promise | void; layout?: 'page' | 'dock'; }>(), - { visible: true, newSession: false, layout: 'dock' }, + { visible: true, newSession: false, layout: 'dock', dismissedFixToolCallIds: () => [] }, ); const emit = defineEmits<{ @@ -83,6 +84,7 @@ defineExpose({ focusInput, getConversationMarkdown }); :agent-status="deriveAgentStatus(agent)" :connected-triggers="connectedTriggers" :can-send-to-assistant="canSendToAssistant" + :dismissed-fix-tool-call-ids="dismissedFixToolCallIds" :before-send="beforeSend" @continue-loaded="emit('continue-loaded', $event)" @session-created="emit('session-created', $event)" diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue index 0b89b08ccf9..4de27ea5c76 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewDock.vue @@ -55,11 +55,17 @@ const props = withDefaults( newSession?: boolean; initialPrompt?: string; canSendToAssistant?: boolean; + dismissedFixToolCallIds?: string[]; canDeleteSession?: boolean; beforeSend?: () => Promise | void; isDeletingSession?: boolean; }>(), - { newSession: false, canDeleteSession: false, isDeletingSession: false }, + { + newSession: false, + canDeleteSession: false, + isDeletingSession: false, + dismissedFixToolCallIds: () => [], + }, ); const emit = defineEmits<{ @@ -294,6 +300,7 @@ useKeybindings({ :new-session="props.newSession" :initial-prompt="props.initialPrompt" :can-send-to-assistant="props.canSendToAssistant" + :dismissed-fix-tool-call-ids="props.dismissedFixToolCallIds" :before-send="props.beforeSend" @continue-loaded="emit('continue-loaded', $event)" @session-created="emit('session-created', $event)" diff --git a/packages/frontend/editor-ui/src/features/agents/composables/__tests__/useFixWithAssistantCalloutDismissal.test.ts b/packages/frontend/editor-ui/src/features/agents/composables/__tests__/useFixWithAssistantCalloutDismissal.test.ts new file mode 100644 index 00000000000..bfa74a1261c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/__tests__/useFixWithAssistantCalloutDismissal.test.ts @@ -0,0 +1,109 @@ +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { agentsEventBus } from '../../agents.eventBus'; +import { useFixWithAssistantCalloutDismissal } from '../useFixWithAssistantCalloutDismissal'; + +const STORAGE_PREFIX = 'N8N_AGENT_PREVIEW_FIX_CALLOUT:'; + +function clearFixCalloutStorage() { + const keys: string[] = []; + for (let index = 0; index < sessionStorage.length; index++) { + const key = sessionStorage.key(index); + if (key?.startsWith(STORAGE_PREFIX)) keys.push(key); + } + for (const key of keys) sessionStorage.removeItem(key); +} + +function readStored(sessionId: string) { + const raw = sessionStorage.getItem(`${STORAGE_PREFIX}${sessionId}`); + return raw + ? (JSON.parse(raw) as { agentId: string; pending: string[]; dismissed: string[] }) + : null; +} + +function mountDismissal(initialSessionId?: string) { + const sessionId = ref(initialSessionId); + let api!: ReturnType; + const wrapper = mount( + defineComponent({ + setup() { + api = useFixWithAssistantCalloutDismissal(sessionId); + return () => null; + }, + }), + ); + return { wrapper, api, sessionId }; +} + +describe('useFixWithAssistantCalloutDismissal', () => { + beforeEach(() => { + clearFixCalloutStorage(); + }); + + afterEach(() => { + clearFixCalloutStorage(); + }); + + it('keeps accepted ids pending until the assistant writes that agent', () => { + const { wrapper, api } = mountDismissal('session-1'); + api.trackAcceptedFixHandoff('session-1', 'agent-1', ['tc-1']); + api.trackAcceptedFixHandoff('session-1', 'agent-1', ['tc-1', 'tc-2']); + + expect(readStored('session-1')).toEqual({ + agentId: 'agent-1', + pending: ['tc-1', 'tc-2'], + dismissed: [], + }); + expect(api.dismissedToolCallIds.value).toEqual([]); + + agentsEventBus.emit('agentUpdated', { agentId: 'agent-1', source: 'agent-builder' }); + agentsEventBus.emit('agentUpdated', { agentId: 'agent-other', source: 'instance-ai' }); + expect(readStored('session-1')?.pending).toEqual(['tc-1', 'tc-2']); + + agentsEventBus.emit('agentUpdated', { agentId: 'agent-1', source: 'instance-ai' }); + + expect(readStored('session-1')).toEqual({ + agentId: 'agent-1', + pending: [], + dismissed: ['tc-1', 'tc-2'], + }); + expect(api.dismissedToolCallIds.value).toEqual(['tc-1', 'tc-2']); + wrapper.unmount(); + + const remounted = mountDismissal('session-1'); + expect(remounted.api.dismissedToolCallIds.value).toEqual(['tc-1', 'tc-2']); + remounted.wrapper.unmount(); + }); + + it('does not write when the session has no pending ids', () => { + sessionStorage.setItem( + `${STORAGE_PREFIX}session-1`, + JSON.stringify({ agentId: 'agent-1', pending: [], dismissed: ['tc-kept'] }), + ); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const { wrapper } = mountDismissal('session-1'); + + agentsEventBus.emit('agentUpdated', { agentId: 'agent-1', source: 'instance-ai' }); + + expect(setItem).not.toHaveBeenCalled(); + expect(readStored('session-1')?.dismissed).toEqual(['tc-kept']); + wrapper.unmount(); + setItem.mockRestore(); + }); + + it('dismisses every stored session for the written agent', () => { + const { wrapper, api } = mountDismissal('session-1'); + api.trackAcceptedFixHandoff('session-1', 'agent-1', ['tc-a']); + api.trackAcceptedFixHandoff('session-2', 'agent-1', ['tc-b']); + api.trackAcceptedFixHandoff('session-3', 'agent-2', ['tc-c']); + + agentsEventBus.emit('agentUpdated', { agentId: 'agent-1', source: 'instance-ai' }); + + expect(readStored('session-1')?.dismissed).toEqual(['tc-a']); + expect(readStored('session-1')?.pending).toEqual([]); + expect(readStored('session-2')?.dismissed).toEqual(['tc-b']); + expect(readStored('session-3')?.pending).toEqual(['tc-c']); + wrapper.unmount(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useFixWithAssistantCalloutDismissal.ts b/packages/frontend/editor-ui/src/features/agents/composables/useFixWithAssistantCalloutDismissal.ts new file mode 100644 index 00000000000..3f96b2d4ab3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/composables/useFixWithAssistantCalloutDismissal.ts @@ -0,0 +1,108 @@ +import { computed, onBeforeUnmount, ref, type Ref } from 'vue'; +import { isRecord } from '@n8n/utils/is-record'; +import { agentsEventBus, type AgentUpdatedEvent } from '../agents.eventBus'; + +/** + * The preview message list unmounts when the dock closes, then history reload + * brings the same tool errors back. Remember handed-off ids here so the + * callout stays gone after that remount. + */ +const STORAGE_PREFIX = 'N8N_AGENT_PREVIEW_FIX_CALLOUT:'; + +interface FixCalloutRecord { + agentId: string; + pending: string[]; + dismissed: string[]; +} + +function storageKey(sessionId: string): string { + return `${STORAGE_PREFIX}${sessionId}`; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function parseRecord(raw: string | null): FixCalloutRecord | null { + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) return null; + if (typeof parsed.agentId !== 'string') return null; + if (!isStringArray(parsed.pending) || !isStringArray(parsed.dismissed)) return null; + return { + agentId: parsed.agentId, + pending: parsed.pending, + dismissed: parsed.dismissed, + }; + } catch { + return null; + } +} + +function readRecord(sessionId: string): FixCalloutRecord | null { + return parseRecord(sessionStorage.getItem(storageKey(sessionId))); +} + +function listStorageKeys(): string[] { + const keys: string[] = []; + for (let index = 0; index < sessionStorage.length; index++) { + const key = sessionStorage.key(index); + if (key?.startsWith(STORAGE_PREFIX)) keys.push(key); + } + return keys; +} + +function unique(ids: string[]): string[] { + return [...new Set(ids)]; +} + +export function useFixWithAssistantCalloutDismissal(sessionId: Ref) { + // Bumped when storage changes so the computed re-reads the current session. + const storageVersion = ref(0); + + const dismissedToolCallIds = computed(() => { + void storageVersion.value; + const id = sessionId.value; + if (!id) return []; + return readRecord(id)?.dismissed ?? []; + }); + + function trackAcceptedFixHandoff( + targetSessionId: string, + agentId: string, + toolCallIds: string[], + ): void { + if (toolCallIds.length === 0) return; + const existing = readRecord(targetSessionId) ?? { + agentId, + pending: [], + dismissed: [], + }; + existing.agentId = agentId; + existing.pending = unique([...existing.pending, ...toolCallIds]); + sessionStorage.setItem(storageKey(targetSessionId), JSON.stringify(existing)); + } + + function onAgentUpdated(event?: AgentUpdatedEvent): void { + if (event?.source !== 'instance-ai' || !event.agentId) return; + + let changed = false; + for (const key of listStorageKeys()) { + const record = parseRecord(sessionStorage.getItem(key)); + if (!record || record.agentId !== event.agentId || record.pending.length === 0) continue; + record.dismissed = unique([...record.dismissed, ...record.pending]); + record.pending = []; + sessionStorage.setItem(key, JSON.stringify(record)); + changed = true; + } + if (changed) storageVersion.value++; + } + + agentsEventBus.on('agentUpdated', onAgentUpdated); + onBeforeUnmount(() => { + agentsEventBus.off('agentUpdated', onAgentUpdated); + }); + + return { dismissedToolCallIds, trackAcceptedFixHandoff }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index d8666a0e77b..1f38bc61cbb 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -136,6 +136,7 @@ import { useAgentCollaborationStore } from '../stores/agentCollaboration.store'; import { useActivityDetection } from '@/app/composables/useActivityDetection'; import { buildAgentChangeRequestPrompt } from '../utils/agent-change-request'; import { buildAgentFixWithAssistantPrompt } from '../utils/fix-with-assistant'; +import { useFixWithAssistantCalloutDismissal } from '../composables/useFixWithAssistantCalloutDismissal'; import { hasBlockingIssues } from '../utils/validationIssues'; const props = withDefaults( @@ -326,12 +327,14 @@ const aiPanelRef = useTemplateRef>('aiP const queuedAiHandoff = ref<{ context: InstanceAiHandoffContext; initialDraft?: PendingComposerDraft; + onAccepted?: () => void; } | null>(null); watch(aiPanelRef, (panel) => { if (!panel || !queuedAiHandoff.value) return; - const { context, initialDraft } = queuedAiHandoff.value; + const { context, initialDraft, onAccepted } = queuedAiHandoff.value; queuedAiHandoff.value = null; - panel.handoff(context, initialDraft); + const handed = panel.handoff(context, initialDraft); + if (handed) onAccepted?.(); }); const aiPanelWidth = useStorage('N8N_AGENT_AI_PANEL_WIDTH', 400); type SidePanel = 'assistant' | 'preview'; @@ -505,9 +508,25 @@ watch( { immediate: true }, ); +// Assigned once `effectiveSessionId` exists. Clicks happen after setup. +let trackAcceptedFixHandoff: ( + sessionId: string, + agentId: string, + toolCallIds: string[], +) => void = () => {}; + async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) { const threadId = effectiveSessionId.value; if (!threadId || !agentId.value || !projectId.value) return; + const targetAgentId = agentId.value; + const acceptFixHandoff = () => { + if (!event || !('failures' in event) || event.failures.length === 0) return; + trackAcceptedFixHandoff( + threadId, + targetAgentId, + event.failures.map((failure) => failure.toolCallId), + ); + }; const session = currentSession.value; const sessionTitle = session?.title?.trim() || currentSessionTitle.value || undefined; const sessionNumber = session?.sessionNumber; @@ -552,7 +571,7 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) { if (isArtifactMode.value) { // The host closes the dock — only it knows whether the hand-off went // through (it refuses one while its composer holds a draft). - emit('assistant-handoff', params); + emit('assistant-handoff', { ...params, onAccepted: acceptFixHandoff }); return; } @@ -572,6 +591,7 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) { if (aiPanelRef.value) { const handed = aiPanelRef.value.handoff(context, params.initialDraft); if (!handed) return; + acceptFixHandoff(); // 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(); @@ -579,7 +599,11 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) { // 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 }; + queuedAiHandoff.value = { + context, + initialDraft: params.initialDraft, + onAccepted: acceptFixHandoff, + }; closePreviewDock(); } @@ -659,6 +683,9 @@ const { projectId, agentId, }); +const { dismissedToolCallIds: dismissedFixToolCallIds, trackAcceptedFixHandoff: trackFixHandoff } = + useFixWithAssistantCalloutDismissal(effectiveSessionId); +trackAcceptedFixHandoff = trackFixHandoff; const previewSessionsLoading = computed( () => sessionsStore.loading || sessionsStore.previewLoading, ); @@ -2817,6 +2844,7 @@ useKeybindings({ :effective-session-id="effectiveSessionId" :new-session="currentSessionIsEphemeral" :can-send-to-assistant="instanceAiAvailable" + :dismissed-fix-tool-call-ids="dismissedFixToolCallIds" :before-send="beforePreviewSend" @continue-loaded="onContinueLoaded" @session-created="markSessionCreated" @@ -2919,6 +2947,7 @@ useKeybindings({ :can-delete-session="canDeletePreviewSession" :is-deleting-session="isDeletingSession" :can-send-to-assistant="instanceAiAvailable" + :dismissed-fix-tool-call-ids="dismissedFixToolCallIds" :before-send="beforePreviewSend" @view-trace="viewPreviewTrace" @new-session="startNewPreviewSession" diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index d7fc408cab1..7aec7f073a9 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -685,10 +685,12 @@ function handleAgentPreviewAssistantHandoff(params: AgentPreviewHandoffParams) { // preview chat open reads as two places to ask the same thing. isAgentPreviewDockOpen.value = false; - conversationRef.value?.applyHandoff( + if (!conversationRef.value) return; + conversationRef.value.applyHandoff( buildInstanceAiAgentPreviewHandoffContext(params), params.initialDraft, ); + params.onAccepted?.(); } /** diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts index c265c0a6a4b..2c177a4763c 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts @@ -10,4 +10,6 @@ export interface AgentPreviewHandoffParams { sessionTitle?: string; executionId?: string; initialDraft?: PendingComposerDraft; + /** Runs only after the assistant composer accepts the hand-off. */ + onAccepted?: () => void; }