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 @@
-
-
-
-
-
-
-
-
(leftWidthPx = v)" />
-
-
-
-
-
-
-
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 @@
diff --git a/client/src/components/Page/PageView.vue b/client/src/components/Page/PageView.vue
index 937a3ac1541..e4f7796ea8e 100644
--- a/client/src/components/Page/PageView.vue
+++ b/client/src/components/Page/PageView.vue
@@ -125,7 +125,7 @@ function stsUrl(config: any) {
{{ page.title || page.name }}
-
+
= {}) {
- return mount(PageChatHistoryList as any, {
- propsData: {
- items: MOCK_ITEMS,
- isLoading: false,
- error: null,
- activeExchangeId: null,
- ...props,
- },
- stubs: {
- FontAwesomeIcon: true,
- UtcDate: true,
- BAlert: true,
- },
- });
-}
-
-describe("PageChatHistoryList", () => {
- // Vue 2.7 emits prop warnings for `string | null` TS types; suppress them
- beforeEach(() => {
- vi.spyOn(console, "error").mockImplementation(() => {});
- createTestingPinia({ createSpy: vi.fn, stubActions: false });
- });
- describe("rendering", () => {
- it("renders items", () => {
- const wrapper = mountList();
- const items = wrapper.findAll('[data-description="sidebar item"]');
- expect(items.length).toBe(3);
- });
-
- it("shows query text for each item", () => {
- const wrapper = mountList();
- expect(wrapper.text()).toContain("First question");
- expect(wrapper.text()).toContain("Second question");
- expect(wrapper.text()).toContain("Third question");
- });
-
- it("shows empty message when no items", () => {
- const wrapper = mountList({ items: [] });
- expect(wrapper.text()).toContain("No conversations yet.");
- });
-
- it("shows loading message when loading", () => {
- const wrapper = mountList({ isLoading: true });
- expect(wrapper.text()).toContain("Loading history...");
- });
- });
-
- describe("selection mode toggle", () => {
- it("trash button toggles selection mode on", async () => {
- const wrapper = mountList();
- expect(wrapper.find(".selection-toolbar").exists()).toBe(false);
-
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- expect(wrapper.find(".selection-toolbar").exists()).toBe(true);
- });
-
- it("cancel button toggles selection mode off", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
- expect(wrapper.find(".selection-toolbar").exists()).toBe(true);
-
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- expect(wrapper.find(".selection-toolbar").exists()).toBe(false);
- });
-
- it("checkboxes visible in selection mode", async () => {
- const wrapper = mountList();
- expect(wrapper.find(".history-checkbox").exists()).toBe(false);
-
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- expect(wrapper.findAll(".history-checkbox").length).toBe(3);
- });
- });
-
- describe("item clicks", () => {
- it("emits select when clicking item outside selection mode", async () => {
- const wrapper = mountList();
- await wrapper.findAll('[data-description="sidebar item"]').at(0)!.trigger("click");
-
- const emitted = wrapper.emitted("select");
- expect(emitted).toBeTruthy();
- expect(emitted![0]![0]).toEqual(MOCK_ITEMS[0]);
- });
-
- it("does NOT emit select when clicking item in selection mode", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- await wrapper.findAll('[data-description="sidebar item"]').at(0)!.trigger("click");
-
- expect(wrapper.emitted("select")).toBeFalsy();
- });
-
- it("toggles item selection when clicking in selection mode", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- await wrapper.findAll('[data-description="sidebar item"]').at(1)!.trigger("click");
-
- // Delete button should show count
- const deleteBtn = wrapper.find('[data-description="delete selected button"]');
- expect(deleteBtn.text()).toContain("1");
- });
- });
-
- describe("select all / deselect all", () => {
- it("select all selects all items", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- await wrapper.find(".select-all-toggle").trigger("click");
-
- const deleteBtn = wrapper.find('[data-description="delete selected button"]');
- expect(deleteBtn.text()).toContain("3");
- });
-
- it("deselect all clears selection", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
- // Select all
- await wrapper.find(".select-all-toggle").trigger("click");
- // Deselect all
- await wrapper.find(".select-all-toggle").trigger("click");
-
- const deleteBtn = wrapper.find('[data-description="delete selected button"]');
- expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true);
- });
- });
-
- describe("delete", () => {
- it("delete button disabled when nothing selected", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- const deleteBtn = wrapper.find('[data-description="delete selected button"]');
- expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true);
- });
-
- it("delete button emits delete with selected IDs", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- // Click first and third items
- const items = wrapper.findAll('[data-description="sidebar item"]');
- await items.at(0)!.trigger("click");
- await items.at(2)!.trigger("click");
-
- await wrapper.find('[data-description="delete selected button"]').trigger("click");
-
- const emitted = wrapper.emitted("delete");
- expect(emitted).toBeTruthy();
- const ids = emitted![0]![0] as string[];
- expect(ids).toHaveLength(2);
- expect(ids).toContain("enc1");
- expect(ids).toContain("enc3");
- });
- });
-
- describe("shift-click range selection", () => {
- it("selects range with shift-click", async () => {
- const wrapper = mountList();
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- const items = wrapper.findAll('[data-description="sidebar item"]');
- // Click first item normally
- await items.at(0)!.trigger("click");
- // Shift-click third item
- await items.at(2)!.trigger("click", { shiftKey: true });
-
- // All 3 should be selected
- const deleteBtn = wrapper.find('[data-description="delete selected button"]');
- expect(deleteBtn.text()).toContain("3");
- });
- });
-
- describe("active exchange highlight", () => {
- it("applies active class to matching item when not in selection mode", () => {
- const wrapper = mountList({ activeExchangeId: "enc2" });
- const items = wrapper.findAll('[data-description="sidebar item"]');
- expect(items.at(1)!.classes()).toContain("active");
- });
-
- it("does not apply active class in selection mode", async () => {
- const wrapper = mountList({ activeExchangeId: "enc2" });
- await wrapper.find('[data-description="toggle selection button"]').trigger("click");
-
- const items = wrapper.findAll('[data-description="sidebar item"]');
- expect(items.at(1)!.classes()).not.toContain("active");
- });
- });
-});
diff --git a/client/src/components/PageEditor/PageChatHistoryList.vue b/client/src/components/PageEditor/PageChatHistoryList.vue
deleted file mode 100644
index 7c20470fe90..00000000000
--- a/client/src/components/PageEditor/PageChatHistoryList.vue
+++ /dev/null
@@ -1,234 +0,0 @@
-
-
-
-
-
- History
-
-
-
-
-
-
-
- {{ allSelected ? "Deselect all" : "Select all" }}
-
-
-
-
-
- {{ props.error }}
-
-
-
-
-
-
-
-
-
{{ item.query }}
-
-
-
- {{ item.message_count }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/src/components/PageEditor/PageChatPanel.test.ts b/client/src/components/PageEditor/PageChatPanel.test.ts
deleted file mode 100644
index aba3de33d10..00000000000
--- a/client/src/components/PageEditor/PageChatPanel.test.ts
+++ /dev/null
@@ -1,385 +0,0 @@
-import { createTestingPinia } from "@pinia/testing";
-import { getLocalVue } from "@tests/vitest/helpers";
-import { mount } from "@vue/test-utils";
-import flushPromises from "flush-promises";
-import type { Pinia } from "pinia";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import { usePageEditorStore } from "@/stores/pageEditorStore";
-
-import PageChatPanel from "./PageChatPanel.vue";
-import ChatInput from "@/components/GalaxyAI/ChatInput.vue";
-import ChatMessageCell from "@/components/GalaxyAI/ChatMessageCell.vue";
-
-// Mock GalaxyApi
-const mockPOST = vi.fn();
-const mockGET = vi.fn();
-const mockPUT = vi.fn();
-
-vi.mock("@/api", () => ({
- GalaxyApi: () => ({
- POST: mockPOST,
- GET: mockGET,
- PUT: mockPUT,
- }),
-}));
-
-vi.mock("vue-router/composables", () => ({
- useRouter: vi.fn(() => ({ push: vi.fn() })),
- useRoute: vi.fn(() => ({ params: {} })),
-}));
-
-const localVue = getLocalVue();
-
-const HISTORY_ID = "history-1";
-const PAGE_ID = "page-1";
-const PAGE_CONTENT = "# Intro\nSome intro text\n# Methods\nSome methods text";
-
-let pinia: Pinia;
-
-function mountComponent(
- propsData: { historyId?: string; pageId: string; pageContent: string } = {
- historyId: HISTORY_ID,
- pageId: PAGE_ID,
- pageContent: PAGE_CONTENT,
- },
-) {
- return mount(PageChatPanel as any, {
- localVue,
- propsData,
- pinia,
- stubs: {
- FontAwesomeIcon: true,
- BSkeleton: true,
- LoadingSpan: true,
- ChatMessageCell: true,
- ProposalDiffView: true,
- SectionPatchView: true,
- },
- });
-}
-
-describe("PageChatPanel", () => {
- beforeEach(() => {
- pinia = createTestingPinia({ createSpy: vi.fn });
- vi.clearAllMocks();
- // Default: no page chat history
- mockGET.mockResolvedValue({ data: [], error: null });
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- describe("Initial render", () => {
- it("renders chat panel container", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- expect(wrapper.find('[data-description="page chat panel"]').exists()).toBe(true);
- });
-
- it("renders header with Markdown Assistant title", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
- });
-
- it("renders ChatInput component", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- expect(wrapper.findComponent(ChatInput).exists()).toBe(true);
- });
-
- it("shows welcome message when no history", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- const cells = wrapper.findAllComponents(ChatMessageCell);
- expect(cells.length).toBe(1);
- });
-
- it("renders new conversation button", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- expect(wrapper.find('[data-description="new conversation button"]').exists()).toBe(true);
- });
- });
-
- describe("Message submission", () => {
- it("sends query to API on submit", async () => {
- mockPOST.mockResolvedValue({
- data: {
- response: "I can help with that.",
- exchange_id: 42,
- agent_response: {
- agent_type: "page_assistant",
- confidence: "high",
- suggestions: [],
- metadata: {},
- },
- },
- error: null,
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- // Use ChatInput's v-model: set value via the textarea + trigger submit
- const textarea = wrapper.find("textarea");
- await textarea.setValue("Rewrite the intro section");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- expect(mockPOST).toHaveBeenCalledWith(
- "/api/chat",
- expect.objectContaining({
- params: { query: { agent_type: "page_assistant" } },
- }),
- );
- });
-
- it("adds user and assistant messages after submit", async () => {
- mockPOST.mockResolvedValue({
- data: {
- response: "Here is a rewrite.",
- exchange_id: 42,
- agent_response: {
- agent_type: "page_assistant",
- confidence: "high",
- suggestions: [],
- metadata: {},
- },
- },
- error: null,
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("Rewrite intro");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- const cells = wrapper.findAllComponents(ChatMessageCell);
- // Welcome + user + assistant = 3
- expect(cells.length).toBe(3);
- });
-
- it("shows error message on API failure", async () => {
- mockPOST.mockResolvedValue({
- data: null,
- error: { err_msg: "Server error" },
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("test query");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- const cells = wrapper.findAllComponents(ChatMessageCell);
- const lastCell = cells.at(cells.length - 1);
- expect(lastCell.props("message").content).toContain("Error");
- });
-
- it("sets exchange_id from response for follow-up queries", async () => {
- mockPOST.mockResolvedValue({
- data: { response: "Done.", exchange_id: 99, agent_response: null },
- error: null,
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("hello");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- // Subsequent request should include the exchange_id
- mockPOST.mockResolvedValue({
- data: { response: "Follow up.", exchange_id: 99, agent_response: null },
- error: null,
- });
-
- await textarea.setValue("follow up");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- const lastCall = mockPOST.mock.calls[mockPOST.mock.calls.length - 1]!;
- expect(lastCall[1].body.exchange_id).toBe(99);
- });
- });
-
- describe("New conversation", () => {
- it("resets messages on new conversation click", async () => {
- mockPOST.mockResolvedValue({
- data: { response: "Test", exchange_id: 1, agent_response: null },
- error: null,
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("hello");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- // Click new conversation
- await wrapper.find('[data-description="new conversation button"]').trigger("click");
- await flushPromises();
-
- const cells = wrapper.findAllComponents(ChatMessageCell);
- expect(cells.length).toBe(1);
- });
- });
-
- describe("Loading state", () => {
- it("shows loading indicator when busy", async () => {
- // Make POST never resolve to keep busy=true
- mockPOST.mockReturnValue(new Promise(() => {}));
-
- const wrapper = mountComponent();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("hello");
- await wrapper.find(".send-button").trigger("click");
- await wrapper.vm.$nextTick();
-
- expect(wrapper.find('[data-description="chat loading indicator"]').exists()).toBe(true);
- });
- });
-
- describe("Store integration", () => {
- it("does not call store methods on mount", async () => {
- const store = usePageEditorStore();
- mountComponent();
- await flushPromises();
-
- expect(store.updateContent).not.toHaveBeenCalled();
- expect(store.savePage).not.toHaveBeenCalled();
- });
- });
-
- describe("Chat persistence", () => {
- it("stores exchange ID in store after first submit", async () => {
- mockPOST.mockResolvedValue({
- data: { response: "Done.", exchange_id: 77, agent_response: null },
- error: null,
- });
- const wrapper = mountComponent();
- await flushPromises();
- const store = usePageEditorStore();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("hello");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- expect(store.setCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID, 77);
- });
-
- it("clears stored exchange ID on new conversation", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- const store = usePageEditorStore();
-
- await wrapper.find('[data-description="new conversation button"]').trigger("click");
- await flushPromises();
-
- expect(store.setCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID, null);
- });
-
- it("checks store for cached exchange ID on mount", async () => {
- const store = usePageEditorStore();
- vi.mocked(store.getCurrentChatExchangeId).mockReturnValue("abc55");
- // Mock the exchange messages load
- mockGET.mockResolvedValue({
- data: [
- { role: "user", content: "hello", timestamp: null },
- { role: "assistant", content: "hi", agent_type: "page_assistant", timestamp: null },
- ],
- error: null,
- });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- expect(store.getCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID);
- // Should have loaded 2 messages from the cached conversation
- const cells = wrapper.findAllComponents(ChatMessageCell);
- expect(cells.length).toBe(2);
- });
-
- it("falls back to API when stored exchange ID returns empty messages", async () => {
- const store = usePageEditorStore();
- vi.mocked(store.getCurrentChatExchangeId).mockReturnValue("abc999");
- // All GET calls return empty — stored exchange has no messages, no page history
- mockGET.mockResolvedValue({ data: [], error: null });
-
- const wrapper = mountComponent();
- await flushPromises();
-
- // Stored ID yielded nothing, API fallback yielded nothing — welcome message shown
- const cells = wrapper.findAllComponents(ChatMessageCell);
- expect(cells.length).toBe(1);
- });
- });
-
- describe("Standalone page mode (no historyId)", () => {
- function mountStandalone() {
- return mountComponent({ pageId: PAGE_ID, pageContent: PAGE_CONTENT });
- }
-
- it("renders header with Markdown Assistant title", async () => {
- const wrapper = mountStandalone();
- await flushPromises();
- expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
- });
-
- it("shows Markdown Assistant welcome message", async () => {
- const wrapper = mountStandalone();
- await flushPromises();
- const cells = wrapper.findAllComponents(ChatMessageCell);
- expect(cells.length).toBe(1);
- // The welcome message should mention "Markdown Assistant"
- expect(cells.at(0).props("message").content).toContain("Markdown Assistant");
- });
-
- it("sends query to API without historyId", async () => {
- mockPOST.mockResolvedValue({
- data: {
- response: "I can help with that.",
- exchange_id: 42,
- agent_response: null,
- },
- error: null,
- });
-
- const wrapper = mountStandalone();
- await flushPromises();
-
- const textarea = wrapper.find("textarea");
- await textarea.setValue("Rewrite the intro");
- await wrapper.find(".send-button").trigger("click");
- await flushPromises();
-
- expect(mockPOST).toHaveBeenCalledWith(
- "/api/chat",
- expect.objectContaining({
- params: { query: { agent_type: "page_assistant" } },
- }),
- );
- });
-
- it("renders Markdown Assistant header when historyId is provided", async () => {
- const wrapper = mountComponent();
- await flushPromises();
- expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
- });
- });
-});
diff --git a/client/src/components/PageEditor/PageChatPanel.vue b/client/src/components/PageEditor/PageChatPanel.vue
deleted file mode 100644
index 04a3fc5317d..00000000000
--- a/client/src/components/PageEditor/PageChatPanel.vue
+++ /dev/null
@@ -1,570 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ store.chatError }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ assistantName }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/src/components/PageEditor/PageEditorView.test.ts b/client/src/components/PageEditor/PageEditorView.test.ts
index 40389c2f711..1ff36e8f84a 100644
--- a/client/src/components/PageEditor/PageEditorView.test.ts
+++ b/client/src/components/PageEditor/PageEditorView.test.ts
@@ -10,12 +10,10 @@ import { ref } from "vue";
import type { HistoryPageDetails, PageRevisionDetails, PageRevisionSummary } from "@/api/pages";
import { usePageEditorStore } from "@/stores/pageEditorStore";
-import PageChatPanel from "./PageChatPanel.vue";
import PageEditorView from "./PageEditorView.vue";
import PageRevisionList from "./PageRevisionList.vue";
import PageRevisionView from "./PageRevisionView.vue";
import ClickToEdit from "@/components/ClickToEdit.vue";
-import SplitView from "@/components/Common/SplitView.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
import MarkdownEditor from "@/components/Markdown/MarkdownEditor.vue";
@@ -67,7 +65,6 @@ const SELECTORS = {
UNSAVED_INDICATOR: "[data-description='page unsaved indicator']",
REVISIONS_BUTTON: "[data-description='page revisions button']",
REVISION_PANEL: ".page-revision-panel",
- CHAT_BUTTON: "[data-description='page chat button']",
PREVIEW_BUTTON: "[data-description='page preview button']",
PERMISSIONS_BUTTON: "[data-description='page permissions button']",
SAVE_VIEW_BUTTON: "[data-description='page save-view button']",
@@ -531,110 +528,6 @@ describe("PageEditorView", () => {
});
});
- describe("Chat Panel", () => {
- it("shows Chat button in toolbar", async () => {
- setupLoadedPage(HISTORY_ID);
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- const chatBtn = wrapper.find(SELECTORS.CHAT_BUTTON);
- expect(chatBtn.exists()).toBe(true);
- expect(chatBtn.text()).toContain("Chat");
- });
-
- it("clicking Chat button calls store.toggleChatPanel", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- const chatBtn = wrapper.find(SELECTORS.CHAT_BUTTON);
- await chatBtn.trigger("click");
- await flushPromises();
-
- expect(store.toggleChatPanel).toHaveBeenCalled();
- });
-
- it("renders split view when store.showChatPanel is true", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- store.showChatPanel = true;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- expect(wrapper.findComponent(SplitView).exists()).toBe(true);
- expect(wrapper.findComponent(PageChatPanel).exists()).toBe(true);
- });
-
- it("hides split view when store.showChatPanel is false", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- store.showChatPanel = false;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- expect(wrapper.findComponent(SplitView).exists()).toBe(false);
- });
-
- it("hides Chat button when llm_api_configured is false", async () => {
- setupLoadedPage(HISTORY_ID);
- mockConfig.value = { llm_api_configured: false };
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(false);
- });
-
- it("shows Chat button when llm_api_configured is true", async () => {
- setupLoadedPage(HISTORY_ID);
- mockConfig.value = { llm_api_configured: true };
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(true);
- });
-
- it("hides Chat button when config is null (not yet loaded)", async () => {
- setupLoadedPage(HISTORY_ID);
- mockConfig.value = null;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(false);
- });
-
- it("passes hideToolbox=true to MarkdownEditor when chat is open", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- store.showChatPanel = true;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- const editor = wrapper.findComponent(MarkdownEditor);
- expect(editor.exists()).toBe(true);
- expect(editor.props("hideToolbox")).toBe(true);
- });
-
- it("does not set hideToolbox when chat is closed", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- store.showChatPanel = false;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- const editor = wrapper.findComponent(MarkdownEditor);
- expect(editor.exists()).toBe(true);
- expect(editor.props("hideToolbox")).toBeFalsy();
- });
-
- it("passes props to PageChatPanel", async () => {
- const store = setupLoadedPage(HISTORY_ID);
- store.showChatPanel = true;
- const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
- await flushPromises();
-
- const panel = wrapper.findComponent(PageChatPanel);
- expect(panel.props("historyId")).toBe(HISTORY_ID);
- expect(panel.props("pageId")).toBe(PAGE_ID);
- expect(panel.props("pageContent")).toBe("# Hello");
- });
- });
-
describe("Lifecycle", () => {
it("calls store.loadPage on mount", async () => {
const store = usePageEditorStore();
diff --git a/client/src/components/PageEditor/PageEditorView.vue b/client/src/components/PageEditor/PageEditorView.vue
index 73d4c29ce47..e7df7887416 100644
--- a/client/src/components/PageEditor/PageEditorView.vue
+++ b/client/src/components/PageEditor/PageEditorView.vue
@@ -1,14 +1,5 @@