From 1bc6f70cd82fdfecfc65a64c2d6ca5c2563d984c Mon Sep 17 00:00:00 2001 From: t8y2 Date: Fri, 25 Sep 2026 01:40:36 +0800 Subject: [PATCH] fix(ai): initialize conversation bindings before default action selection --- .../src/components/editor/AiAssistant.vue | 32 +++++---- .../__tests__/AiAssistant.mount.spec.ts | 67 +++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/AiAssistant.mount.spec.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 1249077025..b4eafa5f99 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -327,19 +327,6 @@ let defaultModeInitialized = false; let initialConversationStateLoaded = false; let initialConversationRestored = false; let assistantViewMounted = false; -watch( - () => settings.isAiConfigLoaded, - (loaded) => { - if (loaded && !defaultModeInitialized) { - assistantMode.value = settings.defaultAiMode; - // Same apply-once rule as the mode: later setting changes must not - // disturb an active conversation. - activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode); - defaultModeInitialized = true; - } - }, - { immediate: true }, -); const currentSessionId = ref(""); const conversationId = ref(""); const conversations = ref([]); @@ -368,6 +355,25 @@ const boundConnection = computed(() => (boundConnectionId.value ? connectionStor const boundDatabase = computed(() => conversationBinding.value.database); const boundSchema = computed(() => conversationBinding.value.schema); +// `immediate` runs this during setup whenever the AI config finished loading +// before the panel mounted (the usual case: the app loads it at startup), and +// `resolveDefaultActionSelection` reads `boundConnection`. It must therefore +// stay below the binding computeds, or setup throws a TDZ ReferenceError and +// the panel never mounts. +watch( + () => settings.isAiConfigLoaded, + (loaded) => { + if (loaded && !defaultModeInitialized) { + assistantMode.value = settings.defaultAiMode; + // Same apply-once rule as the mode: later setting changes must not + // disturb an active conversation. + activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode); + defaultModeInitialized = true; + } + }, + { immediate: true }, +); + /** * Binding the visible conversation's *active run* is executing against. * diff --git a/apps/desktop/src/components/editor/__tests__/AiAssistant.mount.spec.ts b/apps/desktop/src/components/editor/__tests__/AiAssistant.mount.spec.ts new file mode 100644 index 0000000000..f462448d8a --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/AiAssistant.mount.spec.ts @@ -0,0 +1,67 @@ +// @vitest-environment happy-dom +// +// Mounted smoke test for the AI panel. The app loads the AI config at startup, +// so the panel normally mounts with `isAiConfigLoaded` already true and its +// `immediate` default-selection watcher runs during setup. That path once read +// `boundConnection` before it was declared, and the TDZ ReferenceError kept the +// panel from opening at all. +import { createApp, h } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPinia } from "pinia"; +import i18n from "@/i18n"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import AiAssistant from "@/components/editor/AiAssistant.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +vi.mock("@/lib/backend/api", async (importOriginal) => { + const actual = await importOriginal>(); + const empty = () => Promise.resolve([]); + return { + ...actual, + loadAiConversations: empty, + loadAiRuns: empty, + readUserSkills: empty, + loadAiConfigs: empty, + loadPromptTemplates: empty, + getAiGlobalCustomInstructions: () => Promise.resolve(""), + saveAiChatSelection: () => Promise.resolve(), + loadAiChatSelection: () => Promise.resolve(null), + }; +}); + +const cleanups: Array<() => void> = []; + +afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); +}); + +async function mountPanel(aiConfigLoaded: boolean): Promise { + const pinia = createPinia(); + const errors: unknown[] = []; + const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant)) }); + app.use(pinia); + app.use(i18n); + app.config.errorHandler = (error) => errors.push(error); + app.config.warnHandler = () => {}; + useSettingsStore(pinia).isAiConfigLoaded = aiConfigLoaded; + const container = document.createElement("div"); + document.body.append(container); + app.mount(container); + cleanups.push(() => { + app.unmount(); + container.remove(); + }); + // Let mount-time loads settle so their failures surface here too. + await new Promise((resolve) => setTimeout(resolve, 20)); + return errors; +} + +describe("AiAssistant mount", () => { + it("opens when the AI config was loaded before the panel mounted", async () => { + expect((await mountPanel(true)).map(String)).toEqual([]); + }); + + it("opens while the AI config is still loading", async () => { + expect((await mountPanel(false)).map(String)).toEqual([]); + }); +});