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 { useCreditWarningBanner } from './composables/useCreditWarningBanner';
import InstanceAiInput from './components/InstanceAiInput.vue'; import InstanceAiInput from './components/InstanceAiInput.vue';
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue'; import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
import InstanceAiStatusBar from './components/InstanceAiStatusBar.vue';
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue'; import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue'; import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
@ -69,7 +68,6 @@ function handleStop() {
<div :class="$style.emptyLayout"> <div :class="$style.emptyLayout">
<InstanceAiEmptyState /> <InstanceAiEmptyState />
<div :class="$style.centeredInput"> <div :class="$style.centeredInput">
<InstanceAiStatusBar />
<CreditWarningBanner <CreditWarningBanner
v-if="creditBanner.visible.value" v-if="creditBanner.visible.value"
:credits-remaining="store.creditsRemaining" :credits-remaining="store.creditsRemaining"
@ -80,9 +78,16 @@ function handleStop() {
<InstanceAiInput <InstanceAiInput
ref="chatInputRef" ref="chatInputRef"
:is-streaming="store.isStreaming" :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" :suggestions="INSTANCE_AI_EMPTY_STATE_SUGGESTIONS"
@submit="handleSubmit" @submit="handleSubmit"
@stop="handleStop" @stop="handleStop"
@toggle-research-mode="store.toggleResearchMode()"
/> />
</div> </div>
</div> </div>

View File

@ -10,7 +10,7 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { import {
N8nHeading, N8nHeading,
N8nIconButton, N8nIconButton,
@ -23,7 +23,7 @@ import { useI18n } from '@n8n/i18n';
import type { InstanceAiAttachment } from '@n8n/api-types'; import type { InstanceAiAttachment } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useInstanceAiStore } from './instanceAi.store'; import { provideThread, useInstanceAiStore } from './instanceAi.store';
import { useCanvasPreview } from './useCanvasPreview'; import { useCanvasPreview } from './useCanvasPreview';
import { useEventRelay } from './useEventRelay'; import { useEventRelay } from './useEventRelay';
import { useExecutionPushEvents } from './useExecutionPushEvents'; import { useExecutionPushEvents } from './useExecutionPushEvents';
@ -49,10 +49,10 @@ const props = defineProps<{
}>(); }>();
const store = useInstanceAiStore(); const store = useInstanceAiStore();
const thread = provideThread(store);
const { isLowCredits } = storeToRefs(store); const { isLowCredits } = storeToRefs(store);
const rootStore = useRootStore(); const rootStore = useRootStore();
const i18n = useI18n(); const i18n = useI18n();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const { goToUpgrade } = usePageRedirectionHelper(); const { goToUpgrade } = usePageRedirectionHelper();
const creditBanner = useCreditWarningBanner(isLowCredits); const creditBanner = useCreditWarningBanner(isLowCredits);
@ -60,12 +60,12 @@ const creditBanner = useCreditWarningBanner(isLowCredits);
// Running builders render in a dedicated bottom section of the conversation. // Running builders render in a dedicated bottom section of the conversation.
// Once a builder finishes it falls out of this list and AgentTimeline renders // Once a builder finishes it falls out of this list and AgentTimeline renders
// it in its natural chronological slot. // 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 // Assistant messages whose only content has been extracted to the bottom
// builder section (or which haven't produced anything renderable yet) would // builder section (or which haven't produced anything renderable yet) would
// otherwise leave an empty wrapper in the list filter them out. // 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 --- // --- Execution tracking via push events ---
const executionTracking = useExecutionPushEvents(); const executionTracking = useExecutionPushEvents();
@ -75,11 +75,11 @@ const executionTracking = useExecutionPushEvents();
// figuring out which thread to show. Rendering only on a defined value avoids // figuring out which thread to show. Rendering only on a defined value avoids
// the "New conversation" real title flash when resuming a recent thread. // the "New conversation" real title flash when resuming a recent thread.
const currentThreadTitle = computed<string | undefined>(() => { const currentThreadTitle = computed<string | undefined>(() => {
const thread = store.threads.find((t) => t.id === store.currentThreadId); const threadSummary = store.threads.find((t) => t.id === store.currentThreadId);
if (thread && thread.title && thread.title !== NEW_CONVERSATION_TITLE) { if (threadSummary && threadSummary.title && threadSummary.title !== NEW_CONVERSATION_TITLE) {
return thread.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) { if (firstUserMsg?.content) {
const text = firstUserMsg.content.trim(); const text = firstUserMsg.content.trim();
return text.length > 60 ? text.slice(0, 60) + '…' : text; return text.length > 60 ? text.slice(0, 60) + '…' : text;
@ -89,8 +89,8 @@ const currentThreadTitle = computed<string | undefined>(() => {
// --- Canvas / data table preview --- // --- Canvas / data table preview ---
const preview = useCanvasPreview({ const preview = useCanvasPreview({
store, thread,
route, threadId: () => props.threadId,
}); });
provide('openWorkflowPreview', preview.openWorkflowPreview); provide('openWorkflowPreview', preview.openWorkflowPreview);
@ -233,10 +233,10 @@ watch(
); );
function reconnectThreadIfHydrationApplied(threadId: string): void { function reconnectThreadIfHydrationApplied(threadId: string): void {
void store.loadHistoricalMessages(threadId).then((hydrationStatus) => { void thread.loadHistoricalMessages(threadId).then((hydrationStatus) => {
if (hydrationStatus === 'stale') return; if (hydrationStatus === 'stale') return;
void store.loadThreadStatus(threadId); void thread.loadThreadStatus(threadId);
store.connectSSE(threadId); thread.connectSSE(threadId);
}); });
} }
@ -249,7 +249,7 @@ function reconnectThreadIfHydrationApplied(threadId: string): void {
async function syncRouteToStore() { async function syncRouteToStore() {
const requestedThreadId = props.threadId; const requestedThreadId = props.threadId;
if (requestedThreadId === store.currentThreadId) { if (requestedThreadId === store.currentThreadId) {
if (store.sseState === 'disconnected') { if (thread.sseState === 'disconnected') {
reconnectThreadIfHydrationApplied(requestedThreadId); reconnectThreadIfHydrationApplied(requestedThreadId);
} }
return; return;
@ -309,11 +309,11 @@ const eventRelay = useEventRelay({
function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) { function handleSubmit(message: string, attachments?: InstanceAiAttachment[]) {
// Reset scroll on new user message // Reset scroll on new user message
userScrolledUp.value = false; userScrolledUp.value = false;
void store.sendMessage(message, attachments, rootStore.pushRef); void thread.sendMessage(message, attachments, rootStore.pushRef);
} }
function handleStop() { function handleStop() {
void store.cancelRun(); void thread.cancelRun();
} }
</script> </script>
@ -327,7 +327,7 @@ function handleStop() {
{{ currentThreadTitle }} {{ currentThreadTitle }}
</N8nHeading> </N8nHeading>
<N8nText <N8nText
v-if="store.sseState === 'reconnecting'" v-if="thread.sseState === 'reconnecting'"
size="small" size="small"
color="text-light" color="text-light"
:class="$style.reconnecting" :class="$style.reconnecting"
@ -396,7 +396,7 @@ function handleStop() {
> >
<Transition name="fade"> <Transition name="fade">
<N8nIconButton <N8nIconButton
v-if="userScrolledUp && store.hasMessages" v-if="userScrolledUp && thread.hasMessages"
variant="outline" variant="outline"
icon="arrow-down" icon="arrow-down"
:class="$style.scrollToBottomButton" :class="$style.scrollToBottomButton"
@ -421,9 +421,16 @@ function handleStop() {
/> />
<InstanceAiInput <InstanceAiInput
ref="chatInputRef" 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" @submit="handleSubmit"
@stop="handleStop" @stop="handleStop"
@toggle-research-mode="store.toggleResearchMode()"
/> />
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import type { import type {
InstanceAiConfirmation, InstanceAiConfirmation,
InstanceAiToolCallState, InstanceAiToolCallState,
@ -89,7 +89,7 @@ vi.mock('../components/PlanReviewPanel.vue', () => ({
default: { template: '<div />', props: ['plannedTasks', 'message', 'readOnly'] }, default: { template: '<div />', props: ['plannedTasks', 'message', 'readOnly'] },
})); }));
const renderComponent = createComponentRenderer(InstanceAiConfirmationPanel); const renderComponent = createThreadComponentRenderer(InstanceAiConfirmationPanel);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers

View File

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import type { InstanceAiCredentialRequest } from '@n8n/api-types'; import type { InstanceAiCredentialRequest } from '@n8n/api-types';
import InstanceAiCredentialSetup from '../components/InstanceAiCredentialSetup.vue'; import InstanceAiCredentialSetup from '../components/InstanceAiCredentialSetup.vue';
import { useInstanceAiStore } from '../instanceAi.store'; 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) */ /** Creates requests with no existing credentials (shows setup button) */
function makeCredentialRequests(count: number): InstanceAiCredentialRequest[] { function makeCredentialRequests(count: number): InstanceAiCredentialRequest[] {

View File

@ -1,41 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { fireEvent, waitFor, within } from '@testing-library/vue'; import { fireEvent, waitFor, within } from '@testing-library/vue';
import { reactive } from 'vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiInput from '../components/InstanceAiInput.vue'; import InstanceAiInput from '../components/InstanceAiInput.vue';
import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS as suggestions } from '../emptyStateSuggestions'; import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS as suggestions } from '../emptyStateSuggestions';
const toggleResearchMode = vi.fn();
const telemetryTrack = vi.fn(); const telemetryTrack = vi.fn();
const storeState = reactive({
amendContext: null as { agentId: string; role: string } | null, type InputTestProps = {
contextualSuggestion: null as string | null, isStreaming: boolean;
currentThreadId: 'thread-1', isSendingMessage: boolean;
researchMode: false, 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, 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', () => ({ vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({ track: telemetryTrack })), useTelemetry: vi.fn(() => ({ track: telemetryTrack })),
})); }));
vi.mock('../instanceAi.store', () => ({ const renderComponent = createComponentRenderer(InstanceAiInput, {
useInstanceAiStore: vi.fn(() => storeState), props: defaultProps(),
})); });
const renderComponent = createComponentRenderer(InstanceAiInput);
describe('InstanceAiInput', () => { describe('InstanceAiInput', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
telemetryTrack.mockReset(); 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', () => { 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 () => { 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({ const { getByRole } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
contextualSuggestion: 'Summarize the last workflow error for me',
}, },
}); });
@ -336,7 +347,7 @@ describe('InstanceAiInput', () => {
}); });
it('hides suggestions while a send is pending', async () => { it('hides suggestions while a send is pending', async () => {
const { queryByTestId } = renderComponent({ const { queryByTestId, rerender } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
@ -345,7 +356,7 @@ describe('InstanceAiInput', () => {
expect(queryByTestId('instance-ai-suggestion-build-workflow')).toBeInTheDocument(); expect(queryByTestId('instance-ai-suggestion-build-workflow')).toBeInTheDocument();
storeState.isSendingMessage = true; await rerender(inputProps({ suggestions, isSendingMessage: true }));
await waitFor(() => { await waitFor(() => {
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument(); 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 () => { it('toggles research mode from the composer footer button', async () => {
const { getByTestId } = renderComponent({ const { emitted, getByTestId } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
@ -362,7 +373,7 @@ describe('InstanceAiInput', () => {
await userEvent.click(getByTestId('instance-ai-research-toggle')); 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 () => { 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 () => { it('clears the ghost prompt when suggestions become hidden', async () => {
const { getByRole, getByTestId, queryByTestId } = renderComponent({ const { getByRole, getByTestId, queryByTestId, rerender } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
@ -391,7 +402,7 @@ describe('InstanceAiInput', () => {
await userEvent.hover(getByTestId('instance-ai-suggestion-build-workflow')); await userEvent.hover(getByTestId('instance-ai-suggestion-build-workflow'));
expect(textbox.getAttribute('placeholder')).not.toBe(initialPlaceholder); expect(textbox.getAttribute('placeholder')).not.toBe(initialPlaceholder);
storeState.isSendingMessage = true; await rerender(inputProps({ suggestions, isSendingMessage: true }));
await waitFor(() => { await waitFor(() => {
expect(queryByTestId('instance-ai-suggestion-build-workflow')).not.toBeInTheDocument(); 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 () => { it('tracks suggestions shown once when suggestions hide and reappear in the same thread', async () => {
storeState.currentThreadId = 'thread-shown'; const { rerender } = renderComponent({
renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
currentThreadId: 'thread-shown',
}, },
}); });
@ -417,24 +427,29 @@ describe('InstanceAiInput', () => {
}); });
}); });
storeState.isSendingMessage = true; await rerender(
inputProps({
suggestions,
currentThreadId: 'thread-shown',
isSendingMessage: true,
}),
);
await waitFor(() => { await waitFor(() => {
expect(telemetryTrack).toHaveBeenCalledTimes(1); expect(telemetryTrack).toHaveBeenCalledTimes(1);
}); });
storeState.isSendingMessage = false; await rerender(inputProps({ suggestions, currentThreadId: 'thread-shown' }));
await waitFor(() => { await waitFor(() => {
expect(telemetryTrack).toHaveBeenCalledTimes(1); expect(telemetryTrack).toHaveBeenCalledTimes(1);
}); });
}); });
it('tracks suggestions shown again when the empty-state thread changes', async () => { it('tracks suggestions shown again when the empty-state thread changes', async () => {
storeState.currentThreadId = 'thread-a'; const { rerender } = renderComponent({
renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
currentThreadId: 'thread-a',
}, },
}); });
@ -446,7 +461,7 @@ describe('InstanceAiInput', () => {
}); });
}); });
storeState.currentThreadId = 'thread-b'; await rerender(inputProps({ suggestions, currentThreadId: 'thread-b' }));
await waitFor(() => { await waitFor(() => {
expect(telemetryTrack).toHaveBeenCalledWith('Instance AI prompt suggestions shown', { 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 () => { it('includes research mode in the quick examples telemetry payload when enabled', async () => {
storeState.researchMode = true;
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
researchMode: true,
}, },
}); });
@ -538,11 +553,11 @@ describe('InstanceAiInput', () => {
}); });
it('includes the research-mode flag when tracking top-level suggestion selection telemetry', async () => { it('includes the research-mode flag when tracking top-level suggestion selection telemetry', async () => {
storeState.researchMode = true;
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
props: { props: {
isStreaming: false, isStreaming: false,
suggestions, suggestions,
researchMode: true,
}, },
}); });

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import InstanceAiMarkdown from '../components/InstanceAiMarkdown.vue'; 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( function makeRegistry(
entries: Array<{ type: string; id: string; name: string; projectId?: string }>, 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 { describe, it, expect, beforeEach, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import InstanceAiMessageComponent from '../components/InstanceAiMessage.vue'; import InstanceAiMessageComponent from '../components/InstanceAiMessage.vue';
import type { InstanceAiMessage, InstanceAiAgentNode } from '@n8n/api-types'; 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: { global: {
stubs: { stubs: {
AgentActivityTree: { AgentActivityTree: {

View File

@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import type { InstanceAiWorkflowSetupNode } from '@n8n/api-types'; import type { InstanceAiWorkflowSetupNode } from '@n8n/api-types';
import InstanceAiWorkflowSetup from '../components/InstanceAiWorkflowSetup.vue'; import InstanceAiWorkflowSetup from '../components/InstanceAiWorkflowSetup.vue';
import { useInstanceAiStore } from '../instanceAi.store'; import { useInstanceAiStore } from '../instanceAi.store';
@ -78,7 +78,7 @@ vi.mock('@/features/workflows/canvas/experimental/composables/useExpressionResol
useExpressionResolveCtx: () => ({}), useExpressionResolveCtx: () => ({}),
})); }));
const renderComponent = createComponentRenderer(InstanceAiWorkflowSetup); const renderComponent = createThreadComponentRenderer(InstanceAiWorkflowSetup);
/** Render the component and wait for the async onMounted to complete (isStoreReady = true). */ /** Render the component and wait for the async onMounted to complete (isStoreReady = true). */
async function renderAndWait( async function renderAndWait(

View File

@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render'; import { createThreadComponentRenderer } from './createThreadComponentRenderer';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import SubagentStepTimeline from '../components/SubagentStepTimeline.vue'; import SubagentStepTimeline from '../components/SubagentStepTimeline.vue';
import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types'; import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types';
const renderComponent = createComponentRenderer(SubagentStepTimeline, { const renderComponent = createThreadComponentRenderer(SubagentStepTimeline, {
global: { global: {
stubs: { stubs: {
// ToolCallStep is stubbed so we can verify which toolCall was passed // 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 executionTracking = useExecutionPushEvents();
const preview = useCanvasPreview({ const preview = useCanvasPreview({
store: store as unknown as ReturnType<typeof useInstanceAiStore>, thread: store as unknown as ReturnType<typeof useInstanceAiStore>,
route: route as Parameters<typeof useCanvasPreview>[0]['route'], threadId: () => route.params.threadId,
}); });
const relayedEvents: PushMessage[] = []; 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 messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
const isStreaming = ref(false); const isStreaming = ref(false);
const isHydratingThread = 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. // 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 entry: ResourceEntry = { type: 'workflow', id, name };
const nextProduced = new Map(store.producedArtifacts); const nextProduced = new Map(thread.producedArtifacts);
nextProduced.set(id, entry); nextProduced.set(id, entry);
store.producedArtifacts = nextProduced; thread.producedArtifacts = nextProduced;
const nextByName = new Map(store.resourceNameIndex); const nextByName = new Map(thread.resourceNameIndex);
nextByName.set(name.toLowerCase(), entry); 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 entry: ResourceEntry = { type: 'data-table', id, name, projectId };
const nextProduced = new Map(store.producedArtifacts); const nextProduced = new Map(thread.producedArtifacts);
nextProduced.set(id, entry); nextProduced.set(id, entry);
store.producedArtifacts = nextProduced; thread.producedArtifacts = nextProduced;
const nextByName = new Map(store.resourceNameIndex); const nextByName = new Map(thread.resourceNameIndex);
nextByName.set(name.toLowerCase(), entry); 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 // Test helper — create composable + flush
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function setup(options?: { storeOverrides?: Partial<MockStore> }) { function setup(options?: { threadOverrides?: Partial<MockThread> }) {
const store = createMockStore(); const thread = createMockThread();
if (options?.storeOverrides) Object.assign(store, options.storeOverrides); if (options?.threadOverrides) Object.assign(thread, options.threadOverrides);
const route = createMockRoute(); const route = createMockRoute();
const result = useCanvasPreview({ const result = useCanvasPreview({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
store: store as any, thread: thread as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any threadId: () => route.params.threadId,
route: route as any,
}); });
return { ...result, store, route }; return { ...result, thread, route };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -143,8 +147,8 @@ describe('useCanvasPreview', () => {
describe('allArtifactTabs', () => { describe('allArtifactTabs', () => {
test('derives tabs from resource registry', () => { test('derives tabs from resource registry', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1', 'My Workflow'); registerWorkflow(ctx.thread, 'wf-1', 'My Workflow');
registerDataTable(ctx.store, 'dt-1', 'My Table', 'proj-1'); registerDataTable(ctx.thread, 'dt-1', 'My Table', 'proj-1');
expect(ctx.allArtifactTabs.value).toEqual([ expect(ctx.allArtifactTabs.value).toEqual([
{ {
@ -163,7 +167,7 @@ describe('useCanvasPreview', () => {
const registry = new Map<string, ResourceEntry>(); const registry = new Map<string, ResourceEntry>();
registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' }); registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' });
registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' }); 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).toHaveLength(1);
expect(ctx.allArtifactTabs.value[0].type).toBe('workflow'); expect(ctx.allArtifactTabs.value[0].type).toBe('workflow');
@ -173,7 +177,7 @@ describe('useCanvasPreview', () => {
describe('selectTab / closePreview', () => { describe('selectTab / closePreview', () => {
test('selectTab sets activeTabId', () => { test('selectTab sets activeTabId', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.selectTab('wf-1'); ctx.selectTab('wf-1');
@ -184,7 +188,7 @@ describe('useCanvasPreview', () => {
test('closePreview clears activeTabId', () => { test('closePreview clears activeTabId', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.selectTab('wf-1'); ctx.selectTab('wf-1');
ctx.closePreview(); ctx.closePreview();
@ -197,8 +201,8 @@ describe('useCanvasPreview', () => {
describe('openWorkflowPreview', () => { describe('openWorkflowPreview', () => {
test('sets activeWorkflowId and clears data table state', () => { test('sets activeWorkflowId and clears data table state', () => {
const ctx = setup(); const ctx = setup();
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1');
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
@ -210,7 +214,7 @@ describe('useCanvasPreview', () => {
test('makes isPreviewVisible true', () => { test('makes isPreviewVisible true', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
expect(ctx.isPreviewVisible.value).toBe(false); expect(ctx.isPreviewVisible.value).toBe(false);
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
@ -221,8 +225,8 @@ describe('useCanvasPreview', () => {
describe('openDataTablePreview', () => { describe('openDataTablePreview', () => {
test('sets data table state and clears workflow state', () => { test('sets data table state and clears workflow state', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1');
@ -234,7 +238,7 @@ describe('useCanvasPreview', () => {
test('makes isPreviewVisible true', () => { test('makes isPreviewVisible true', () => {
const ctx = setup(); 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); expect(ctx.isPreviewVisible.value).toBe(false);
ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1');
@ -245,7 +249,7 @@ describe('useCanvasPreview', () => {
describe('thread switch (route.params.threadId change)', () => { describe('thread switch (route.params.threadId change)', () => {
test('resets all preview state on thread switch', async () => { test('resets all preview state on thread switch', async () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
ctx.route.params.threadId = 'thread-2'; 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 () => { test('clears the preview on thread switch, then stays closed while the new thread hydrates', async () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
expect(ctx.isPreviewVisible.value).toBe(true); expect(ctx.isPreviewVisible.value).toBe(true);
@ -271,9 +275,9 @@ describe('useCanvasPreview', () => {
// Past artifacts surfacing during the new thread's hydration shouldn't // Past artifacts surfacing during the new thread's hydration shouldn't
// pop the panel — historical data, not a live build. // pop the panel — historical data, not a live build.
ctx.store.isHydratingThread = true; ctx.thread.isHydratingThread = true;
registerWorkflow(ctx.store, 'wf-historical'); registerWorkflow(ctx.thread, 'wf-historical');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -295,10 +299,10 @@ describe('useCanvasPreview', () => {
describe('auto-open on build result', () => { describe('auto-open on build result', () => {
test('auto-opens canvas when streaming and build result appears', async () => { test('auto-opens canvas when streaming and build result appears', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
registerWorkflow(ctx.store, 'wf-new'); registerWorkflow(ctx.thread, 'wf-new');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -321,9 +325,9 @@ describe('useCanvasPreview', () => {
const ctx = setup(); const ctx = setup();
// Simulate the loadHistoricalMessages window: artifacts that surface // Simulate the loadHistoricalMessages window: artifacts that surface
// as part of past data shouldn't pop the preview panel. // 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({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -344,12 +348,12 @@ describe('useCanvasPreview', () => {
test('switches to latest artifact when a new workflow is built while viewing different artifact', async () => { test('switches to latest artifact when a new workflow is built while viewing different artifact', async () => {
const ctx = setup(); const ctx = setup();
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1');
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -371,11 +375,11 @@ describe('useCanvasPreview', () => {
test('increments workflowRefreshKey on each build', async () => { test('increments workflowRefreshKey on each build', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
const initialKey = ctx.workflowRefreshKey.value; const initialKey = ctx.workflowRefreshKey.value;
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -397,10 +401,10 @@ describe('useCanvasPreview', () => {
describe('auto-open data table preview', () => { describe('auto-open data table preview', () => {
test('auto-opens data table preview when streaming', async () => { test('auto-opens data table preview when streaming', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
registerDataTable(ctx.store, 'dt-1', 'Test Table'); registerDataTable(ctx.thread, 'dt-1', 'Test Table');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -422,9 +426,9 @@ describe('useCanvasPreview', () => {
test('does not auto-open data table preview while hydrating', async () => { test('does not auto-open data table preview while hydrating', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isHydratingThread = true; ctx.thread.isHydratingThread = true;
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -445,10 +449,10 @@ describe('useCanvasPreview', () => {
test('looks up projectId from producedArtifacts', async () => { test('looks up projectId from producedArtifacts', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
registerDataTable(ctx.store, 'dt-1', 'Test Table', 'proj-42'); registerDataTable(ctx.thread, 'dt-1', 'Test Table', 'proj-42');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -469,10 +473,10 @@ describe('useCanvasPreview', () => {
test('increments dataTableRefreshKey on each data table update', async () => { test('increments dataTableRefreshKey on each data table update', async () => {
const ctx = setup(); const ctx = setup();
ctx.store.isStreaming = true; ctx.thread.isStreaming = true;
const initialKey = ctx.dataTableRefreshKey.value; const initialKey = ctx.dataTableRefreshKey.value;
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -495,11 +499,11 @@ describe('useCanvasPreview', () => {
describe('close data table on delete', () => { describe('close data table on delete', () => {
test('closes data table preview when active table is deleted', async () => { test('closes data table preview when active table is deleted', async () => {
const ctx = setup(); 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'); ctx.openDataTablePreview('dt-1', 'proj-1');
expect(ctx.activeDataTableId.value).toBe('dt-1'); expect(ctx.activeDataTableId.value).toBe('dt-1');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -519,10 +523,10 @@ describe('useCanvasPreview', () => {
test('does not close preview when a different table is deleted', async () => { test('does not close preview when a different table is deleted', async () => {
const ctx = setup(); 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.openDataTablePreview('dt-1', 'proj-1');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -542,11 +546,11 @@ describe('useCanvasPreview', () => {
test('falls back to first remaining tab when active table is deleted', async () => { test('falls back to first remaining tab when active table is deleted', async () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
registerDataTable(ctx.store, 'dt-1', 'Table', 'proj-1'); registerDataTable(ctx.thread, 'dt-1', 'Table', 'proj-1');
ctx.openDataTablePreview('dt-1', 'proj-1'); ctx.openDataTablePreview('dt-1', 'proj-1');
ctx.store.messages = [ ctx.thread.messages = [
makeMessage({ makeMessage({
agentTree: makeAgentNode({ agentTree: makeAgentNode({
toolCalls: [ toolCalls: [
@ -570,14 +574,14 @@ describe('useCanvasPreview', () => {
describe('isPreviewVisible', () => { describe('isPreviewVisible', () => {
test('is true when workflow is active', () => { test('is true when workflow is active', () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.openWorkflowPreview('wf-1'); ctx.openWorkflowPreview('wf-1');
expect(ctx.isPreviewVisible.value).toBe(true); expect(ctx.isPreviewVisible.value).toBe(true);
}); });
test('is true when data table is active', () => { test('is true when data table is active', () => {
const ctx = setup(); 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'); ctx.openDataTablePreview('dt-1', 'proj-1');
expect(ctx.isPreviewVisible.value).toBe(true); expect(ctx.isPreviewVisible.value).toBe(true);
}); });
@ -591,8 +595,8 @@ describe('useCanvasPreview', () => {
describe('tab guard', () => { describe('tab guard', () => {
test('falls back to first tab when active tab is removed from registry', async () => { test('falls back to first tab when active tab is removed from registry', async () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
registerWorkflow(ctx.store, 'wf-2', 'Second Workflow'); registerWorkflow(ctx.thread, 'wf-2', 'Second Workflow');
ctx.selectTab('wf-2'); ctx.selectTab('wf-2');
await nextTick(); await nextTick();
@ -601,7 +605,7 @@ describe('useCanvasPreview', () => {
// Remove wf-2 from registry, keeping wf-1 // Remove wf-2 from registry, keeping wf-1
const next = new Map<string, ResourceEntry>(); const next = new Map<string, ResourceEntry>();
next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' }); next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' });
ctx.store.producedArtifacts = next; ctx.thread.producedArtifacts = next;
await nextTick(); await nextTick();
expect(ctx.activeTabId.value).toBe('wf-1'); 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 () => { test('does not clear activeTabId when registry is empty (race condition)', async () => {
const ctx = setup(); const ctx = setup();
registerWorkflow(ctx.store, 'wf-1'); registerWorkflow(ctx.thread, 'wf-1');
ctx.selectTab('wf-1'); ctx.selectTab('wf-1');
await nextTick(); await nextTick();
// Temporarily empty registry (simulates race where registry hasn't been populated yet) // Temporarily empty registry (simulates race where registry hasn't been populated yet)
ctx.store.producedArtifacts = new Map(); ctx.thread.producedArtifacts = new Map();
await nextTick(); await nextTick();
// Tab should remain set — guard skips when tabs are empty // 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 AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, toRef, useTemplateRef } from 'vue'; import { computed, toRef, useTemplateRef } from 'vue';
import type { ArtifactInfo } from '../agentTimeline.utils'; import type { ArtifactInfo } from '../agentTimeline.utils';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import { useTimelineGrouping } from '../useTimelineGrouping'; import { useTimelineGrouping } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue'; import AgentTimeline from './AgentTimeline.vue';
import ArtifactCard from './ArtifactCard.vue'; import ArtifactCard from './ArtifactCard.vue';
@ -25,7 +25,7 @@ const props = withDefaults(
); );
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const hasReasoning = computed(() => props.agentNode.reasoning.length > 0); const hasReasoning = computed(() => props.agentNode.reasoning.length > 0);
const triggerRef = useTemplateRef<HTMLElement>('triggerRef'); const triggerRef = useTemplateRef<HTMLElement>('triggerRef');
@ -46,7 +46,7 @@ const lastGroupIdx = computed(() => {
}); });
function resolveArtifactName(artifact: ArtifactInfo): string { function resolveArtifactName(artifact: ArtifactInfo): string {
const entry = store.producedArtifacts.get(artifact.resourceId); const entry = thread.producedArtifacts.get(artifact.resourceId);
return entry?.name ?? artifact.name; return entry?.name ?? artifact.name;
} }
</script> </script>
@ -91,7 +91,7 @@ function resolveArtifactName(artifact: ArtifactInfo): string {
:name="resolveArtifactName(artifact)" :name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId" :resource-id="artifact.resourceId"
:project-id="artifact.projectId" :project-id="artifact.projectId"
:archived="store.producedArtifacts.get(artifact.resourceId)?.archived" :archived="thread.producedArtifacts.get(artifact.resourceId)?.archived"
:class="$style.artifactCard" :class="$style.artifactCard"
/> />
</template> </template>

View File

@ -11,7 +11,7 @@ import { computed } from 'vue';
import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils'; import { extractArtifacts, HIDDEN_TOOLS, type ArtifactInfo } from '../agentTimeline.utils';
import { useTelemetry } from '@/app/composables/useTelemetry'; import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import { isActiveBuilderAgent } from '../builderAgents'; import { isActiveBuilderAgent } from '../builderAgents';
import AgentSection from './AgentSection.vue'; import AgentSection from './AgentSection.vue';
import AnsweredQuestions from './AnsweredQuestions.vue'; import AnsweredQuestions from './AnsweredQuestions.vue';
@ -23,13 +23,13 @@ import TaskChecklist from './TaskChecklist.vue';
import ToolCallStep from './ToolCallStep.vue'; import ToolCallStep from './ToolCallStep.vue';
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const rootStore = useRootStore(); const rootStore = useRootStore();
/** Resolve artifact name from the enriched registry (falls back to extracted name). */ /** Resolve artifact name from the enriched registry (falls back to extracted name). */
function resolveArtifactName(artifact: ArtifactInfo): string { function resolveArtifactName(artifact: ArtifactInfo): string {
const entry = store.producedArtifacts.get(artifact.resourceId); const entry = thread.producedArtifacts.get(artifact.resourceId);
return entry?.name ?? artifact.name; 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 numTasks = ((tc.args?.tasks as PlannedTaskArg[] | undefined) ?? []).length;
const eventProps = { const eventProps = {
thread_id: store.currentThreadId, thread_id: thread.currentThreadId,
input_thread_id: tc.confirmation?.inputThreadId ?? '', input_thread_id: tc.confirmation?.inputThreadId ?? '',
instance_id: rootStore.instanceId, instance_id: rootStore.instanceId,
type: 'plan-review', type: 'plan-review',
@ -137,8 +137,8 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb
}; };
telemetry.track('User finished providing input', eventProps); telemetry.track('User finished providing input', eventProps);
store.resolveConfirmation(requestId, approved ? 'approved' : 'denied'); thread.resolveConfirmation(requestId, approved ? 'approved' : 'denied');
void store.confirmAction(requestId, { void thread.confirmAction(requestId, {
kind: 'approval', kind: 'approval',
approved, approved,
...(feedback ? { userInput: feedback } : {}), ...(feedback ? { userInput: feedback } : {}),
@ -293,7 +293,7 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
:name="resolveArtifactName(artifact)" :name="resolveArtifactName(artifact)"
:resource-id="artifact.resourceId" :resource-id="artifact.resourceId"
:project-id="artifact.projectId" :project-id="artifact.projectId"
:archived="store.producedArtifacts.get(artifact.resourceId)?.archived" :archived="thread.producedArtifacts.get(artifact.resourceId)?.archived"
:metadata="formatArtifactMetadata(artifact)" :metadata="formatArtifactMetadata(artifact)"
/> />
</template> </template>

View File

@ -3,7 +3,7 @@ import { N8nButton, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types'; import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationFooter from './ConfirmationFooter.vue';
import ConfirmationPreview from './ConfirmationPreview.vue'; import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue'; import SplitButton from './SplitButton.vue';
@ -29,7 +29,7 @@ interface WebSearchProps {
const props = defineProps<DomainProps | WebSearchProps>(); const props = defineProps<DomainProps | WebSearchProps>();
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const resolved = ref(false); const resolved = ref(false);
const isWebSearch = computed(() => props.query !== undefined); const isWebSearch = computed(() => props.query !== undefined);
@ -63,8 +63,8 @@ const dropdownItems = computed<Array<ActionDropdownItem<DomainAction>>>(() => [
function handleAction(approved: boolean, domainAccessAction?: DomainAction) { function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
resolved.value = true; resolved.value = true;
store.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied'); thread.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied');
void store.confirmAction( void thread.confirmAction(
props.requestId, props.requestId,
approved && domainAccessAction approved && domainAccessAction
? { kind: 'domainAccessApprove', domainAccessAction } ? { kind: 'domainAccessApprove', domainAccessAction }

View File

@ -6,7 +6,7 @@ import { computed } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry'; import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationFooter from './ConfirmationFooter.vue';
import ConfirmationPreview from './ConfirmationPreview.vue'; import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue'; import SplitButton from './SplitButton.vue';
@ -35,7 +35,7 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const rootStore = useRootStore(); const rootStore = useRootStore();
const store = useInstanceAiStore(); const thread = useThread();
interface OptionEntry { interface OptionEntry {
decision: InstanceGatewayResourceDecision; decision: InstanceGatewayResourceDecision;
@ -72,10 +72,10 @@ const approveDropdownItems = computed(() => {
}); });
async function confirm(decision: InstanceGatewayResourceDecision) { async function confirm(decision: InstanceGatewayResourceDecision) {
const tc = store.findToolCallByRequestId(props.requestId); const tc = thread.findToolCallByRequestId(props.requestId);
const inputThreadId = tc?.confirmation?.inputThreadId ?? ''; const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
const eventProps = { const eventProps = {
thread_id: store.currentThreadId, thread_id: thread.currentThreadId,
input_thread_id: inputThreadId, input_thread_id: inputThreadId,
instance_id: rootStore.instanceId, instance_id: rootStore.instanceId,
type: 'resource-decision', type: 'resource-decision',
@ -83,7 +83,7 @@ async function confirm(decision: InstanceGatewayResourceDecision) {
skipped_inputs: [], skipped_inputs: [],
}; };
telemetry.track('User finished providing input', eventProps); telemetry.track('User finished providing input', eventProps);
await store.confirmResourceDecision(props.requestId, decision); await thread.confirmResourceDecision(props.requestId, decision);
} }
</script> </script>

View File

@ -2,14 +2,14 @@
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import { N8nHeading, N8nIcon } from '@n8n/design-system'; import { N8nHeading, N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import type { TaskItem } from '@n8n/api-types'; import type { TaskItem } from '@n8n/api-types';
import type { IconName } from '@n8n/design-system'; import type { IconName } from '@n8n/design-system';
import type { ResourceEntry } from '../useResourceRegistry'; import type { ResourceEntry } from '../useResourceRegistry';
import ConnectionsCard from './ConnectionsCard.vue'; import ConnectionsCard from './ConnectionsCard.vue';
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined); const openPreview = inject<((id: string) => void) | undefined>('openWorkflowPreview', undefined);
const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>( const openDataTablePreview = inject<((id: string, projectId: string) => void) | undefined>(
'openDataTablePreview', 'openDataTablePreview',
@ -35,7 +35,7 @@ function handleArtifactClick(artifact: ResourceEntry, e: MouseEvent) {
} }
// --- Tasks --- // --- Tasks ---
const tasks = computed(() => store.currentTasks); const tasks = computed(() => thread.currentTasks);
const doneCount = computed(() => { const doneCount = computed(() => {
if (!tasks.value) return 0; if (!tasks.value) return 0;
@ -56,7 +56,7 @@ const statusIconMap: Record<
// --- Artifacts --- // --- Artifacts ---
const artifacts = computed((): ResourceEntry[] => { const artifacts = computed((): ResourceEntry[] => {
const result: 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') { if (entry.type === 'workflow' || entry.type === 'data-table') {
result.push(entry); result.push(entry);
} }

View File

@ -5,7 +5,7 @@ import type { InstanceAiConfirmation } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry'; 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 { useToolLabel } from '../toolLabels';
import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationFooter from './ConfirmationFooter.vue';
import DomainAccessApproval from './DomainAccessApproval.vue'; import DomainAccessApproval from './DomainAccessApproval.vue';
@ -17,7 +17,7 @@ import InstanceAiWorkflowSetup from './InstanceAiWorkflowSetup.vue';
import ConfirmationPreview from './ConfirmationPreview.vue'; import ConfirmationPreview from './ConfirmationPreview.vue';
import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue'; import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue';
const store = useInstanceAiStore(); const thread = useThread();
const i18n = useI18n(); const i18n = useI18n();
const rootStore = useRootStore(); const rootStore = useRootStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@ -48,7 +48,7 @@ function trackInputCompleted(
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
): void { ): void {
const eventProps = { const eventProps = {
thread_id: store.currentThreadId, thread_id: thread.currentThreadId,
input_thread_id: conf.inputThreadId ?? '', input_thread_id: conf.inputThreadId ?? '',
instance_id: rootStore.instanceId, instance_id: rootStore.instanceId,
type: getConfirmationType(conf), type: getConfirmationType(conf),
@ -108,7 +108,7 @@ const chunks = computed((): ConfirmationChunk[] => {
const result: ConfirmationChunk[] = []; const result: ConfirmationChunk[] = [];
const wrappedByAgent = new Map<string, ApprovalWrappedGroup>(); const wrappedByAgent = new Map<string, ApprovalWrappedGroup>();
for (const item of store.pendingConfirmations) { for (const item of thread.pendingConfirmations) {
if (isApprovalWrapped(item)) { if (isApprovalWrapped(item)) {
const key = item.agentNode.agentId; const key = item.agentNode.agentId;
let group = wrappedByAgent.get(key); let group = wrappedByAgent.get(key);
@ -134,7 +134,7 @@ const textInputValues = ref<Record<string, string>>({});
function handleConfirm(item: PendingConfirmationItem, approved: boolean) { function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
const conf = item.toolCall.confirmation; const conf = item.toolCall.confirmation;
if (store.resolvedConfirmationIds.has(conf.requestId)) return; if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
trackInputCompleted( trackInputCompleted(
conf, conf,
[ [
@ -146,21 +146,21 @@ function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
], ],
[], [],
); );
store.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied'); thread.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
void store.confirmAction(conf.requestId, { kind: 'approval', approved }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved });
} }
function handleApproveAll(items: PendingConfirmationItem[]) { function handleApproveAll(items: PendingConfirmationItem[]) {
for (const item of items) { for (const item of items) {
const conf = item.toolCall.confirmation; const conf = item.toolCall.confirmation;
if (store.resolvedConfirmationIds.has(conf.requestId)) continue; if (thread.resolvedConfirmationIds.has(conf.requestId)) continue;
trackInputCompleted( trackInputCompleted(
conf, conf,
[{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }], [{ label: conf.message, options: ['approve', 'deny'], option_chosen: 'approve' }],
[], [],
); );
store.resolveConfirmation(conf.requestId, 'approved'); thread.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
} }
} }
@ -180,8 +180,8 @@ function handleTextSubmit(conf: InstanceAiConfirmation) {
], ],
[], [],
); );
store.resolveConfirmation(conf.requestId, 'approved'); thread.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value });
} }
function handleTextSkip(conf: InstanceAiConfirmation) { function handleTextSkip(conf: InstanceAiConfirmation) {
@ -190,19 +190,19 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
[], [],
[{ label: conf.message, question: conf.message, input_type: 'text', options: [] }], [{ label: conf.message, question: conf.message, input_type: 'text', options: [] }],
); );
store.resolveConfirmation(conf.requestId, 'deferred'); thread.resolveConfirmation(conf.requestId, 'deferred');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved: false });
} }
function handleContinue(conf: InstanceAiConfirmation) { function handleContinue(conf: InstanceAiConfirmation) {
if (store.resolvedConfirmationIds.has(conf.requestId)) return; if (thread.resolvedConfirmationIds.has(conf.requestId)) return;
trackInputCompleted( trackInputCompleted(
conf, conf,
[{ label: conf.message, options: ['continue'], option_chosen: 'continue' }], [{ label: conf.message, options: ['continue'], option_chosen: 'continue' }],
[], [],
); );
store.resolveConfirmation(conf.requestId, 'approved'); thread.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
} }
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) { function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
@ -243,8 +243,8 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
} }
} }
trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length }); trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length });
store.resolveConfirmation(conf.requestId, 'approved'); thread.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'questions', answers }); void thread.confirmAction(conf.requestId, { kind: 'questions', answers });
} }
function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) { function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
@ -254,8 +254,8 @@ function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
[], [],
{ num_tasks: numTasks }, { num_tasks: numTasks },
); );
store.resolveConfirmation(conf.requestId, 'approved'); thread.resolveConfirmation(conf.requestId, 'approved');
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true }); void thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
} }
function handlePlanRequestChanges( function handlePlanRequestChanges(
@ -269,8 +269,8 @@ function handlePlanRequestChanges(
[], [],
{ num_tasks: numTasks, feedback }, { num_tasks: numTasks, feedback },
); );
store.resolveConfirmation(conf.requestId, 'denied'); thread.resolveConfirmation(conf.requestId, 'denied');
void store.confirmAction(conf.requestId, { void thread.confirmAction(conf.requestId, {
kind: 'approval', kind: 'approval',
approved: false, approved: false,
userInput: feedback, userInput: feedback,

View File

@ -12,7 +12,7 @@ import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry'; import { useTelemetry } from '@/app/composables/useTelemetry';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue'; import ConfirmationFooter from './ConfirmationFooter.vue';
const props = defineProps<{ const props = defineProps<{
@ -26,7 +26,7 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const rootStore = useRootStore(); const rootStore = useRootStore();
const store = useInstanceAiStore(); const thread = useThread();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
@ -246,7 +246,7 @@ function onCredentialSelected(
} }
function trackCredentialInput() { function trackCredentialInput() {
const tc = store.findToolCallByRequestId(props.requestId); const tc = thread.findToolCallByRequestId(props.requestId);
const inputThreadId = tc?.confirmation?.inputThreadId ?? ''; const inputThreadId = tc?.confirmation?.inputThreadId ?? '';
const provided: Array<{ label: string; options: string[]; option_chosen: string }> = []; const provided: Array<{ label: string; options: string[]; option_chosen: string }> = [];
const skipped: Array<{ label: string; options: string[] }> = []; const skipped: Array<{ label: string; options: string[] }> = [];
@ -259,7 +259,7 @@ function trackCredentialInput() {
} }
} }
telemetry.track('User finished providing input', { telemetry.track('User finished providing input', {
thread_id: store.currentThreadId, thread_id: thread.currentThreadId,
input_thread_id: inputThreadId, input_thread_id: inputThreadId,
instance_id: rootStore.instanceId, instance_id: rootStore.instanceId,
type: 'credential-setup', type: 'credential-setup',
@ -279,12 +279,12 @@ async function handleContinue() {
isSubmitted.value = true; isSubmitted.value = true;
const success = await store.confirmAction(props.requestId, { const success = await thread.confirmAction(props.requestId, {
kind: 'credentialSelection', kind: 'credentialSelection',
credentials, credentials,
}); });
if (success) { if (success) {
store.resolveConfirmation(props.requestId, 'approved'); thread.resolveConfirmation(props.requestId, 'approved');
} else { } else {
isSubmitted.value = false; isSubmitted.value = false;
} }
@ -296,12 +296,12 @@ async function handleLater() {
isSubmitted.value = true; isSubmitted.value = true;
isDeferred.value = true; isDeferred.value = true;
const success = await store.confirmAction(props.requestId, { const success = await thread.confirmAction(props.requestId, {
kind: 'approval', kind: 'approval',
approved: false, approved: false,
}); });
if (success) { if (success) {
store.resolveConfirmation(props.requestId, 'deferred'); thread.resolveConfirmation(props.requestId, 'deferred');
} else { } else {
isSubmitted.value = false; isSubmitted.value = false;
isDeferred.value = false; isDeferred.value = false;

View File

@ -2,12 +2,13 @@
import { N8nIcon, N8nIconButton } from '@n8n/design-system'; import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'; 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'; import { useInstanceAiDebugStore } from '../instanceAiDebug.store';
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const store = useInstanceAiStore();
const thread = useThread();
const debugStore = useInstanceAiDebugStore(); const debugStore = useInstanceAiDebugStore();
// --- Tab state --- // --- Tab state ---
@ -17,7 +18,7 @@ const activeTab = ref<Tab>('events');
// --- Events tab state --- // --- Events tab state ---
const expandedIndex = ref<number | null>(null); const expandedIndex = ref<number | null>(null);
const eventListRef = useTemplateRef<HTMLElement>('eventList'); const eventListRef = useTemplateRef<HTMLElement>('eventList');
const events = computed(() => store.debugEvents); const events = computed(() => thread.debugEvents);
// --- Threads tab state --- // --- Threads tab state ---
const expandedMessageIndex = ref<number | null>(null); const expandedMessageIndex = ref<number | null>(null);
@ -81,7 +82,7 @@ function contentPreview(content: unknown): string {
} }
async function handleCopyTrace() { async function handleCopyTrace() {
const trace = store.copyFullTrace(); const trace = thread.copyFullTrace();
await navigator.clipboard.writeText(trace); await navigator.clipboard.writeText(trace);
} }
@ -116,7 +117,7 @@ function handleRefreshThreads() {
// Tool call timing summary // Tool call timing summary
const toolCallTimings = computed(() => { const toolCallTimings = computed(() => {
const timings: Array<{ name: string; duration: string; toolCallId: string }> = []; 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; if (!msg.agentTree) continue;
const nodes = [msg.agentTree, ...msg.agentTree.children]; const nodes = [msg.agentTree, ...msg.agentTree.children];
for (const node of nodes) { for (const node of nodes) {
@ -177,8 +178,8 @@ onMounted(() => {
<template v-if="activeTab === 'events'"> <template v-if="activeTab === 'events'">
<!-- Connection status --> <!-- Connection status -->
<div :class="$style.statusBar"> <div :class="$style.statusBar">
<span :class="$style.statusDot" :data-state="store.sseState" /> <span :class="$style.statusDot" :data-state="thread.sseState" />
<span>SSE: {{ store.sseState }}</span> <span>SSE: {{ thread.sseState }}</span>
<span :class="$style.eventCount">{{ events.length }} events</span> <span :class="$style.eventCount">{{ events.length }} events</span>
</div> </div>

View File

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

View File

@ -2,13 +2,13 @@
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue'; import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
import type { ComponentPublicInstance } from 'vue'; import type { ComponentPublicInstance } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, useCssModule } 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<{ const props = defineProps<{
content: string; content: string;
}>(); }>();
const store = useInstanceAiStore(); const thread = useThread();
const styles = useCssModule(); const styles = useCssModule();
const wrapperRef = ref<ComponentPublicInstance | null>(null); 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; /<(?: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 processedContent = computed(() => {
const registry = store.resourceNameIndex; const registry = thread.resourceNameIndex;
// Strip internal protocol blocks the LLM may have echoed // Strip internal protocol blocks the LLM may have echoed
let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim(); 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 // Search the name index because it contains both produced and listed
// resources a user may click through to a resource the agent // resources a user may click through to a resource the agent
// only referenced via a list call. // 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, (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 { N8nCallout, N8nIconButton, N8nMessageRating, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store'; import { useInstanceAiStore, useThread } from '../instanceAi.store';
import AgentActivityTree from './AgentActivityTree.vue'; import AgentActivityTree from './AgentActivityTree.vue';
import AttachmentPreview from './AttachmentPreview.vue'; import AttachmentPreview from './AttachmentPreview.vue';
import InstanceAiMarkdown from './InstanceAiMarkdown.vue'; import InstanceAiMarkdown from './InstanceAiMarkdown.vue';
@ -15,6 +15,7 @@ const props = defineProps<{
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const store = useInstanceAiStore();
const thread = useThread();
const showDebugInfo = ref(false); const showDebugInfo = ref(false);
const isUser = computed(() => props.message.role === 'user'); const isUser = computed(() => props.message.role === 'user');
@ -66,16 +67,16 @@ const responseId = computed(() => props.message.messageGroupId ?? props.message.
const isRateable = computed( const isRateable = computed(
() => () =>
!isUser.value && !isUser.value &&
store.rateableResponseId === responseId.value && thread.rateableResponseId === responseId.value &&
!(responseId.value in store.feedbackByResponseId), !(responseId.value in thread.feedbackByResponseId),
); );
const hasSubmittedFeedback = computed( const hasSubmittedFeedback = computed(
() => !isUser.value && responseId.value in store.feedbackByResponseId, () => !isUser.value && responseId.value in thread.feedbackByResponseId,
); );
function onFeedback(payload: RatingFeedback) { function onFeedback(payload: RatingFeedback) {
store.submitFeedback(responseId.value, payload); thread.submitFeedback(responseId.value, payload);
} }
function formatJson(value: unknown): string { function formatJson(value: unknown): string {

View File

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

View File

@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'; import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue'; import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels'; import { useToolLabel } from '../toolLabels';
import ToolResultJson from './ToolResultJson.vue'; import ToolResultJson from './ToolResultJson.vue';
import ToolResultRenderer from './ToolResultRenderer.vue'; import ToolResultRenderer from './ToolResultRenderer.vue';
@ -15,7 +15,7 @@ const props = defineProps<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const { getToolLabel } = useToolLabel(); const { getToolLabel } = useToolLabel();
const isOpen = ref(false); const isOpen = ref(false);
@ -37,14 +37,14 @@ const showConfirmation = computed(
props.toolCall.isLoading && props.toolCall.isLoading &&
props.toolCall.confirmationStatus !== 'approved' && props.toolCall.confirmationStatus !== 'approved' &&
props.toolCall.confirmationStatus !== 'denied' && 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. */ /** Resolved confirmation action — from backend or local optimistic state. */
const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null => { const resolvedAction = computed((): 'approved' | 'denied' | 'deferred' | null => {
// Local optimistic state takes priority (has richer semantics like 'deferred') // Local optimistic state takes priority (has richer semantics like 'deferred')
const rid = props.toolCall.confirmation?.requestId; 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; if (local) return local;
const status = props.toolCall.confirmationStatus; const status = props.toolCall.confirmationStatus;
if (status === 'approved' || status === 'denied') return status; 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 { useRootStore } from '@n8n/stores/useRootStore';
import { NodeHelpers } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow';
import { computed, defineComponent, onMounted, onUnmounted, provide, ref, toRef, watch } from 'vue'; import { computed, defineComponent, onMounted, onUnmounted, provide, ref, toRef, watch } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store'; import { useThread } from '../instanceAi.store';
import { import {
credGroupKey, credGroupKey,
isTriggerOnly as isTriggerOnlyUtil, isTriggerOnly as isTriggerOnlyUtil,
@ -52,7 +52,7 @@ const props = defineProps<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const store = useInstanceAiStore(); const thread = useThread();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
@ -171,7 +171,7 @@ const {
onCredentialSelected, onCredentialSelected,
} = useSetupActions({ } = useSetupActions({
requestId: toRef(props, 'requestId'), requestId: toRef(props, 'requestId'),
store, thread,
cards, cards,
currentDisplayCard, currentDisplayCard,
displayCards, displayCards,

View File

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

View File

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