diff --git a/client/src/components/ActivityBar/ActivityBar.test.js b/client/src/components/ActivityBar/ActivityBar.test.js index be4088d80e0..4d48bc3e387 100644 --- a/client/src/components/ActivityBar/ActivityBar.test.js +++ b/client/src/components/ActivityBar/ActivityBar.test.js @@ -21,7 +21,7 @@ vi.mock("@/composables/config", () => ({ })); vi.mock("vue-router/composables", () => ({ - useRoute: vi.fn(() => ({})), + useRoute: vi.fn(() => ({ path: "/", params: {}, query: {} })), useRouter: vi.fn(() => ({ push: vi.fn() })), })); diff --git a/client/src/components/ActivityBar/ActivityBar.vue b/client/src/components/ActivityBar/ActivityBar.vue index 43a5926b73a..deecff3d8c9 100644 --- a/client/src/components/ActivityBar/ActivityBar.vue +++ b/client/src/components/ActivityBar/ActivityBar.vue @@ -3,11 +3,12 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faBell, faEllipsisH, faUserCog } from "@fortawesome/free-solid-svg-icons"; import { watchImmediate } from "@vueuse/core"; import { storeToRefs } from "pinia"; -import { computed, type Ref, ref } from "vue"; +import { computed, type Ref, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router/composables"; import draggable from "vuedraggable"; import { useConfig } from "@/composables/config"; +import { useActiveContext } from "@/composables/useActiveContext"; import { convertDropData } from "@/stores/activitySetup"; import { useActivityStore } from "@/stores/activityStore"; import type { Activity } from "@/stores/activityStoreTypes"; @@ -82,6 +83,15 @@ const route = useRoute(); const router = useRouter(); const userStore = useUserStore(); const chatStore = useChatStore(); +const { activeContext } = useActiveContext(); + +// Notebook context must never use center mode — the chat panel floats alongside +// the page editor, so force right-panel whenever we enter a notebook context. +watch(activeContext, (ctx) => { + if (ctx?.contextType === "notebook" && chatStore.isCenterMode) { + chatStore.setLocation("right"); + } +}); const eventStore = useEventStore(); const activityStore = useActivityStore(props.activityBarId); @@ -239,6 +249,18 @@ function toggleSidebar(toggle: string = "", to: string | null = null) { } function onChatGxyClick() { + // On notebook routes, always use the right panel — never navigate to center. + if (activeContext.value?.contextType === "notebook") { + if (chatStore.isCenterMode) { + chatStore.setLocation("right"); + } + chatStore.toggleChat(); + if (isActiveSideBar("galaxyai")) { + toggleSidebar("galaxyai"); + } + return; + } + if (chatStore.isCenterMode) { toggleSidebar("galaxyai"); if (!route.path.startsWith("/galaxyai")) { diff --git a/client/src/components/Common/SplitView.test.ts b/client/src/components/Common/SplitView.test.ts deleted file mode 100644 index 96c756344d0..00000000000 --- a/client/src/components/Common/SplitView.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getLocalVue } from "@tests/vitest/helpers"; -import { shallowMount } from "@vue/test-utils"; -import { describe, expect, it } from "vitest"; - -import SplitView from "./SplitView.vue"; -import DraggableSeparator from "@/components/Common/DraggableSeparator.vue"; - -const localVue = getLocalVue(); - -function mountComponent(propsData = {}) { - return shallowMount(SplitView as object, { - localVue, - propsData, - slots: { - left: '
Left
', - right: '
Right
', - }, - }); -} - -describe("SplitView", () => { - it("renders split view container", () => { - const wrapper = mountComponent(); - expect(wrapper.find('[data-description="split view"]').exists()).toBe(true); - }); - - it("renders left slot", () => { - const wrapper = mountComponent(); - expect(wrapper.find(".test-left").exists()).toBe(true); - }); - - it("renders right slot", () => { - const wrapper = mountComponent(); - expect(wrapper.find(".test-right").exists()).toBe(true); - }); - - it("delegates the divider to DraggableSeparator", () => { - const wrapper = mountComponent(); - expect(wrapper.findComponent(DraggableSeparator).exists()).toBe(true); - }); -}); diff --git a/client/src/components/Common/SplitView.vue b/client/src/components/Common/SplitView.vue deleted file mode 100644 index 2e4b368ca3e..00000000000 --- a/client/src/components/Common/SplitView.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/client/src/components/GalaxyAI.test.ts b/client/src/components/GalaxyAI.test.ts new file mode 100644 index 00000000000..32ebea643b7 --- /dev/null +++ b/client/src/components/GalaxyAI.test.ts @@ -0,0 +1,302 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { shallowMount } from "@vue/test-utils"; +import flushPromises from "flush-promises"; +import { setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; + +import type { ActiveContext } from "@/composables/useActiveContext"; +import { useChatStore } from "@/stores/chatStore"; +import { usePageEditorStore } from "@/stores/pageEditorStore"; + +import GalaxyAI from "./GalaxyAI.vue"; + +// ── API mock ───────────────────────────────────────────────────────────────── +const mockGetMessages = vi.fn(); +const mockGetHistory = vi.fn(); + +vi.mock("@/api", () => ({ + GalaxyApi: () => ({ + GET: (path: string, _opts: unknown) => { + if (path.includes("messages")) { + return mockGetMessages(); + } + if (path.includes("history")) { + return mockGetHistory(); + } + return { data: null, error: null }; + }, + }), +})); + +// ── Composable stubs ────────────────────────────────────────────────────────── +const mockActiveContext = ref(null); + +vi.mock("@/composables/useActiveContext", () => ({ + useActiveContext: () => ({ activeContext: mockActiveContext, contextLabel: ref("") }), +})); + +vi.mock("@/composables/userLocalStorage", () => ({ + useUserLocalStorage: (_key: string, initial: unknown) => ref(initial), +})); + +vi.mock("@/composables/markdown", () => ({ + useMarkdown: () => ({ renderMarkdown: (s: string) => s }), +})); + +vi.mock("@/composables/agentActions", () => ({ + useAgentActions: () => ({ processingAction: ref(false), handleAction: vi.fn() }), +})); + +vi.mock("@/composables/confirmDialog", () => ({ + useConfirmDialog: () => ({ confirm: vi.fn() }), +})); + +vi.mock("@/composables/toast", () => ({ + useToast: () => ({ error: vi.fn(), success: vi.fn() }), +})); + +vi.mock("@/composables/useEntityMentions", () => ({ + parseMentions: (s: string) => s, + resolveMentions: (s: string) => s, + buildEntityContext: () => null, +})); + +vi.mock("@/composables/usePageProposals", () => ({ + usePageProposals: () => ({ + pageContent: ref(""), + loadForPage: vi.fn(), + clear: vi.fn(), + getEditProposal: vi.fn(), + isProposalStale: vi.fn(), + isProposalVisible: vi.fn(() => false), + buildProposedContent: vi.fn(), + applyFullReplacement: vi.fn(), + applySectionPatched: vi.fn(), + dismissProposal: vi.fn(), + }), +})); + +vi.mock("vue-router/composables", () => ({ + useRoute: () => ({ path: "/", params: {}, query: {} }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +const localVue = getLocalVue(); +let lastWrapper: ReturnType | null = null; + +const EXCHANGE_ID = "exchange-abc"; +const MESSAGES_RESPONSE = [ + { role: "user", content: "Hello", timestamp: null }, + { role: "assistant", content: "Hi", timestamp: null, agent_type: "router", agent_response: null, feedback: null }, +]; + +function makeSuccessfulFetch(messages = MESSAGES_RESPONSE) { + mockGetMessages.mockResolvedValue({ data: messages, error: null }); +} + +function makeEmptyFetch() { + mockGetMessages.mockResolvedValue({ data: [], error: null }); +} + +function makeHistoryFetch(items = [{ id: "latest-chat" }]) { + mockGetHistory.mockResolvedValue({ data: items, error: null }); +} + +interface MountOptions { + props?: Record; + 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({ + props = {}, + activeChatId = null, + cachedExchangeId = null, + pageId = "page-1", + chatHistoryItems, +}: MountOptions = {}) { + const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + + 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); + } + + const wrapper = shallowMount(GalaxyAI as object, { + localVue, + pinia, + propsData: props, + stubs: { + FontAwesomeIcon: true, + ChatActions: true, + ChatInput: true, + ChatMessageCell: true, + ProposalDiffView: true, + SectionPatchView: true, + Heading: true, + BSkeleton: true, + }, + }); + + lastWrapper = wrapper; + return { wrapper, chatStore, pageEditorStore }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe("GalaxyAI fetch operations on mount", () => { + beforeEach(() => { + mockActiveContext.value = null; + vi.clearAllMocks(); + // Default: empty history so loadLatestChat doesn't cascade into fetchConversation + makeHistoryFetch([]); + }); + + afterEach(() => { + lastWrapper?.destroy(); + lastWrapper = null; + vi.restoreAllMocks(); + }); + + // Branch 1: explicit exchangeId prop (not "new") + describe("when exchangeId prop is provided and not 'new'", () => { + it("fetches the specified exchange on mount", async () => { + makeSuccessfulFetch(); + mountGalaxyAI({ props: { exchangeId: EXCHANGE_ID } }); + await flushPromises(); + expect(mockGetMessages).toHaveBeenCalledOnce(); + }); + + it("does not call loadLatestChat", async () => { + makeSuccessfulFetch(); + mountGalaxyAI({ props: { exchangeId: EXCHANGE_ID } }); + await flushPromises(); + expect(mockGetHistory).not.toHaveBeenCalled(); + }); + }); + + // Branch 2: exchangeId === "new" + describe("when exchangeId prop is 'new'", () => { + it("starts a new chat without fetching messages", async () => { + mountGalaxyAI({ props: { exchangeId: "new" } }); + await flushPromises(); + expect(mockGetMessages).not.toHaveBeenCalled(); + }); + + it("does not restore from activeChatId even when one is set", async () => { + // Regression: exchangeId='new' previously fell into the docked/panel restore branch + // and would fetch activeChatId, causing the route to flip back to the old chat. + mountGalaxyAI({ props: { exchangeId: "new" }, activeChatId: EXCHANGE_ID }); + await flushPromises(); + expect(mockGetMessages).not.toHaveBeenCalled(); + }); + }); + + // Branch 3: docked/panel — notebook context with a cached exchange ID and no activeChatId + describe("docked/panel — notebook context with cached exchange ID", () => { + it("fetches the cached exchange when activeChatId is null", async () => { + makeSuccessfulFetch(); + mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" }; + mountGalaxyAI({ props: { docked: true }, activeChatId: null, cachedExchangeId: EXCHANGE_ID }); + await flushPromises(); + expect(mockGetMessages).toHaveBeenCalledOnce(); + }); + + it("clears stale cached ID when fetch returns no messages", async () => { + makeEmptyFetch(); + mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" }; + const { pageEditorStore } = mountGalaxyAI({ + props: { docked: true }, + activeChatId: null, + cachedExchangeId: EXCHANGE_ID, + }); + await flushPromises(); + expect(pageEditorStore.getCurrentChatExchangeId("page-1")).toBeNull(); + }); + }); + + // 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: "some-other-chat", + chatHistoryItems: [{ id: EXCHANGE_ID }], + }); + await flushPromises(); + expect(mockGetMessages).toHaveBeenCalledOnce(); + }); + }); + + // 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(); + expect(mockGetMessages).not.toHaveBeenCalled(); + }); + }); + + // Branch 6: docked/panel — non-notebook context with activeChatId set + describe("docked/panel — non-notebook context with activeChatId", () => { + it("fetches the active chat exchange", async () => { + makeSuccessfulFetch(); + mountGalaxyAI({ props: { panel: true }, activeChatId: EXCHANGE_ID }); + await flushPromises(); + expect(mockGetMessages).toHaveBeenCalledOnce(); + }); + }); + + // Branch 7: docked/panel — non-notebook context, no activeChatId + describe("docked/panel — non-notebook context, no activeChatId", () => { + it("starts a new chat without fetching", async () => { + mountGalaxyAI({ props: { docked: true }, activeChatId: null }); + await flushPromises(); + expect(mockGetMessages).not.toHaveBeenCalled(); + }); + }); + + // Branch 8: center mode (no exchangeId, not docked/panel) + describe("center mode (no exchangeId, not docked/panel)", () => { + it("calls loadLatestChat to fetch most recent history", async () => { + mountGalaxyAI({}); + await flushPromises(); + expect(mockGetHistory).toHaveBeenCalledOnce(); + }); + + it("does not fetch exchange messages when history is empty", async () => { + mountGalaxyAI({}); + await flushPromises(); + expect(mockGetMessages).not.toHaveBeenCalled(); + }); + + it("fetches the latest exchange when history has items", async () => { + makeHistoryFetch([{ id: "latest-chat" }]); + makeSuccessfulFetch(); + mountGalaxyAI({}); + await flushPromises(); + expect(mockGetMessages).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/client/src/components/GalaxyAI.vue b/client/src/components/GalaxyAI.vue index 1ecfda352a2..62992541d32 100644 --- a/client/src/components/GalaxyAI.vue +++ b/client/src/components/GalaxyAI.vue @@ -1,5 +1,14 @@