From 0d571c05e4982b5136eeae93c05a1541701b60cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Mon, 11 May 2026 13:45:19 +0200 Subject: [PATCH] refactor(editor): Add Instance AI thread provider (no-changelog) (#30090) --- .../ai/instanceAi/InstanceAiEmptyView.vue | 9 +- .../ai/instanceAi/InstanceAiThreadView.vue | 47 +++--- .../InstanceAiConfirmationPanel.test.ts | 4 +- .../InstanceAiCredentialSetup.test.ts | 4 +- .../__tests__/InstanceAiInput.test.ts | 87 +++++----- .../__tests__/InstanceAiMarkdown.test.ts | 4 +- .../__tests__/InstanceAiMessage.test.ts | 4 +- .../__tests__/InstanceAiWorkflowSetup.test.ts | 4 +- .../__tests__/SubagentStepTimeline.test.ts | 4 +- .../__tests__/createInstanceAiHarness.ts | 4 +- .../createThreadComponentRenderer.ts | 27 ++++ .../__tests__/useCanvasPreview.test.ts | 150 +++++++++--------- .../components/AgentActivityTree.vue | 8 +- .../instanceAi/components/AgentTimeline.vue | 14 +- .../components/DomainAccessApproval.vue | 8 +- .../components/GatewayResourceDecision.vue | 10 +- .../components/InstanceAiArtifactsPanel.vue | 8 +- .../InstanceAiConfirmationPanel.vue | 46 +++--- .../components/InstanceAiCredentialSetup.vue | 16 +- .../components/InstanceAiDebugPanel.vue | 13 +- .../instanceAi/components/InstanceAiInput.vue | 39 +++-- .../components/InstanceAiMarkdown.vue | 8 +- .../components/InstanceAiMessage.vue | 11 +- .../components/InstanceAiStatusBar.vue | 18 +-- .../components/InstanceAiToolCall.vue | 8 +- .../components/InstanceAiWorkflowSetup.vue | 6 +- .../instanceAi/composables/useSetupActions.ts | 22 +-- .../ai/instanceAi/instanceAi.store.ts | 19 ++- .../ai/instanceAi/useCanvasPreview.ts | 50 +++--- 29 files changed, 366 insertions(+), 286 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue index 23361b0e425..02ddb876872 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue @@ -12,7 +12,6 @@ import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS } from './emptyStateSuggestions'; import { useCreditWarningBanner } from './composables/useCreditWarningBanner'; import InstanceAiInput from './components/InstanceAiInput.vue'; import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue'; -import InstanceAiStatusBar from './components/InstanceAiStatusBar.vue'; import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue'; import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue'; @@ -69,7 +68,6 @@ function handleStop() {
-
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 f4c0c7a4bef..345058a7345 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -10,7 +10,7 @@ import { watch, } from 'vue'; import { storeToRefs } from 'pinia'; -import { useRoute, useRouter } from 'vue-router'; +import { useRouter } from 'vue-router'; import { N8nHeading, N8nIconButton, @@ -23,7 +23,7 @@ import { useI18n } from '@n8n/i18n'; import type { InstanceAiAttachment } from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; -import { useInstanceAiStore } from './instanceAi.store'; +import { provideThread, useInstanceAiStore } from './instanceAi.store'; import { useCanvasPreview } from './useCanvasPreview'; import { useEventRelay } from './useEventRelay'; import { useExecutionPushEvents } from './useExecutionPushEvents'; @@ -49,10 +49,10 @@ const props = defineProps<{ }>(); const store = useInstanceAiStore(); +const thread = provideThread(store); const { isLowCredits } = storeToRefs(store); const rootStore = useRootStore(); const i18n = useI18n(); -const route = useRoute(); const router = useRouter(); const { goToUpgrade } = usePageRedirectionHelper(); const creditBanner = useCreditWarningBanner(isLowCredits); @@ -60,12 +60,12 @@ const creditBanner = useCreditWarningBanner(isLowCredits); // Running builders render in a dedicated bottom section of the conversation. // Once a builder finishes it falls out of this list and AgentTimeline renders // it in its natural chronological slot. -const builderAgents = computed(() => collectActiveBuilderAgents(store.messages)); +const builderAgents = computed(() => collectActiveBuilderAgents(thread.messages)); // Assistant messages whose only content has been extracted to the bottom // builder section (or which haven't produced anything renderable yet) would // otherwise leave an empty wrapper in the list — filter them out. -const displayedMessages = computed(() => store.messages.filter(messageHasVisibleContent)); +const displayedMessages = computed(() => thread.messages.filter(messageHasVisibleContent)); // --- Execution tracking via push events --- const executionTracking = useExecutionPushEvents(); @@ -75,11 +75,11 @@ const executionTracking = useExecutionPushEvents(); // figuring out which thread to show. Rendering only on a defined value avoids // the "New conversation" → real title flash when resuming a recent thread. const currentThreadTitle = computed(() => { - const thread = store.threads.find((t) => t.id === store.currentThreadId); - if (thread && thread.title && thread.title !== NEW_CONVERSATION_TITLE) { - return thread.title; + const threadSummary = store.threads.find((t) => t.id === store.currentThreadId); + if (threadSummary && threadSummary.title && threadSummary.title !== NEW_CONVERSATION_TITLE) { + return threadSummary.title; } - const firstUserMsg = store.messages.find((m) => m.role === 'user'); + 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; @@ -89,8 +89,8 @@ const currentThreadTitle = computed(() => { // --- Canvas / data table preview --- const preview = useCanvasPreview({ - store, - route, + thread, + threadId: () => props.threadId, }); provide('openWorkflowPreview', preview.openWorkflowPreview); @@ -233,10 +233,10 @@ watch( ); function reconnectThreadIfHydrationApplied(threadId: string): void { - void store.loadHistoricalMessages(threadId).then((hydrationStatus) => { + void thread.loadHistoricalMessages(threadId).then((hydrationStatus) => { if (hydrationStatus === 'stale') return; - void store.loadThreadStatus(threadId); - store.connectSSE(threadId); + void thread.loadThreadStatus(threadId); + thread.connectSSE(threadId); }); } @@ -249,7 +249,7 @@ function reconnectThreadIfHydrationApplied(threadId: string): void { async function syncRouteToStore() { const requestedThreadId = props.threadId; if (requestedThreadId === store.currentThreadId) { - if (store.sseState === 'disconnected') { + if (thread.sseState === 'disconnected') { reconnectThreadIfHydrationApplied(requestedThreadId); } return; @@ -309,11 +309,11 @@ const eventRelay = useEventRelay({ function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) { // Reset scroll on new user message userScrolledUp.value = false; - void store.sendMessage(message, attachments, rootStore.pushRef); + void thread.sendMessage(message, attachments, rootStore.pushRef); } function handleStop() { - void store.cancelRun(); + void thread.cancelRun(); } @@ -327,7 +327,7 @@ function handleStop() { {{ currentThreadTitle }} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts index eff1fddd08b..9d530b91aed 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import userEvent from '@testing-library/user-event'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import type { InstanceAiConfirmation, InstanceAiToolCallState, @@ -89,7 +89,7 @@ vi.mock('../components/PlanReviewPanel.vue', () => ({ default: { template: '
', props: ['plannedTasks', 'message', 'readOnly'] }, })); -const renderComponent = createComponentRenderer(InstanceAiConfirmationPanel); +const renderComponent = createThreadComponentRenderer(InstanceAiConfirmationPanel); // --------------------------------------------------------------------------- // Helpers diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts index b2f7bf0e1e1..020a18508f0 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import userEvent from '@testing-library/user-event'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import type { InstanceAiCredentialRequest } from '@n8n/api-types'; import InstanceAiCredentialSetup from '../components/InstanceAiCredentialSetup.vue'; import { useInstanceAiStore } from '../instanceAi.store'; @@ -49,7 +49,7 @@ vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({ }, })); -const renderComponent = createComponentRenderer(InstanceAiCredentialSetup); +const renderComponent = createThreadComponentRenderer(InstanceAiCredentialSetup); /** Creates requests with no existing credentials (shows setup button) */ function makeCredentialRequests(count: number): InstanceAiCredentialRequest[] { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts index c1263cca23e..cd0104de2ba 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts @@ -1,41 +1,52 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import userEvent from '@testing-library/user-event'; import { fireEvent, waitFor, within } from '@testing-library/vue'; -import { reactive } from 'vue'; import { createComponentRenderer } from '@/__tests__/render'; import InstanceAiInput from '../components/InstanceAiInput.vue'; import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS as suggestions } from '../emptyStateSuggestions'; -const toggleResearchMode = vi.fn(); const telemetryTrack = vi.fn(); -const storeState = reactive({ - amendContext: null as { agentId: string; role: string } | null, - contextualSuggestion: null as string | null, - currentThreadId: 'thread-1', - researchMode: false, + +type InputTestProps = { + isStreaming: boolean; + isSendingMessage: boolean; + isAwaitingConfirmation: boolean; + currentThreadId: string; + amendContext: { agentId: string; role: string } | null; + contextualSuggestion: string | null; + researchMode: boolean; + suggestions?: typeof suggestions; +}; + +const defaultProps = (): InputTestProps => ({ + isStreaming: false, isSendingMessage: false, - toggleResearchMode, + isAwaitingConfirmation: false, + currentThreadId: 'thread-1', + amendContext: null, + contextualSuggestion: null, + researchMode: false, }); +function inputProps(overrides: Partial = {}): InputTestProps { + return { + ...defaultProps(), + ...overrides, + }; +} + vi.mock('@/app/composables/useTelemetry', () => ({ useTelemetry: vi.fn(() => ({ track: telemetryTrack })), })); -vi.mock('../instanceAi.store', () => ({ - useInstanceAiStore: vi.fn(() => storeState), -})); - -const renderComponent = createComponentRenderer(InstanceAiInput); +const renderComponent = createComponentRenderer(InstanceAiInput, { + props: defaultProps(), +}); describe('InstanceAiInput', () => { beforeEach(() => { vi.clearAllMocks(); telemetryTrack.mockReset(); - storeState.amendContext = null; - storeState.contextualSuggestion = null; - storeState.currentThreadId = 'thread-1'; - storeState.researchMode = false; - storeState.isSendingMessage = false; }); it('uses the shared suggestions fixture with the expected top-level contract', () => { @@ -93,11 +104,11 @@ describe('InstanceAiInput', () => { }); it('fills the textarea from the contextual suggestion when Tab is pressed on an empty input', async () => { - storeState.contextualSuggestion = 'Summarize the last workflow error for me'; const { getByRole } = renderComponent({ props: { isStreaming: false, suggestions, + contextualSuggestion: 'Summarize the last workflow error for me', }, }); @@ -336,7 +347,7 @@ describe('InstanceAiInput', () => { }); it('hides suggestions while a send is pending', async () => { - const { queryByTestId } = renderComponent({ + const { queryByTestId, rerender } = renderComponent({ props: { isStreaming: false, suggestions, @@ -345,7 +356,7 @@ describe('InstanceAiInput', () => { expect(queryByTestId('instance-ai-suggestion-build-workflow')).toBeInTheDocument(); - storeState.isSendingMessage = true; + await rerender(inputProps({ suggestions, isSendingMessage: true })); await waitFor(() => { expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument(); @@ -353,7 +364,7 @@ describe('InstanceAiInput', () => { }); it('toggles research mode from the composer footer button', async () => { - const { getByTestId } = renderComponent({ + const { emitted, getByTestId } = renderComponent({ props: { isStreaming: false, suggestions, @@ -362,7 +373,7 @@ describe('InstanceAiInput', () => { await userEvent.click(getByTestId('instance-ai-research-toggle')); - expect(toggleResearchMode).toHaveBeenCalledTimes(1); + expect(emitted()['toggle-research-mode']).toEqual([[]]); }); it('emits stop when the streaming stop button is clicked', async () => { @@ -379,7 +390,7 @@ describe('InstanceAiInput', () => { }); it('clears the ghost prompt when suggestions become hidden', async () => { - const { getByRole, getByTestId, queryByTestId } = renderComponent({ + const { getByRole, getByTestId, queryByTestId, rerender } = renderComponent({ props: { isStreaming: false, suggestions, @@ -391,7 +402,7 @@ describe('InstanceAiInput', () => { await userEvent.hover(getByTestId('instance-ai-suggestion-build-workflow')); expect(textbox.getAttribute('placeholder')).not.toBe(initialPlaceholder); - storeState.isSendingMessage = true; + await rerender(inputProps({ suggestions, isSendingMessage: true })); await waitFor(() => { expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument(); @@ -400,12 +411,11 @@ describe('InstanceAiInput', () => { }); it('tracks suggestions shown once when suggestions hide and reappear in the same thread', async () => { - storeState.currentThreadId = 'thread-shown'; - - renderComponent({ + const { rerender } = renderComponent({ props: { isStreaming: false, suggestions, + currentThreadId: 'thread-shown', }, }); @@ -417,24 +427,29 @@ describe('InstanceAiInput', () => { }); }); - storeState.isSendingMessage = true; + await rerender( + inputProps({ + suggestions, + currentThreadId: 'thread-shown', + isSendingMessage: true, + }), + ); await waitFor(() => { expect(telemetryTrack).toHaveBeenCalledTimes(1); }); - storeState.isSendingMessage = false; + await rerender(inputProps({ suggestions, currentThreadId: 'thread-shown' })); await waitFor(() => { expect(telemetryTrack).toHaveBeenCalledTimes(1); }); }); it('tracks suggestions shown again when the empty-state thread changes', async () => { - storeState.currentThreadId = 'thread-a'; - - renderComponent({ + const { rerender } = renderComponent({ props: { isStreaming: false, suggestions, + currentThreadId: 'thread-a', }, }); @@ -446,7 +461,7 @@ describe('InstanceAiInput', () => { }); }); - storeState.currentThreadId = 'thread-b'; + await rerender(inputProps({ suggestions, currentThreadId: 'thread-b' })); await waitFor(() => { expect(telemetryTrack).toHaveBeenCalledWith('Instance AI prompt suggestions shown', { @@ -483,11 +498,11 @@ describe('InstanceAiInput', () => { }); it('includes research mode in the quick examples telemetry payload when enabled', async () => { - storeState.researchMode = true; const { getByTestId } = renderComponent({ props: { isStreaming: false, suggestions, + researchMode: true, }, }); @@ -538,11 +553,11 @@ describe('InstanceAiInput', () => { }); it('includes the research-mode flag when tracking top-level suggestion selection telemetry', async () => { - storeState.researchMode = true; const { getByTestId } = renderComponent({ props: { isStreaming: false, suggestions, + researchMode: true, }, }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts index 400ee559172..0be201a3cb8 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import { createTestingPinia } from '@pinia/testing'; import { mockedStore } from '@/__tests__/utils'; import InstanceAiMarkdown from '../components/InstanceAiMarkdown.vue'; @@ -14,7 +14,7 @@ vi.mock('@/features/ai/chatHub/components/ChatMarkdownChunk.vue', () => ({ }, })); -const renderComponent = createComponentRenderer(InstanceAiMarkdown); +const renderComponent = createThreadComponentRenderer(InstanceAiMarkdown); function makeRegistry( entries: Array<{ type: string; id: string; name: string; projectId?: string }>, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts index ab85aaec89f..ee988666296 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMessage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import { createTestingPinia } from '@pinia/testing'; import InstanceAiMessageComponent from '../components/InstanceAiMessage.vue'; import type { InstanceAiMessage, InstanceAiAgentNode } from '@n8n/api-types'; @@ -11,7 +11,7 @@ vi.mock('@/features/ai/chatHub/components/ChatMarkdownChunk.vue', () => ({ }, })); -const renderComponent = createComponentRenderer(InstanceAiMessageComponent, { +const renderComponent = createThreadComponentRenderer(InstanceAiMessageComponent, { global: { stubs: { AgentActivityTree: { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowSetup.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowSetup.test.ts index 8ac38b6785a..cfe5b51e185 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiWorkflowSetup.test.ts @@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/vue'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import type { InstanceAiWorkflowSetupNode } from '@n8n/api-types'; import InstanceAiWorkflowSetup from '../components/InstanceAiWorkflowSetup.vue'; import { useInstanceAiStore } from '../instanceAi.store'; @@ -78,7 +78,7 @@ vi.mock('@/features/workflows/canvas/experimental/composables/useExpressionResol useExpressionResolveCtx: () => ({}), })); -const renderComponent = createComponentRenderer(InstanceAiWorkflowSetup); +const renderComponent = createThreadComponentRenderer(InstanceAiWorkflowSetup); /** Render the component and wait for the async onMounted to complete (isStoreReady = true). */ async function renderAndWait( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SubagentStepTimeline.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SubagentStepTimeline.test.ts index 7a658060ccd..380c7e928cf 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SubagentStepTimeline.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SubagentStepTimeline.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createComponentRenderer } from '@/__tests__/render'; +import { createThreadComponentRenderer } from './createThreadComponentRenderer'; import { createTestingPinia } from '@pinia/testing'; import SubagentStepTimeline from '../components/SubagentStepTimeline.vue'; import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types'; -const renderComponent = createComponentRenderer(SubagentStepTimeline, { +const renderComponent = createThreadComponentRenderer(SubagentStepTimeline, { global: { stubs: { // ToolCallStep is stubbed so we can verify which toolCall was passed diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts index d711c2b670b..4b1231f4fa4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts @@ -191,8 +191,8 @@ export async function createInstanceAiHarness(): Promise { const executionTracking = useExecutionPushEvents(); const preview = useCanvasPreview({ - store: store as unknown as ReturnType, - route: route as Parameters[0]['route'], + thread: store as unknown as ReturnType, + threadId: () => route.params.threadId, }); const relayedEvents: PushMessage[] = []; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts new file mode 100644 index 00000000000..4611268028c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createThreadComponentRenderer.ts @@ -0,0 +1,27 @@ +import { defineComponent, h, type Component } from 'vue'; +import { createComponentRenderer, type RenderOptions } from '@/__tests__/render'; +import { provideThread, useInstanceAiStore } from '../instanceAi.store'; + +type RendererOptions = { merge?: boolean }; + +export function createThreadComponentRenderer( + component: T, + defaultOptions: RenderOptions = {}, +) { + const ThreadProvider = defineComponent({ + name: 'InstanceAiThreadTestProvider', + inheritAttrs: false, + setup(_, { attrs, slots }) { + provideThread(useInstanceAiStore()); + return () => h(component as Component, attrs, slots); + }, + }); + + const renderProvider = createComponentRenderer( + ThreadProvider, + defaultOptions as unknown as RenderOptions, + ); + + return (options: RenderOptions = {}, rendererOptions: RendererOptions = {}) => + renderProvider(options as unknown as RenderOptions, rendererOptions); +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts index 507cd4e8132..cb13f4fdf10 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts @@ -47,10 +47,10 @@ function makeMessage(overrides: Partial = {}): InstanceAiMess } // --------------------------------------------------------------------------- -// Store mock +// Thread mock // --------------------------------------------------------------------------- -function createMockStore() { +function createMockThread() { const messages = ref([]) as Ref; const isStreaming = ref(false); const isHydratingThread = ref(false); @@ -67,31 +67,36 @@ function createMockStore() { }); } -type MockStore = ReturnType; +type MockThread = ReturnType; // --------------------------------------------------------------------------- -// Registry helpers — populate the store's producedArtifacts so computed +// Registry helpers — populate the thread's producedArtifacts so computed // activeWorkflowId / activeDataTableId can derive values from tabs. // --------------------------------------------------------------------------- -function registerWorkflow(store: MockStore, id: string, name = `Workflow ${id}`) { +function registerWorkflow(thread: MockThread, id: string, name = `Workflow ${id}`) { const entry: ResourceEntry = { type: 'workflow', id, name }; - const nextProduced = new Map(store.producedArtifacts); + const nextProduced = new Map(thread.producedArtifacts); nextProduced.set(id, entry); - store.producedArtifacts = nextProduced; - const nextByName = new Map(store.resourceNameIndex); + thread.producedArtifacts = nextProduced; + const nextByName = new Map(thread.resourceNameIndex); nextByName.set(name.toLowerCase(), entry); - store.resourceNameIndex = nextByName; + thread.resourceNameIndex = nextByName; } -function registerDataTable(store: MockStore, id: string, name = `Table ${id}`, projectId?: string) { +function registerDataTable( + thread: MockThread, + id: string, + name = `Table ${id}`, + projectId?: string, +) { const entry: ResourceEntry = { type: 'data-table', id, name, projectId }; - const nextProduced = new Map(store.producedArtifacts); + const nextProduced = new Map(thread.producedArtifacts); nextProduced.set(id, entry); - store.producedArtifacts = nextProduced; - const nextByName = new Map(store.resourceNameIndex); + thread.producedArtifacts = nextProduced; + const nextByName = new Map(thread.resourceNameIndex); nextByName.set(name.toLowerCase(), entry); - store.resourceNameIndex = nextByName; + thread.resourceNameIndex = nextByName; } // --------------------------------------------------------------------------- @@ -116,19 +121,18 @@ function createMockRoute(threadId = 'thread-1') { // Test helper — create composable + flush // --------------------------------------------------------------------------- -function setup(options?: { storeOverrides?: Partial }) { - const store = createMockStore(); - if (options?.storeOverrides) Object.assign(store, options.storeOverrides); +function setup(options?: { threadOverrides?: Partial }) { + const thread = createMockThread(); + if (options?.threadOverrides) Object.assign(thread, options.threadOverrides); const route = createMockRoute(); const result = useCanvasPreview({ // eslint-disable-next-line @typescript-eslint/no-explicit-any - store: store as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - route: route as any, + thread: thread as any, + threadId: () => route.params.threadId, }); - return { ...result, store, route }; + return { ...result, thread, route }; } // --------------------------------------------------------------------------- @@ -143,8 +147,8 @@ describe('useCanvasPreview', () => { describe('allArtifactTabs', () => { test('derives tabs from resource registry', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1', 'My Workflow'); - registerDataTable(ctx.store, 'dt-1', 'My Table', 'proj-1'); + registerWorkflow(ctx.thread, 'wf-1', 'My Workflow'); + registerDataTable(ctx.thread, 'dt-1', 'My Table', 'proj-1'); expect(ctx.allArtifactTabs.value).toEqual([ { @@ -163,7 +167,7 @@ describe('useCanvasPreview', () => { const registry = new Map(); registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' }); registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' }); - ctx.store.producedArtifacts = registry; + ctx.thread.producedArtifacts = registry; expect(ctx.allArtifactTabs.value).toHaveLength(1); expect(ctx.allArtifactTabs.value[0].type).toBe('workflow'); @@ -173,7 +177,7 @@ describe('useCanvasPreview', () => { describe('selectTab / closePreview', () => { test('selectTab sets activeTabId', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.selectTab('wf-1'); @@ -184,7 +188,7 @@ describe('useCanvasPreview', () => { test('closePreview clears activeTabId', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.selectTab('wf-1'); ctx.closePreview(); @@ -197,8 +201,8 @@ describe('useCanvasPreview', () => { describe('openWorkflowPreview', () => { test('sets activeWorkflowId and clears data table state', () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); - registerWorkflow(ctx.store, 'wf-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openWorkflowPreview('wf-1'); @@ -210,7 +214,7 @@ describe('useCanvasPreview', () => { test('makes isPreviewVisible true', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); expect(ctx.isPreviewVisible.value).toBe(false); ctx.openWorkflowPreview('wf-1'); @@ -221,8 +225,8 @@ describe('useCanvasPreview', () => { describe('openDataTablePreview', () => { test('sets data table state and clears workflow state', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); + registerWorkflow(ctx.thread, 'wf-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); ctx.openWorkflowPreview('wf-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); @@ -234,7 +238,7 @@ describe('useCanvasPreview', () => { test('makes isPreviewVisible true', () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); expect(ctx.isPreviewVisible.value).toBe(false); ctx.openDataTablePreview('dt-1', 'proj-1'); @@ -245,7 +249,7 @@ describe('useCanvasPreview', () => { describe('thread switch (route.params.threadId change)', () => { test('resets all preview state on thread switch', async () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.openWorkflowPreview('wf-1'); ctx.route.params.threadId = 'thread-2'; @@ -259,7 +263,7 @@ describe('useCanvasPreview', () => { test('clears the preview on thread switch, then stays closed while the new thread hydrates', async () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.openWorkflowPreview('wf-1'); expect(ctx.isPreviewVisible.value).toBe(true); @@ -271,9 +275,9 @@ describe('useCanvasPreview', () => { // Past artifacts surfacing during the new thread's hydration shouldn't // pop the panel — historical data, not a live build. - ctx.store.isHydratingThread = true; - registerWorkflow(ctx.store, 'wf-historical'); - ctx.store.messages = [ + ctx.thread.isHydratingThread = true; + registerWorkflow(ctx.thread, 'wf-historical'); + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -295,10 +299,10 @@ describe('useCanvasPreview', () => { describe('auto-open on build result', () => { test('auto-opens canvas when streaming and build result appears', async () => { const ctx = setup(); - ctx.store.isStreaming = true; - registerWorkflow(ctx.store, 'wf-new'); + ctx.thread.isStreaming = true; + registerWorkflow(ctx.thread, 'wf-new'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -321,9 +325,9 @@ describe('useCanvasPreview', () => { const ctx = setup(); // Simulate the loadHistoricalMessages window: artifacts that surface // as part of past data shouldn't pop the preview panel. - ctx.store.isHydratingThread = true; + ctx.thread.isHydratingThread = true; - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -344,12 +348,12 @@ describe('useCanvasPreview', () => { test('switches to latest artifact when a new workflow is built while viewing different artifact', async () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); - registerWorkflow(ctx.store, 'wf-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); - ctx.store.isStreaming = true; + ctx.thread.isStreaming = true; - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -371,11 +375,11 @@ describe('useCanvasPreview', () => { test('increments workflowRefreshKey on each build', async () => { const ctx = setup(); - ctx.store.isStreaming = true; - registerWorkflow(ctx.store, 'wf-1'); + ctx.thread.isStreaming = true; + registerWorkflow(ctx.thread, 'wf-1'); const initialKey = ctx.workflowRefreshKey.value; - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -397,10 +401,10 @@ describe('useCanvasPreview', () => { describe('auto-open data table preview', () => { test('auto-opens data table preview when streaming', async () => { const ctx = setup(); - ctx.store.isStreaming = true; - registerDataTable(ctx.store, 'dt-1', 'Test Table'); + ctx.thread.isStreaming = true; + registerDataTable(ctx.thread, 'dt-1', 'Test Table'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -422,9 +426,9 @@ describe('useCanvasPreview', () => { test('does not auto-open data table preview while hydrating', async () => { const ctx = setup(); - ctx.store.isHydratingThread = true; + ctx.thread.isHydratingThread = true; - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -445,10 +449,10 @@ describe('useCanvasPreview', () => { test('looks up projectId from producedArtifacts', async () => { const ctx = setup(); - ctx.store.isStreaming = true; - registerDataTable(ctx.store, 'dt-1', 'Test Table', 'proj-42'); + ctx.thread.isStreaming = true; + registerDataTable(ctx.thread, 'dt-1', 'Test Table', 'proj-42'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -469,10 +473,10 @@ describe('useCanvasPreview', () => { test('increments dataTableRefreshKey on each data table update', async () => { const ctx = setup(); - ctx.store.isStreaming = true; + ctx.thread.isStreaming = true; const initialKey = ctx.dataTableRefreshKey.value; - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -495,11 +499,11 @@ describe('useCanvasPreview', () => { describe('close data table on delete', () => { test('closes data table preview when active table is deleted', async () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); expect(ctx.activeDataTableId.value).toBe('dt-1'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -519,10 +523,10 @@ describe('useCanvasPreview', () => { test('does not close preview when a different table is deleted', async () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table 1', 'proj-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table 1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -542,11 +546,11 @@ describe('useCanvasPreview', () => { test('falls back to first remaining tab when active table is deleted', async () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); + registerWorkflow(ctx.thread, 'wf-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); - ctx.store.messages = [ + ctx.thread.messages = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ @@ -570,14 +574,14 @@ describe('useCanvasPreview', () => { describe('isPreviewVisible', () => { test('is true when workflow is active', () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.openWorkflowPreview('wf-1'); expect(ctx.isPreviewVisible.value).toBe(true); }); test('is true when data table is active', () => { const ctx = setup(); - registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); + registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1'); expect(ctx.isPreviewVisible.value).toBe(true); }); @@ -591,8 +595,8 @@ describe('useCanvasPreview', () => { describe('tab guard', () => { test('falls back to first tab when active tab is removed from registry', async () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); - registerWorkflow(ctx.store, 'wf-2', 'Second Workflow'); + registerWorkflow(ctx.thread, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-2', 'Second Workflow'); ctx.selectTab('wf-2'); await nextTick(); @@ -601,7 +605,7 @@ describe('useCanvasPreview', () => { // Remove wf-2 from registry, keeping wf-1 const next = new Map(); next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' }); - ctx.store.producedArtifacts = next; + ctx.thread.producedArtifacts = next; await nextTick(); expect(ctx.activeTabId.value).toBe('wf-1'); @@ -609,12 +613,12 @@ describe('useCanvasPreview', () => { test('does not clear activeTabId when registry is empty (race condition)', async () => { const ctx = setup(); - registerWorkflow(ctx.store, 'wf-1'); + registerWorkflow(ctx.thread, 'wf-1'); ctx.selectTab('wf-1'); await nextTick(); // Temporarily empty registry (simulates race where registry hasn't been populated yet) - ctx.store.producedArtifacts = new Map(); + ctx.thread.producedArtifacts = new Map(); await nextTick(); // Tab should remain set — guard skips when tabs are empty diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue index 01dae6fd45b..de7cbe11ba7 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue @@ -7,7 +7,7 @@ import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'; import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue'; import { computed, toRef, useTemplateRef } from 'vue'; import type { ArtifactInfo } from '../agentTimeline.utils'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import { useTimelineGrouping } from '../useTimelineGrouping'; import AgentTimeline from './AgentTimeline.vue'; import ArtifactCard from './ArtifactCard.vue'; @@ -25,7 +25,7 @@ const props = withDefaults( ); const i18n = useI18n(); -const store = useInstanceAiStore(); +const thread = useThread(); const hasReasoning = computed(() => props.agentNode.reasoning.length > 0); const triggerRef = useTemplateRef('triggerRef'); @@ -46,7 +46,7 @@ const lastGroupIdx = computed(() => { }); function resolveArtifactName(artifact: ArtifactInfo): string { - const entry = store.producedArtifacts.get(artifact.resourceId); + const entry = thread.producedArtifacts.get(artifact.resourceId); return entry?.name ?? artifact.name; } @@ -91,7 +91,7 @@ function resolveArtifactName(artifact: ArtifactInfo): string { :name="resolveArtifactName(artifact)" :resource-id="artifact.resourceId" :project-id="artifact.projectId" - :archived="store.producedArtifacts.get(artifact.resourceId)?.archived" + :archived="thread.producedArtifacts.get(artifact.resourceId)?.archived" :class="$style.artifactCard" /> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue index 8988b22f105..7e306daa6f3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue @@ -11,7 +11,7 @@ import { computed } from 'vue'; import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useRootStore } from '@n8n/stores/useRootStore'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import { isActiveBuilderAgent } from '../builderAgents'; import AgentSection from './AgentSection.vue'; import AnsweredQuestions from './AnsweredQuestions.vue'; @@ -23,13 +23,13 @@ import TaskChecklist from './TaskChecklist.vue'; import ToolCallStep from './ToolCallStep.vue'; const i18n = useI18n(); -const store = useInstanceAiStore(); +const thread = useThread(); const telemetry = useTelemetry(); const rootStore = useRootStore(); /** Resolve artifact name from the enriched registry (falls back to extracted name). */ function resolveArtifactName(artifact: ArtifactInfo): string { - const entry = store.producedArtifacts.get(artifact.resourceId); + const entry = thread.producedArtifacts.get(artifact.resourceId); return entry?.name ?? artifact.name; } @@ -120,7 +120,7 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb const numTasks = ((tc.args?.tasks as PlannedTaskArg[] | undefined) ?? []).length; const eventProps = { - thread_id: store.currentThreadId, + thread_id: thread.currentThreadId, input_thread_id: tc.confirmation?.inputThreadId ?? '', instance_id: rootStore.instanceId, type: 'plan-review', @@ -137,8 +137,8 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb }; telemetry.track('User finished providing input', eventProps); - store.resolveConfirmation(requestId, approved ? 'approved' : 'denied'); - void store.confirmAction(requestId, { + thread.resolveConfirmation(requestId, approved ? 'approved' : 'denied'); + void thread.confirmAction(requestId, { kind: 'approval', approved, ...(feedback ? { userInput: feedback } : {}), @@ -293,7 +293,7 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi :name="resolveArtifactName(artifact)" :resource-id="artifact.resourceId" :project-id="artifact.projectId" - :archived="store.producedArtifacts.get(artifact.resourceId)?.archived" + :archived="thread.producedArtifacts.get(artifact.resourceId)?.archived" :metadata="formatArtifactMetadata(artifact)" /> diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue index 39fbb8bac5b..a682956c530 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/DomainAccessApproval.vue @@ -3,7 +3,7 @@ import { N8nButton, N8nText } from '@n8n/design-system'; import type { ActionDropdownItem } from '@n8n/design-system/types'; import { useI18n } from '@n8n/i18n'; import { computed, ref } from 'vue'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationPreview from './ConfirmationPreview.vue'; import SplitButton from './SplitButton.vue'; @@ -29,7 +29,7 @@ interface WebSearchProps { const props = defineProps(); const i18n = useI18n(); -const store = useInstanceAiStore(); +const thread = useThread(); const resolved = ref(false); const isWebSearch = computed(() => props.query !== undefined); @@ -63,8 +63,8 @@ const dropdownItems = computed>>(() => [ function handleAction(approved: boolean, domainAccessAction?: DomainAction) { resolved.value = true; - store.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied'); - void store.confirmAction( + thread.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied'); + void thread.confirmAction( props.requestId, approved && domainAccessAction ? { kind: 'domainAccessApprove', domainAccessAction } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue index e91ff8c97b2..675a9f2d9be 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue @@ -6,7 +6,7 @@ import { computed } from 'vue'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useRootStore } from '@n8n/stores/useRootStore'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationPreview from './ConfirmationPreview.vue'; import SplitButton from './SplitButton.vue'; @@ -35,7 +35,7 @@ const props = defineProps<{ const i18n = useI18n(); const telemetry = useTelemetry(); const rootStore = useRootStore(); -const store = useInstanceAiStore(); +const thread = useThread(); interface OptionEntry { decision: InstanceGatewayResourceDecision; @@ -72,10 +72,10 @@ const approveDropdownItems = computed(() => { }); async function confirm(decision: InstanceGatewayResourceDecision) { - const tc = store.findToolCallByRequestId(props.requestId); + const tc = thread.findToolCallByRequestId(props.requestId); const inputThreadId = tc?.confirmation?.inputThreadId ?? ''; const eventProps = { - thread_id: store.currentThreadId, + thread_id: thread.currentThreadId, input_thread_id: inputThreadId, instance_id: rootStore.instanceId, type: 'resource-decision', @@ -83,7 +83,7 @@ async function confirm(decision: InstanceGatewayResourceDecision) { skipped_inputs: [], }; telemetry.track('User finished providing input', eventProps); - await store.confirmResourceDecision(props.requestId, decision); + await thread.confirmResourceDecision(props.requestId, decision); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue index a3e109337f1..0ee62708618 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue @@ -2,14 +2,14 @@ import { computed, inject } from 'vue'; import { N8nHeading, N8nIcon } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import type { TaskItem } from '@n8n/api-types'; import type { IconName } from '@n8n/design-system'; import type { ResourceEntry } from '../useResourceRegistry'; import ConnectionsCard from './ConnectionsCard.vue'; const i18n = useI18n(); -const store = useInstanceAiStore(); +const thread = useThread(); const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined); const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>( 'openDataTablePreview', @@ -35,7 +35,7 @@ function handleArtifactClick(artifact: ResourceEntry, e: MouseEvent) { } // --- Tasks --- -const tasks = computed(() => store.currentTasks); +const tasks = computed(() => thread.currentTasks); const doneCount = computed(() => { if (!tasks.value) return 0; @@ -56,7 +56,7 @@ const statusIconMap: Record< // --- Artifacts --- const artifacts = computed((): ResourceEntry[] => { const result: ResourceEntry[] = []; - for (const entry of store.producedArtifacts.values()) { + for (const entry of thread.producedArtifacts.values()) { if (entry.type === 'workflow' || entry.type === 'data-table') { result.push(entry); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue index 0036f2dbcdb..8a139015613 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue @@ -5,7 +5,7 @@ import type { InstanceAiConfirmation } from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { computed, ref } from 'vue'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { useInstanceAiStore, type PendingConfirmationItem } from '../instanceAi.store'; +import { useThread, type PendingConfirmationItem } from '../instanceAi.store'; import { useToolLabel } from '../toolLabels'; import ConfirmationFooter from './ConfirmationFooter.vue'; import DomainAccessApproval from './DomainAccessApproval.vue'; @@ -17,7 +17,7 @@ import InstanceAiWorkflowSetup from './InstanceAiWorkflowSetup.vue'; import ConfirmationPreview from './ConfirmationPreview.vue'; import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue'; -const store = useInstanceAiStore(); +const thread = useThread(); const i18n = useI18n(); const rootStore = useRootStore(); const telemetry = useTelemetry(); @@ -48,7 +48,7 @@ function trackInputCompleted( extra?: Record, ): void { const eventProps = { - thread_id: store.currentThreadId, + thread_id: thread.currentThreadId, input_thread_id: conf.inputThreadId ?? '', instance_id: rootStore.instanceId, type: getConfirmationType(conf), @@ -108,7 +108,7 @@ const chunks = computed((): ConfirmationChunk[] => { const result: ConfirmationChunk[] = []; const wrappedByAgent = new Map(); - for (const item of store.pendingConfirmations) { + for (const item of thread.pendingConfirmations) { if (isApprovalWrapped(item)) { const key = item.agentNode.agentId; let group = wrappedByAgent.get(key); @@ -134,7 +134,7 @@ const textInputValues = ref>({}); function handleConfirm(item: PendingConfirmationItem, approved: boolean) { const conf = item.toolCall.confirmation; - if (store.resolvedConfirmationIds.has(conf.requestId)) return; + if (thread.resolvedConfirmationIds.has(conf.requestId)) return; trackInputCompleted( conf, [ @@ -146,21 +146,21 @@ function handleConfirm(item: PendingConfirmationItem, approved: boolean) { ], [], ); - store.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved }); + thread.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved }); } function handleApproveAll(items: PendingConfirmationItem[]) { for (const item of items) { const conf = item.toolCall.confirmation; - if (store.resolvedConfirmationIds.has(conf.requestId)) continue; + if (thread.resolvedConfirmationIds.has(conf.requestId)) continue; trackInputCompleted( conf, [{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }], [], ); - store.resolveConfirmation(conf.requestId, 'approved'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); + thread.resolveConfirmation(conf.requestId, 'approved'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true }); } } @@ -180,8 +180,8 @@ function handleTextSubmit(conf: InstanceAiConfirmation) { ], [], ); - store.resolveConfirmation(conf.requestId, 'approved'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value }); + thread.resolveConfirmation(conf.requestId, 'approved'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value }); } function handleTextSkip(conf: InstanceAiConfirmation) { @@ -190,19 +190,19 @@ function handleTextSkip(conf: InstanceAiConfirmation) { [], [{ label: conf.message, question: conf.message, input_type: 'text', options: [] }], ); - store.resolveConfirmation(conf.requestId, 'deferred'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved: false }); + thread.resolveConfirmation(conf.requestId, 'deferred'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: false }); } function handleContinue(conf: InstanceAiConfirmation) { - if (store.resolvedConfirmationIds.has(conf.requestId)) return; + if (thread.resolvedConfirmationIds.has(conf.requestId)) return; trackInputCompleted( conf, [{ label: conf.message, options: ['continue'], option_chosen: 'continue' }], [], ); - store.resolveConfirmation(conf.requestId, 'approved'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); + thread.resolveConfirmation(conf.requestId, 'approved'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true }); } function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) { @@ -243,8 +243,8 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn } } trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length }); - store.resolveConfirmation(conf.requestId, 'approved'); - void store.confirmAction(conf.requestId, { kind: 'questions', answers }); + thread.resolveConfirmation(conf.requestId, 'approved'); + void thread.confirmAction(conf.requestId, { kind: 'questions', answers }); } function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) { @@ -254,8 +254,8 @@ function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) { [], { num_tasks: numTasks }, ); - store.resolveConfirmation(conf.requestId, 'approved'); - void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); + thread.resolveConfirmation(conf.requestId, 'approved'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true }); } function handlePlanRequestChanges( @@ -269,8 +269,8 @@ function handlePlanRequestChanges( [], { num_tasks: numTasks, feedback }, ); - store.resolveConfirmation(conf.requestId, 'denied'); - void store.confirmAction(conf.requestId, { + thread.resolveConfirmation(conf.requestId, 'denied'); + void thread.confirmAction(conf.requestId, { kind: 'approval', approved: false, userInput: feedback, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue index e4c37f3130d..f58b7051efc 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue @@ -12,7 +12,7 @@ import { useI18n } from '@n8n/i18n'; import { useRootStore } from '@n8n/stores/useRootStore'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useTelemetry } from '@/app/composables/useTelemetry'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useThread } from '../instanceAi.store'; import ConfirmationFooter from './ConfirmationFooter.vue'; const props = defineProps<{ @@ -26,7 +26,7 @@ const props = defineProps<{ const i18n = useI18n(); const telemetry = useTelemetry(); const rootStore = useRootStore(); -const store = useInstanceAiStore(); +const thread = useThread(); const credentialsStore = useCredentialsStore(); const uiStore = useUIStore(); @@ -246,7 +246,7 @@ function onCredentialSelected( } function trackCredentialInput() { - const tc = store.findToolCallByRequestId(props.requestId); + const tc = thread.findToolCallByRequestId(props.requestId); const inputThreadId = tc?.confirmation?.inputThreadId ?? ''; const provided: Array<{ label: string; options: string[]; option_chosen: string }> = []; const skipped: Array<{ label: string; options: string[] }> = []; @@ -259,7 +259,7 @@ function trackCredentialInput() { } } telemetry.track('User finished providing input', { - thread_id: store.currentThreadId, + thread_id: thread.currentThreadId, input_thread_id: inputThreadId, instance_id: rootStore.instanceId, type: 'credential-setup', @@ -279,12 +279,12 @@ async function handleContinue() { isSubmitted.value = true; - const success = await store.confirmAction(props.requestId, { + const success = await thread.confirmAction(props.requestId, { kind: 'credentialSelection', credentials, }); if (success) { - store.resolveConfirmation(props.requestId, 'approved'); + thread.resolveConfirmation(props.requestId, 'approved'); } else { isSubmitted.value = false; } @@ -296,12 +296,12 @@ async function handleLater() { isSubmitted.value = true; isDeferred.value = true; - const success = await store.confirmAction(props.requestId, { + const success = await thread.confirmAction(props.requestId, { kind: 'approval', approved: false, }); if (success) { - store.resolveConfirmation(props.requestId, 'deferred'); + thread.resolveConfirmation(props.requestId, 'deferred'); } else { isSubmitted.value = false; isDeferred.value = false; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiDebugPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiDebugPanel.vue index 4fe695636cc..4ee1459e78e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiDebugPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiDebugPanel.vue @@ -2,12 +2,13 @@ import { N8nIcon, N8nIconButton } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'; -import { useInstanceAiStore } from '../instanceAi.store'; +import { useInstanceAiStore, useThread } from '../instanceAi.store'; import { useInstanceAiDebugStore } from '../instanceAiDebug.store'; const emit = defineEmits<{ close: [] }>(); const i18n = useI18n(); const store = useInstanceAiStore(); +const thread = useThread(); const debugStore = useInstanceAiDebugStore(); // --- Tab state --- @@ -17,7 +18,7 @@ const activeTab = ref('events'); // --- Events tab state --- const expandedIndex = ref(null); const eventListRef = useTemplateRef('eventList'); -const events = computed(() => store.debugEvents); +const events = computed(() => thread.debugEvents); // --- Threads tab state --- const expandedMessageIndex = ref(null); @@ -81,7 +82,7 @@ function contentPreview(content: unknown): string { } async function handleCopyTrace() { - const trace = store.copyFullTrace(); + const trace = thread.copyFullTrace(); await navigator.clipboard.writeText(trace); } @@ -116,7 +117,7 @@ function handleRefreshThreads() { // Tool call timing summary const toolCallTimings = computed(() => { const timings: Array<{ name: string; duration: string; toolCallId: string }> = []; - for (const msg of store.messages) { + for (const msg of thread.messages) { if (!msg.agentTree) continue; const nodes = [msg.agentTree, ...msg.agentTree.children]; for (const node of nodes) { @@ -177,8 +178,8 @@ onMounted(() => {