refactor(editor): Add Instance AI thread provider (no-changelog) (#30090)

This commit is contained in:
Raúl Gómez Morales 2026-05-11 13:45:19 +02:00 committed by GitHub
parent 6f9b99a3cf
commit 0d571c05e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 366 additions and 286 deletions

View File

@ -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() {
<div :class="$style.emptyLayout">
<InstanceAiEmptyState />
<div :class="$style.centeredInput">
<InstanceAiStatusBar />
<CreditWarningBanner
v-if="creditBanner.visible.value"
:credits-remaining="store.creditsRemaining"
@ -80,9 +78,16 @@ function handleStop() {
<InstanceAiInput
ref="chatInputRef"
:is-streaming="store.isStreaming"
:is-sending-message="store.isSendingMessage"
:is-awaiting-confirmation="store.isAwaitingConfirmation"
:current-thread-id="store.currentThreadId"
:amend-context="store.amendContext"
:contextual-suggestion="store.contextualSuggestion"
:research-mode="store.researchMode"
:suggestions="INSTANCE_AI_EMPTY_STATE_SUGGESTIONS"
@submit="handleSubmit"
@stop="handleStop"
@toggle-research-mode="store.toggleResearchMode()"
/>
</div>
</div>

View File

@ -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<string | undefined>(() => {
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<string | undefined>(() => {
// --- 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();
}
</script>
@ -327,7 +327,7 @@ function handleStop() {
{{ currentThreadTitle }}
</N8nHeading>
<N8nText
v-if="store.sseState === 'reconnecting'"
v-if="thread.sseState === 'reconnecting'"
size="small"
color="text-light"
:class="$style.reconnecting"
@ -396,7 +396,7 @@ function handleStop() {
>
<Transition name="fade">
<N8nIconButton
v-if="userScrolledUp && store.hasMessages"
v-if="userScrolledUp && thread.hasMessages"
variant="outline"
icon="arrow-down"
:class="$style.scrollToBottomButton"
@ -421,9 +421,16 @@ function handleStop() {
/>
<InstanceAiInput
ref="chatInputRef"
:is-streaming="store.isStreaming"
:is-streaming="thread.isStreaming"
:is-sending-message="thread.isSendingMessage"
:is-awaiting-confirmation="thread.isAwaitingConfirmation"
:current-thread-id="thread.currentThreadId"
:amend-context="thread.amendContext"
:contextual-suggestion="thread.contextualSuggestion"
:research-mode="store.researchMode"
@submit="handleSubmit"
@stop="handleStop"
@toggle-research-mode="store.toggleResearchMode()"
/>
</div>
</div>

View File

@ -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: '<div />', props: ['plannedTasks', 'message', 'readOnly'] },
}));
const renderComponent = createComponentRenderer(InstanceAiConfirmationPanel);
const renderComponent = createThreadComponentRenderer(InstanceAiConfirmationPanel);
// ---------------------------------------------------------------------------
// Helpers

View File

@ -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[] {

View File

@ -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> = {}): 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,
},
});

View File

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

View File

@ -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: {

View File

@ -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(

View File

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

View File

@ -191,8 +191,8 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
const executionTracking = useExecutionPushEvents();
const preview = useCanvasPreview({
store: store as unknown as ReturnType<typeof useInstanceAiStore>,
route: route as Parameters<typeof useCanvasPreview>[0]['route'],
thread: store as unknown as ReturnType<typeof useInstanceAiStore>,
threadId: () => route.params.threadId,
});
const relayedEvents: PushMessage[] = [];

View File

@ -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<T extends Component>(
component: T,
defaultOptions: RenderOptions<T> = {},
) {
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<typeof ThreadProvider>,
);
return (options: RenderOptions<T> = {}, rendererOptions: RendererOptions = {}) =>
renderProvider(options as unknown as RenderOptions<typeof ThreadProvider>, rendererOptions);
}

View File

@ -47,10 +47,10 @@ function makeMessage(overrides: Partial<InstanceAiMessage> = {}): InstanceAiMess
}
// ---------------------------------------------------------------------------
// Store mock
// Thread mock
// ---------------------------------------------------------------------------
function createMockStore() {
function createMockThread() {
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
const isStreaming = ref(false);
const isHydratingThread = ref(false);
@ -67,31 +67,36 @@ function createMockStore() {
});
}
type MockStore = ReturnType<typeof createMockStore>;
type MockThread = ReturnType<typeof createMockThread>;
// ---------------------------------------------------------------------------
// 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<MockStore> }) {
const store = createMockStore();
if (options?.storeOverrides) Object.assign(store, options.storeOverrides);
function setup(options?: { threadOverrides?: Partial<MockThread> }) {
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<string, ResourceEntry>();
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<string, ResourceEntry>();
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

View File

@ -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<HTMLElement>('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;
}
</script>
@ -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"
/>
</template>

View File

@ -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)"
/>
</template>

View File

@ -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<DomainProps | WebSearchProps>();
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<Array<ActionDropdownItem<DomainAction>>>(() => [
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 }

View File

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

View File

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

View File

@ -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<string, unknown>,
): 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<string, ApprovalWrappedGroup>();
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<Record<string, string>>({});
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,

View File

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

View File

@ -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<Tab>('events');
// --- Events tab state ---
const expandedIndex = ref<number | null>(null);
const eventListRef = useTemplateRef<HTMLElement>('eventList');
const events = computed(() => store.debugEvents);
const events = computed(() => thread.debugEvents);
// --- Threads tab state ---
const expandedMessageIndex = ref<number | null>(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(() => {
<template v-if="activeTab === 'events'">
<!-- Connection status -->
<div :class="$style.statusBar">
<span :class="$style.statusDot" :data-state="store.sseState" />
<span>SSE: {{ store.sseState }}</span>
<span :class="$style.statusDot" :data-state="thread.sseState" />
<span>SSE: {{ thread.sseState }}</span>
<span :class="$style.eventCount">{{ events.length }} events</span>
</div>

View File

@ -6,23 +6,30 @@ import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
import AttachmentPreview from './AttachmentPreview.vue';
import InstanceAiPromptSuggestions from './InstanceAiPromptSuggestions.vue';
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
import { useInstanceAiStore } from '../instanceAi.store';
import type { InstanceAiAttachment } from '@n8n/api-types';
import type { InstanceAiEmptyStateSuggestion } from '../emptyStateSuggestions';
import { useInstanceAiPromptSuggestionsTelemetry } from '../instanceAiPromptSuggestions.telemetry';
type AmendContext = { agentId: string; role: string } | null;
const props = defineProps<{
isStreaming: boolean;
isSendingMessage: boolean;
isAwaitingConfirmation: boolean;
currentThreadId: string;
amendContext: AmendContext;
contextualSuggestion: string | null;
researchMode: boolean;
suggestions?: readonly InstanceAiEmptyStateSuggestion[];
}>();
const emit = defineEmits<{
submit: [message: string, attachments?: InstanceAiAttachment[]];
stop: [];
'toggle-research-mode': [];
}>();
const i18n = useI18n();
const store = useInstanceAiStore();
const promptSuggestionsTelemetry = useInstanceAiPromptSuggestionsTelemetry();
const inputText = ref('');
const attachedFiles = ref<File[]>([]);
@ -33,12 +40,12 @@ defineExpose({
focus: () => chatInputRef.value?.focus(),
});
const isBusy = computed(() => props.isStreaming || store.isSendingMessage);
const isBusy = computed(() => props.isStreaming || props.isSendingMessage);
const hasNonWhitespaceDraftText = computed(() => inputText.value.trim().length > 0);
const isInputVisuallyEmpty = computed(() => inputText.value.length === 0);
const hasAttachments = computed(() => attachedFiles.value.length > 0);
const isComposerDirty = computed(() => hasNonWhitespaceDraftText.value || hasAttachments.value);
const isGatedBySetup = computed(() => store.isAwaitingConfirmation);
const isGatedBySetup = computed(() => props.isAwaitingConfirmation);
const canSubmit = computed(() => isComposerDirty.value && !isBusy.value && !isGatedBySetup.value);
const canShowSuggestions = computed(
() =>
@ -48,7 +55,7 @@ const canShowSuggestions = computed(
!isGatedBySetup.value,
);
const visibleSuggestionThreadId = computed(() =>
canShowSuggestions.value ? store.currentThreadId : null,
canShowSuggestions.value ? props.currentThreadId : null,
);
const placeholder = computed(() => {
@ -58,13 +65,13 @@ const placeholder = computed(() => {
if (previewPromptKey.value && isInputVisuallyEmpty.value) {
return i18n.baseText(previewPromptKey.value);
}
if (store.amendContext) {
if (props.amendContext) {
return i18n.baseText('instanceAi.input.amendPlaceholder', {
interpolate: { role: store.amendContext.role },
interpolate: { role: props.amendContext.role },
});
}
if (store.contextualSuggestion) {
return store.contextualSuggestion;
if (props.contextualSuggestion) {
return props.contextualSuggestion;
}
return i18n.baseText('instanceAi.input.placeholder');
});
@ -75,7 +82,7 @@ watch(
if (threadId) {
promptSuggestionsTelemetry.trackSuggestionsShown({
threadId,
researchMode: store.researchMode,
researchMode: props.researchMode,
});
return;
}
@ -132,8 +139,8 @@ function handleStop() {
}
function handleTabAutocomplete() {
if (!inputText.value && store.contextualSuggestion) {
inputText.value = store.contextualSuggestion;
if (!inputText.value && props.contextualSuggestion) {
inputText.value = props.contextualSuggestion;
}
}
@ -150,8 +157,8 @@ function handleFileRemove(file: File) {
function getTelemetryContext() {
return {
threadId: store.currentThreadId,
researchMode: store.researchMode,
threadId: props.currentThreadId,
researchMode: props.researchMode,
};
}
@ -225,9 +232,9 @@ const resizable = computed(() => {
:show-after="300"
>
<button
:class="[$style.researchToggle, { [$style.active]: store.researchMode }]"
:class="[$style.researchToggle, { [$style.active]: props.researchMode }]"
data-test-id="instance-ai-research-toggle"
@click="store.toggleResearchMode()"
@click="emit('toggle-research-mode')"
>
<svg
:class="$style.researchIcon"

View File

@ -2,13 +2,13 @@
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
import type { ComponentPublicInstance } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import { useThread } from '../instanceAi.store';
const props = defineProps<{
content: string;
}>();
const store = useInstanceAiStore();
const thread = useThread();
const styles = useCssModule();
const wrapperRef = ref<ComponentPublicInstance | null>(null);
@ -55,7 +55,7 @@ const INTERNAL_BLOCK_PATTERN =
/<(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)[\s\S]*?<\/(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)>/g;
const processedContent = computed(() => {
const registry = store.resourceNameIndex;
const registry = thread.resourceNameIndex;
// Strip internal protocol blocks the LLM may have echoed
let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim();
@ -178,7 +178,7 @@ function enhanceResourceLinks(): void {
// Search the name index because it contains both produced and listed
// resources a user may click through to a resource the agent
// only referenced via a list call.
const registryEntry = [...store.resourceNameIndex.values()].find(
const registryEntry = [...thread.resourceNameIndex.values()].find(
(r) => r.type === type && r.id === id,
);

View File

@ -4,7 +4,7 @@ import type { RatingFeedback } from '@n8n/design-system';
import { N8nCallout, N8nIconButton, N8nMessageRating, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import { useInstanceAiStore, useThread } from '../instanceAi.store';
import AgentActivityTree from './AgentActivityTree.vue';
import AttachmentPreview from './AttachmentPreview.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
@ -15,6 +15,7 @@ const props = defineProps<{
const i18n = useI18n();
const store = useInstanceAiStore();
const thread = useThread();
const showDebugInfo = ref(false);
const isUser = computed(() => props.message.role === 'user');
@ -66,16 +67,16 @@ const responseId = computed(() => props.message.messageGroupId ?? props.message.
const isRateable = computed(
() =>
!isUser.value &&
store.rateableResponseId === responseId.value &&
!(responseId.value in store.feedbackByResponseId),
thread.rateableResponseId === responseId.value &&
!(responseId.value in thread.feedbackByResponseId),
);
const hasSubmittedFeedback = computed(
() => !isUser.value && responseId.value in store.feedbackByResponseId,
() => !isUser.value && responseId.value in thread.feedbackByResponseId,
);
function onFeedback(payload: RatingFeedback) {
store.submitFeedback(responseId.value, payload);
thread.submitFeedback(responseId.value, payload);
}
function formatJson(value: unknown): string {

View File

@ -3,10 +3,10 @@ import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nIcon } from '@n8n/design-system';
import type { InstanceAiMessage } from '@n8n/api-types';
import { useInstanceAiStore } from '../instanceAi.store';
import { useThread } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
const store = useInstanceAiStore();
const thread = useThread();
const i18n = useI18n();
const { getToolLabel } = useToolLabel();
@ -47,13 +47,13 @@ function deriveActivity(messages: InstanceAiMessage[]): { label: string; detail?
}
const activity = computed(() => {
if (store.isAwaitingConfirmation) {
if (thread.isAwaitingConfirmation) {
return { label: i18n.baseText('instanceAi.statusBar.waitingForInput') };
}
return deriveActivity(store.messages);
return deriveActivity(thread.messages);
});
const isVisible = computed(() => store.isStreaming);
const isVisible = computed(() => thread.isStreaming);
const formattedElapsed = computed(() => {
const s = elapsed.value;
@ -63,7 +63,7 @@ const formattedElapsed = computed(() => {
return `${String(m)}m ${String(remaining).padStart(2, '0')}s`;
});
const isCountingElapsed = computed(() => isVisible.value && !store.isAwaitingConfirmation);
const isCountingElapsed = computed(() => isVisible.value && !thread.isAwaitingConfirmation);
watch(isVisible, (visible) => {
if (visible) {
@ -98,11 +98,11 @@ onUnmounted(() => {
<Transition name="status-bar">
<div
v-if="isVisible && activity"
:class="[$style.bar, { [$style.muted]: store.isAwaitingConfirmation }]"
:class="[$style.bar, { [$style.muted]: thread.isAwaitingConfirmation }]"
data-test-id="instance-ai-status-bar"
>
<N8nIcon
v-if="store.isAwaitingConfirmation"
v-if="thread.isAwaitingConfirmation"
:class="$style.glyph"
icon="circle-pause"
size="xsmall"
@ -111,7 +111,7 @@ onUnmounted(() => {
<span :class="$style.label">{{ activity.label }}</span>
<span v-if="activity.detail" :class="$style.separator">&middot;</span>
<span v-if="activity.detail" :class="$style.detail">{{ activity.detail }}</span>
<template v-if="!store.isAwaitingConfirmation">
<template v-if="!thread.isAwaitingConfirmation">
<span :class="$style.separator">&middot;</span>
<span :class="$style.elapsed">{{ formattedElapsed }}</span>
</template>

View File

@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import { useThread } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
import ToolResultJson from './ToolResultJson.vue';
import ToolResultRenderer from './ToolResultRenderer.vue';
@ -15,7 +15,7 @@ const props = defineProps<{
}>();
const i18n = useI18n();
const store = useInstanceAiStore();
const thread = useThread();
const { getToolLabel } = useToolLabel();
const isOpen = ref(false);
@ -37,14 +37,14 @@ const showConfirmation = computed(
props.toolCall.isLoading &&
props.toolCall.confirmationStatus !== 'approved' &&
props.toolCall.confirmationStatus !== 'denied' &&
!store.resolvedConfirmationIds.has(props.toolCall.confirmation.requestId),
!thread.resolvedConfirmationIds.has(props.toolCall.confirmation.requestId),
);
/** Resolved confirmation action — from backend or local optimistic state. */
const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null => {
// Local optimistic state takes priority (has richer semantics like 'deferred')
const rid = props.toolCall.confirmation?.requestId;
const local = rid ? store.resolvedConfirmationIds.get(rid) : undefined;
const local = rid ? thread.resolvedConfirmationIds.get(rid) : undefined;
if (local) return local;
const status = props.toolCall.confirmationStatus;
if (status === 'approved' || status === 'denied') return status;

View File

@ -22,7 +22,7 @@ import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { NodeHelpers } from 'n8n-workflow';
import { computed, defineComponent, onMounted, onUnmounted, provide, ref, toRef, watch } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import { useThread } from '../instanceAi.store';
import {
credGroupKey,
isTriggerOnly as isTriggerOnlyUtil,
@ -52,7 +52,7 @@ const props = defineProps<{
}>();
const i18n = useI18n();
const store = useInstanceAiStore();
const thread = useThread();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore();
@ -171,7 +171,7 @@ const {
onCredentialSelected,
} = useSetupActions({
requestId: toRef(props, 'requestId'),
store,
thread,
cards,
currentDisplayCard,
displayCards,

View File

@ -5,13 +5,13 @@ import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useTelemetry } from '@/app/composables/useTelemetry';
import type { INodeUi } from '@/Interface';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { useInstanceAiStore } from '../instanceAi.store';
import type { ThreadRuntime } from '../instanceAi.store';
import type { DisplayCard, SetupCard } from '../instanceAiWorkflowSetup.utils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
export function useSetupActions(deps: {
requestId: Ref<string>;
store: ReturnType<typeof useInstanceAiStore>;
thread: ThreadRuntime;
cards: ComputedRef<SetupCard[]>;
currentDisplayCard: ComputedRef<DisplayCard | undefined>;
displayCards: ComputedRef<DisplayCard[]>;
@ -50,7 +50,7 @@ export function useSetupActions(deps: {
});
function trackSetupInput() {
const tc = deps.store.findToolCallByRequestId(deps.requestId.value);
const tc = deps.thread.findToolCallByRequestId(deps.requestId.value);
const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
const skipped: Array<{ label: string; options: string[] }> = [];
@ -63,7 +63,7 @@ export function useSetupActions(deps: {
}
}
telemetry.track('User finished providing input', {
thread_id: deps.store.currentThreadId,
thread_id: deps.thread.currentThreadId,
input_thread_id: inputThreadId,
instance_id: useRootStore().instanceId,
type: 'setup',
@ -82,7 +82,7 @@ export function useSetupActions(deps: {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const promise = new Promise<Record<string, unknown> | null>((resolve) => {
const existing = deps.store.findToolCallByRequestId(requestId);
const existing = deps.thread.findToolCallByRequestId(requestId);
if (existing?.result !== undefined) {
resolve(isToolResult(existing.result) ? existing.result : null);
return;
@ -91,7 +91,7 @@ export function useSetupActions(deps: {
stopWatch = watch(
() => {
const tc: InstanceAiToolCallState | undefined =
deps.store.findToolCallByRequestId(requestId);
deps.thread.findToolCallByRequestId(requestId);
return tc?.result;
},
(result) => {
@ -167,7 +167,7 @@ export function useSetupActions(deps: {
isApplying.value = true;
applyError.value = null;
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
const postSuccess = await deps.thread.confirmAction(deps.requestId.value, {
kind: 'setupWorkflowApply',
nodeCredentials,
nodeParameters,
@ -191,7 +191,7 @@ export function useSetupActions(deps: {
isSubmitted.value = true;
isPartial.value = toolResult.partial === true;
deps.onApplySuccess?.();
deps.store.resolveConfirmation(deps.requestId.value, 'approved');
deps.thread.resolveConfirmation(deps.requestId.value, 'approved');
} else if (toolResult) {
applyError.value = typeof toolResult.error === 'string' ? toolResult.error : 'Apply failed';
} else {
@ -205,7 +205,7 @@ export function useSetupActions(deps: {
applyError.value = null;
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
const postSuccess = await deps.thread.confirmAction(deps.requestId.value, {
kind: 'setupWorkflowTestTrigger',
testTriggerNode: nodeName,
nodeCredentials,
@ -289,12 +289,12 @@ export function useSetupActions(deps: {
isSubmitted.value = true;
isDeferred.value = true;
const success = await deps.store.confirmAction(deps.requestId.value, {
const success = await deps.thread.confirmAction(deps.requestId.value, {
kind: 'approval',
approved: false,
});
if (success) {
deps.store.resolveConfirmation(deps.requestId.value, 'deferred');
deps.thread.resolveConfirmation(deps.requestId.value, 'deferred');
} else {
isSubmitted.value = false;
isDeferred.value = false;

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { ref, computed, inject, provide, type InjectionKey } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
@ -345,3 +345,20 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
closeSSE: runtime.closeSSE,
};
});
export type ThreadRuntime = ReturnType<typeof useInstanceAiStore>;
const ThreadKey: InjectionKey<ThreadRuntime> = Symbol('instanceAiThread');
export function provideThread(thread: ThreadRuntime): ThreadRuntime {
provide(ThreadKey, thread);
return thread;
}
export function useThread(): ThreadRuntime {
const thread = inject(ThreadKey, null);
if (!thread) {
throw new Error('useThread() requires a provideThread() ancestor.');
}
return thread;
}

View File

@ -1,5 +1,4 @@
import { computed, ref, watch } from 'vue';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import type { IconName } from '@n8n/design-system';
import {
getLatestBuildResult,
@ -7,7 +6,7 @@ import {
getLatestDataTableResult,
getLatestDeletedDataTableId,
} from './canvasPreview.utils';
import type { useInstanceAiStore } from './instanceAi.store';
import type { ThreadRuntime } from './instanceAi.store';
export interface ArtifactTab {
id: string;
@ -23,18 +22,18 @@ const ARTIFACT_ICON_MAP: Record<string, IconName> = {
};
interface UseCanvasPreviewOptions {
store: ReturnType<typeof useInstanceAiStore>;
route: RouteLocationNormalizedLoadedGeneric;
thread: ThreadRuntime;
threadId: () => string;
}
export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
export function useCanvasPreview({ thread, threadId }: UseCanvasPreviewOptions) {
// --- Tab state ---
const activeTabId = ref<string>();
// All artifacts (workflows + data tables) in the current thread, derived from resource registry
const allArtifactTabs = computed((): ArtifactTab[] => {
const result: ArtifactTab[] = [];
for (const entry of store.producedArtifacts.values()) {
for (const entry of thread.producedArtifacts.values()) {
if (entry.type === 'workflow' || entry.type === 'data-table') {
result.push({
id: entry.id,
@ -117,26 +116,23 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
// Each thread is stateless for the preview panel: switching threads
// closes the panel. Past artifacts are reachable via their inline
// references in the message timeline.
watch(
() => route.params.threadId,
(threadId, oldThreadId) => {
// Skip if this is the initial route setup (e.g. URL updated from
// /instance-ai to /instance-ai/:threadId after the first message)
if (!oldThreadId) return;
// Skip if the thread ID hasn't actually changed
if (threadId === oldThreadId) return;
watch(threadId, (nextThreadId, oldThreadId) => {
// Skip if this is the initial route setup (e.g. URL updated from
// /instance-ai to /instance-ai/:threadId after the first message)
if (!oldThreadId) return;
// Skip if the thread ID hasn't actually changed
if (nextThreadId === oldThreadId) return;
activeTabId.value = undefined;
},
);
activeTabId.value = undefined;
});
// --- Auto-open canvas when AI creates/modifies a workflow ---
const workflowRefreshKey = ref(0);
const latestBuildResult = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
const msg = store.messages[i];
for (let i = thread.messages.length - 1; i >= 0; i--) {
const msg = thread.messages[i];
if (msg.agentTree) {
const result = getLatestBuildResult(msg.agentTree);
if (result) return result;
@ -158,7 +154,7 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
() => latestBuildResult.value?.toolCallId,
(toolCallId) => {
if (!toolCallId || !latestBuildResult.value) return;
if (store.isHydratingThread) return;
if (thread.isHydratingThread) return;
activeTabId.value = latestBuildResult.value.workflowId;
workflowRefreshKey.value++;
@ -171,8 +167,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
// by getLatestBuildResult. Refresh the preview so the iframe shows the latest state.
const latestSetupResult = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
const msg = store.messages[i];
for (let i = thread.messages.length - 1; i >= 0; i--) {
const msg = thread.messages[i];
if (msg.agentTree) {
const result = getLatestWorkflowSetupResult(msg.agentTree);
if (result) return result;
@ -198,8 +194,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
// --- Auto-open data table preview when AI creates/modifies a data table ---
const latestDataTableResult = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
const msg = store.messages[i];
for (let i = thread.messages.length - 1; i >= 0; i--) {
const msg = thread.messages[i];
if (msg.agentTree) {
const result = getLatestDataTableResult(msg.agentTree);
if (result) return result;
@ -212,7 +208,7 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
() => latestDataTableResult.value?.toolCallId,
(toolCallId) => {
if (!toolCallId || !latestDataTableResult.value) return;
if (store.isHydratingThread) return;
if (thread.isHydratingThread) return;
activeTabId.value = latestDataTableResult.value.dataTableId;
dataTableRefreshKey.value++;
@ -223,8 +219,8 @@ export function useCanvasPreview({ store, route }: UseCanvasPreviewOptions) {
// --- Close data table preview if the active table is deleted ---
const latestDeletedDataTableId = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
const msg = store.messages[i];
for (let i = thread.messages.length - 1; i >= 0; i--) {
const msg = thread.messages[i];
if (msg.agentTree) {
const id = getLatestDeletedDataTableId(msg.agentTree);
if (id) return id;