mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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>
This commit is contained in:
parent
bd9e1d8947
commit
0f11660414
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const props = withDefaults(
|
|||
connectedTriggers: string[];
|
||||
canEditAgent?: boolean;
|
||||
canSendToAssistant?: boolean;
|
||||
dismissedFixToolCallIds?: string[];
|
||||
beforeSend?: () => Promise<void> | 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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<AgentFixWithAssistantFailure[]>(() => {
|
||||
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 {
|
|||
</template>
|
||||
</template>
|
||||
|
||||
<N8nCallout
|
||||
v-if="fixableErrorTexts.length > 0"
|
||||
theme="danger"
|
||||
data-test-id="agent-chat-tool-fix-with-assistant-callout"
|
||||
>
|
||||
<template v-if="fixableErrorTexts.length === 1">
|
||||
{{ fixableErrorTexts[0] }}
|
||||
</template>
|
||||
<ul v-else :class="$style.errorList">
|
||||
<li v-for="error in fixableErrorTexts" :key="error">{{ error }}</li>
|
||||
</ul>
|
||||
<template #trailingContent>
|
||||
<N8nButton
|
||||
size="small"
|
||||
variant="subtle"
|
||||
data-test-id="agent-chat-tool-fix-with-assistant"
|
||||
@click="emitFixWithAssistant"
|
||||
>
|
||||
<template #icon><N8nIcon icon="sparkles" size="small" /></template>
|
||||
{{ i18n.baseText('agents.builder.preview.fixWithAssistant') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
<Transition name="fix-callout">
|
||||
<N8nCallout
|
||||
v-if="fixableErrorTexts.length > 0"
|
||||
theme="danger"
|
||||
data-test-id="agent-chat-tool-fix-with-assistant-callout"
|
||||
>
|
||||
<template v-if="fixableErrorTexts.length === 1">
|
||||
{{ fixableErrorTexts[0] }}
|
||||
</template>
|
||||
<ul v-else :class="$style.errorList">
|
||||
<li v-for="error in fixableErrorTexts" :key="error">{{ error }}</li>
|
||||
</ul>
|
||||
<template #trailingContent>
|
||||
<N8nButton
|
||||
size="small"
|
||||
variant="subtle"
|
||||
data-test-id="agent-chat-tool-fix-with-assistant"
|
||||
@click="emitFixWithAssistant"
|
||||
>
|
||||
<template #icon><N8nIcon icon="sparkles" size="small" /></template>
|
||||
{{ i18n.baseText('agents.builder.preview.fixWithAssistant') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -432,3 +439,13 @@ function hasActiveToolCall(): boolean {
|
|||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.fix-callout-leave-active {
|
||||
transition: opacity var(--duration--snappy) var(--easing--ease-in);
|
||||
}
|
||||
|
||||
.fix-callout-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ const props = withDefaults(
|
|||
newSession?: boolean;
|
||||
initialPrompt?: string;
|
||||
canSendToAssistant?: boolean;
|
||||
dismissedFixToolCallIds?: string[];
|
||||
beforeSend?: () => Promise<void> | 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)"
|
||||
|
|
|
|||
|
|
@ -55,11 +55,17 @@ const props = withDefaults(
|
|||
newSession?: boolean;
|
||||
initialPrompt?: string;
|
||||
canSendToAssistant?: boolean;
|
||||
dismissedFixToolCallIds?: string[];
|
||||
canDeleteSession?: boolean;
|
||||
beforeSend?: () => Promise<void> | 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)"
|
||||
|
|
|
|||
|
|
@ -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<typeof useFixWithAssistantCalloutDismissal>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | undefined>) {
|
||||
// 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 };
|
||||
}
|
||||
|
|
@ -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<InstanceType<typeof InstanceAiChatPanel>>('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"
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user