fix(editor): Fix blank workflow previews in AI Assistant (backport to release-candidate/2.40.x) (#38914)

Co-authored-by: Romeo Balta <7095569+romeobalta@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
n8n-assistant[bot] 2026-09-17 08:54:45 +00:00 committed by GitHub
parent a8e0e7c384
commit 2049d15dbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 44 additions and 2 deletions

View File

@ -1,4 +1,5 @@
import { setActivePinia, createPinia } from 'pinia';
import { effectScope, nextTick } from 'vue';
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureThread } from '../instanceAi.api';
import {
@ -191,6 +192,28 @@ describe('useInstanceAiStore - runtime registry', () => {
expect(store.getRuntime('thread-1')).toBeUndefined();
});
it('keeps the runtime watchers alive after the creating scope stops', async () => {
const store = useInstanceAiStore();
// The thread view creates the runtime in setup. A Suspense duplicate of the
// view is discarded before it mounts, which stops that component scope.
const creatorScope = effectScope();
const runtime = creatorScope.run(() => store.getOrCreateRuntime('thread-1'));
creatorScope.stop();
runtime?.messages.push({
id: 'm1',
role: 'user',
createdAt: '2026-01-01T00:00:00.000Z',
content: '',
reasoning: '',
isStreaming: false,
attachments: [{ type: 'workflow', id: 'wf-1', name: 'My workflow' }],
});
await nextTick();
expect(runtime?.producedArtifacts.get('wf-1')?.name).toBe('My workflow');
});
it('syncs a thread into the sidebar list', async () => {
const store = useInstanceAiStore();
mockEnsureThread.mockResolvedValueOnce({

View File

@ -1,5 +1,14 @@
import { defineStore } from 'pinia';
import { ref, computed, inject, provide, shallowReactive, type InjectionKey } from 'vue';
import {
ref,
computed,
effectScope,
inject,
provide,
shallowReactive,
type EffectScope,
type InjectionKey,
} from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@n8n/composables/useToast';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -66,6 +75,11 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
// --- Thread runtimes ---
const runtimes = shallowReactive(new Map<string, ThreadRuntime>());
// Detached scopes owning each runtime's watchers. The runtime must outlive the
// component that created it: a Suspense duplicate of the thread view can create
// it in setup and be discarded, and a component scope would take the watchers
// (e.g. the resource registry) down with it.
const runtimeScopes = new Map<string, EffectScope>();
const runtimeHooks = {
onTitleUpdated: (threadId, title) => {
for (const thread of localThreadEntries(threadId)) thread.title = title;
@ -81,8 +95,11 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
const existingRuntime = runtimes.get(threadId);
if (existingRuntime) return existingRuntime;
const runtime = createThreadRuntime(threadId, runtimeHooks, projectId);
const scope = effectScope(true);
const runtime = scope.run(() => createThreadRuntime(threadId, runtimeHooks, projectId));
if (!runtime) throw new Error('Failed to create thread runtime');
runtimes.set(threadId, runtime);
runtimeScopes.set(threadId, scope);
return runtime;
}
@ -95,6 +112,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
if (!runtime) return;
runtime.dispose();
runtimeScopes.get(threadId)?.stop();
runtimeScopes.delete(threadId);
runtimes.delete(threadId);
}