diff --git a/client/src/components/GalaxyAI.test.ts b/client/src/components/GalaxyAI.test.ts index 841838dbe05..32ebea643b7 100644 --- a/client/src/components/GalaxyAI.test.ts +++ b/client/src/components/GalaxyAI.test.ts @@ -85,6 +85,7 @@ vi.mock("vue-router/composables", () => ({ // ── Helpers ─────────────────────────────────────────────────────────────────── const localVue = getLocalVue(); +let lastWrapper: ReturnType | null = null; const EXCHANGE_ID = "exchange-abc"; const MESSAGES_RESPONSE = [ @@ -109,6 +110,8 @@ interface MountOptions { activeChatId?: string | null; cachedExchangeId?: string | null; pageId?: string; + /** Pre-populate chatStore.chatHistory and stub loadHistory as a no-op so no real HTTP call is made. */ + chatHistoryItems?: { id: string }[]; } function mountGalaxyAI({ @@ -116,6 +119,7 @@ function mountGalaxyAI({ activeChatId = null, cachedExchangeId = null, pageId = "page-1", + chatHistoryItems, }: MountOptions = {}) { const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); setActivePinia(pinia); @@ -123,6 +127,13 @@ function mountGalaxyAI({ const chatStore = useChatStore(); chatStore.activeChatId = activeChatId as any; + if (chatHistoryItems !== undefined) { + chatStore.chatHistory = chatHistoryItems as any; + // Stub loadHistory so it doesn't make real HTTP calls (chatStore imports @/api/client + // which is not mocked); keep chatHistory as pre-populated. + vi.spyOn(chatStore, "loadHistory").mockResolvedValue(undefined); + } + const pageEditorStore = usePageEditorStore(); if (cachedExchangeId) { pageEditorStore.setCurrentChatExchangeId(pageId, cachedExchangeId); @@ -144,6 +155,7 @@ function mountGalaxyAI({ }, }); + lastWrapper = wrapper; return { wrapper, chatStore, pageEditorStore }; } @@ -157,6 +169,8 @@ describe("GalaxyAI fetch operations on mount", () => { }); afterEach(() => { + lastWrapper?.destroy(); + lastWrapper = null; vi.restoreAllMocks(); }); @@ -217,20 +231,26 @@ describe("GalaxyAI fetch operations on mount", () => { }); }); - // Branch 4: docked/panel — notebook context, no cached exchange but activeChatId set - describe("docked/panel — notebook context, no cached ID but activeChatId present", () => { - it("fetches from activeChatId", async () => { + // Branch 4: docked/panel — notebook context, no cached exchange but page has history + describe("docked/panel — notebook context, no cached ID but page has history", () => { + it("fetches the most recent chat from page history (ignores global activeChatId)", async () => { + // activeChatId is set but belongs to a different context — must not be used for notebook. makeSuccessfulFetch(); mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" }; - mountGalaxyAI({ props: { panel: true }, activeChatId: EXCHANGE_ID }); + mountGalaxyAI({ + props: { panel: true }, + activeChatId: "some-other-chat", + chatHistoryItems: [{ id: EXCHANGE_ID }], + }); await flushPromises(); expect(mockGetMessages).toHaveBeenCalledOnce(); }); }); - // Branch 5: docked/panel — notebook context, no cached exchange, no activeChatId - describe("docked/panel — notebook context, no cached ID and no activeChatId", () => { + // Branch 5: docked/panel — notebook context, no cached exchange, empty history + describe("docked/panel — notebook context, no cached ID and empty history", () => { it("starts a new chat without fetching", async () => { + // beforeEach already mocks history as empty mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" }; mountGalaxyAI({ props: { docked: true }, activeChatId: null }); await flushPromises(); diff --git a/client/src/components/GalaxyAI.vue b/client/src/components/GalaxyAI.vue index 1e247b69c26..62992541d32 100644 --- a/client/src/components/GalaxyAI.vue +++ b/client/src/components/GalaxyAI.vue @@ -62,18 +62,44 @@ const { activeContext, contextLabel } = useActiveContext(); const pageEditorStore = usePageEditorStore(); const contextDismissed = ref(false); -watch(activeContext, (newCtx, oldCtx) => { +watch(activeContext, async (newCtx, oldCtx) => { contextDismissed.value = false; - // When navigating away from a notebook, clear activeChatId if it belongs to that - // page (i.e. was loaded from the page-scoped history) so the panel doesn't keep - // showing a notebook-specific conversation in a non-notebook context. - if (oldCtx?.contextType === "notebook" && newCtx?.contextType !== "notebook") { - const currentId = chatStore.activeChatId; - if (currentId && chatStore.chatHistory.some((item) => item.id === currentId)) { - chatStore.setActiveChatId(null); + if (!props.docked && !props.panel) { + return; + } + + const switchingToNotebook = + newCtx?.contextType === "notebook" && (oldCtx?.contextType !== "notebook" || oldCtx.pageId !== newCtx.pageId); + + if (switchingToNotebook) { + // Switched to a notebook page (or to a different notebook page): restore this page's + // cached exchange, or fall back to the most recent chat in this page's history. + const pageId = newCtx.pageId; + const cachedId = pageEditorStore.getCurrentChatExchangeId(pageId); + if (cachedId) { + await fetchConversation(cachedId); + if (messages.value.length === 0) { + pageEditorStore.clearCurrentChatExchangeId(pageId); + startNewChat(); + } + } else { + try { + await chatStore.loadHistory(pageId); + } catch (e) { + Toast.error(errorMessageAsString(e), "Failed to load chat history"); + } + const latestChat = chatStore.chatHistory[0]; + if (latestChat) { + await fetchConversation(latestChat.id); + } else { + startNewChat(); + } } + } else if (oldCtx?.contextType === "notebook" && newCtx?.contextType !== "notebook") { + // Switched away from notebook: drop notebook-specific state and start fresh. clearProposals(); + startNewChat(); } }); @@ -136,22 +162,40 @@ onMounted(async () => { startNewChat(); } else if (props.docked || props.panel) { const ctx = activeContext.value; - // For notebook pages, prefer the per-page cached exchange over the global activeChatId. + // For notebook pages, always prefer the per-page cached exchange over the global + // activeChatId — the global one may belong to a completely unrelated normal chat. const notebookPageId = ctx?.contextType === "notebook" ? ctx.pageId : null; - const cachedId = notebookPageId && !chatStore.activeChatId - ? pageEditorStore.getCurrentChatExchangeId(notebookPageId) - : null; - const chatId = cachedId || chatStore.activeChatId || null; - if (chatId) { - await fetchConversation(chatId); - // If the cached notebook exchange was deleted/empty, drop the stale ID. - if (cachedId && messages.value.length === 0) { - pageEditorStore.clearCurrentChatExchangeId(notebookPageId!); - startNewChat(); + if (notebookPageId) { + // Notebook context: prefer per-page cached exchange, fall back to page history. + const cachedId = pageEditorStore.getCurrentChatExchangeId(notebookPageId); + if (cachedId) { + await fetchConversation(cachedId); + if (messages.value.length === 0) { + pageEditorStore.clearCurrentChatExchangeId(notebookPageId); + startNewChat(); + } + } else { + try { + await chatStore.loadHistory(notebookPageId); + } catch (e) { + Toast.error(errorMessageAsString(e), "Failed to load chat history"); + } + const latestChat = chatStore.chatHistory[0]; + if (latestChat) { + await fetchConversation(latestChat.id); + } else { + startNewChat(); + } } } else { - startNewChat(); + // Non-notebook context: use global activeChatId if available. + const chatId = chatStore.activeChatId; + if (chatId) { + await fetchConversation(chatId); + } else { + startNewChat(); + } } } else { await loadLatestChat();