Merge pull request #22807 from ahmedhamidawan/use_existing_panels_in_notebooks

[26.1] Reuse the global, context-aware GalaxyAI for Galaxy Notebooks
This commit is contained in:
Marius van den Beek
2026-06-11 09:19:08 +02:00
committed by GitHub
31 changed files with 1149 additions and 2415 deletions
@@ -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() })),
}));
@@ -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")) {
@@ -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: '<div class="test-left">Left</div>',
right: '<div class="test-right">Right</div>',
},
});
}
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);
});
});
@@ -1,92 +0,0 @@
<script setup lang="ts">
/**
* Two-pane split layout with a draggable resize handle.
*
* Composes ``DraggableSeparator`` for the divider, inheriting its
* keyboard accessibility, hover-delay, and animation-frame throttling.
* Caller supplies the pane content via ``left``/``right`` slots.
*/
import { useElementBounding } from "@vueuse/core";
import { computed, ref, watch } from "vue";
import DraggableSeparator from "@/components/Common/DraggableSeparator.vue";
const props = withDefaults(
defineProps<{
/** Starting size of the left pane, as a percent of the container. */
initialSplit?: number;
/** Smallest left-pane size, as a percent of the container. */
minPercent?: number;
/** Largest left-pane size, as a percent of the container. */
maxPercent?: number;
}>(),
{
initialSplit: 60,
minPercent: 20,
maxPercent: 80,
},
);
const containerRef = ref<HTMLElement>();
const { width: containerWidth } = useElementBounding(containerRef);
// Position is the left-pane width in pixels; ``DraggableSeparator`` works in
// pixels so this is the natural shape and avoids percent<->px drift.
const leftWidthPx = ref(0);
let initialized = false;
watch(
containerWidth,
(w) => {
if (!w) {
return;
}
if (!initialized) {
leftWidthPx.value = (props.initialSplit / 100) * w;
initialized = true;
} else {
const minPx = (props.minPercent / 100) * w;
const maxPx = (props.maxPercent / 100) * w;
leftWidthPx.value = Math.min(Math.max(leftWidthPx.value, minPx), maxPx);
}
},
{ immediate: true },
);
const minPx = computed(() => (containerWidth.value * props.minPercent) / 100);
const maxPx = computed(() => (containerWidth.value * props.maxPercent) / 100);
const rightWidthPx = computed(() => Math.max(containerWidth.value - leftWidthPx.value, 0));
</script>
<template>
<div ref="containerRef" class="split-view" data-description="split view">
<div class="split-pane" :style="{ flexBasis: `${leftWidthPx}px` }">
<slot name="left" />
</div>
<DraggableSeparator
:position="leftWidthPx"
side="left"
:inner="true"
:min="minPx"
:max="maxPx"
@positionChanged="(v) => (leftWidthPx = v)" />
<div class="split-pane" :style="{ flexBasis: `${rightWidthPx}px` }">
<slot name="right" />
</div>
</div>
</template>
<style scoped>
.split-view {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
position: relative;
}
.split-pane {
overflow: auto;
min-width: 0;
}
</style>
+302
View File
@@ -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<ActiveContext | null>(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<typeof shallowMount> | 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<string, unknown>;
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();
});
});
});
+140 -6
View File
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { faFile, faMagic, faSitemap, faTimes, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
import {
faBook,
faFile,
faFileContract,
faMagic,
faSitemap,
faTimes,
faTrash,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BSkeleton } from "bootstrap-vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
@@ -12,7 +21,9 @@ import { useMarkdown } from "@/composables/markdown";
import { useToast } from "@/composables/toast";
import { useActiveContext } from "@/composables/useActiveContext";
import { buildEntityContext, parseMentions, resolveMentions } from "@/composables/useEntityMentions";
import { usePageProposals } from "@/composables/usePageProposals";
import { useChatStore } from "@/stores/chatStore";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { getAgentIcon } from "./GalaxyAI/agentTypes";
@@ -22,6 +33,8 @@ import { generateId, scrollToBottom } from "./GalaxyAI/chatUtils";
import ChatActions from "./GalaxyAI/ChatActions.vue";
import ChatInput from "./GalaxyAI/ChatInput.vue";
import ChatMessageCell from "./GalaxyAI/ChatMessageCell.vue";
import ProposalDiffView from "./PageEditor/ProposalDiffView.vue";
import SectionPatchView from "./PageEditor/SectionPatchView.vue";
import Heading from "@/components/Common/Heading.vue";
const props = withDefaults(
@@ -46,10 +59,48 @@ const chatStore = useChatStore();
const Toast = useToast();
const { activeContext, contextLabel } = useActiveContext();
const pageEditorStore = usePageEditorStore();
const contextDismissed = ref(false);
watch(activeContext, () => {
watch(activeContext, async (newCtx, oldCtx) => {
contextDismissed.value = false;
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();
}
});
const effectiveContext = computed(() => {
@@ -68,6 +119,8 @@ const contextIcon = computed(() => {
case "workflow_editor":
case "workflow_run":
return faSitemap;
case "notebook":
return effectiveContext.value?.historyId ? faBook : faFileContract;
default:
return faMagic;
}
@@ -88,11 +141,62 @@ const hasLoadedInitialChat = ref(false);
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true, removeNewlinesAfterList: true });
const { processingAction, handleAction } = useAgentActions();
// Proposal rendering (notebook / page_assistant context)
const {
pageContent,
loadForPage: loadProposalsForPage,
clear: clearProposals,
getEditProposal,
isProposalStale,
isProposalVisible,
buildProposedContent,
applyFullReplacement,
applySectionPatched,
dismissProposal,
} = usePageProposals(activeContext);
onMounted(async () => {
if (props.exchangeId && props.exchangeId !== "new") {
await fetchConversation(props.exchangeId);
} else if (props.docked || props.panel || props.exchangeId === "new") {
} else if (props.exchangeId === "new") {
startNewChat();
} else if (props.docked || props.panel) {
const ctx = activeContext.value;
// 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;
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 {
// Non-notebook context: use global activeChatId if available.
const chatId = chatStore.activeChatId;
if (chatId) {
await fetchConversation(chatId);
} else {
startNewChat();
}
}
} else {
await loadLatestChat();
}
@@ -105,7 +209,7 @@ onMounted(async () => {
watch(
() => props.exchangeId,
async (newId, oldId) => {
if (newId === oldId) {
if (newId === oldId || newId === currentChatId.value) {
return;
}
if (newId && newId !== "new") {
@@ -316,6 +420,11 @@ async function fetchConversation(exchangeId: string) {
currentChatId.value = exchangeId;
nextTick(() => scrollToBottom(chatContainer.value));
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
loadProposalsForPage(ctx.pageId);
}
hasLoadedInitialChat.value = true;
}
@@ -350,6 +459,7 @@ function startNewChat() {
];
currentChatId.value = null;
query.value = "";
clearProposals();
if (props.docked || props.panel) {
chatStore.setActiveChatId(null);
}
@@ -389,11 +499,18 @@ function dockTo(location: "right" | "bottom") {
watch(currentChatId, async (newId) => {
if (props.docked || props.panel) {
chatStore.setActiveChatId(newId);
// Keep the per-page cache in sync so reopening the panel restores this exchange.
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
pageEditorStore.setCurrentChatExchangeId(ctx.pageId, newId);
}
}
if (newId && !chatStore.chatHistory.some((item) => item.id === newId)) {
const pageId = activeContext.value?.contextType === "notebook" ? activeContext.value.pageId : undefined;
try {
await chatStore.loadHistory();
await chatStore.loadHistory(pageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
@@ -446,7 +563,24 @@ watch(currentChatId, async (newId) => {
:processing-action="processingAction"
@feedback="sendFeedback"
@handle-action="handleAction"
@select-clarification-option="selectClarificationOption" />
@select-clarification-option="selectClarificationOption">
<template v-if="isProposalVisible(message)" v-slot:after-content>
<ProposalDiffView
v-if="getEditProposal(message)?.mode === 'full_replacement'"
:original="pageContent"
:proposed="buildProposedContent(message)"
:stale="isProposalStale(message)"
@accept="applyFullReplacement(message)"
@reject="dismissProposal(message)" />
<SectionPatchView
v-else-if="getEditProposal(message)?.mode === 'section_patch'"
:original="pageContent"
:proposed="buildProposedContent(message)"
:stale="isProposalStale(message)"
@accept="applySectionPatched($event, message)"
@reject="dismissProposal(message)" />
</template>
</ChatMessageCell>
<!-- Loading state -->
<div v-if="busy" class="loading-entry">
+16 -8
View File
@@ -117,14 +117,22 @@ function onDockTo(location: "right" | "bottom") {
@click="maximize">
<FontAwesomeIcon :icon="faExpand" fixed-width />
</GButton>
<template v-if="props.source === 'center'">
<GButton size="small" transparent title="Dock to side panel" @click="onDockTo('right')">
<FontAwesomeIcon :icon="faColumns" fixed-width />
</GButton>
<GButton size="small" transparent title="Dock to bottom panel" @click="onDockTo('bottom')">
<FontAwesomeIcon :icon="faAngleDoubleDown" fixed-width />
</GButton>
</template>
<GButton
v-if="props.source !== 'docked'"
size="small"
transparent
title="Dock to side panel"
@click="onDockTo('right')">
<FontAwesomeIcon :icon="faColumns" fixed-width />
</GButton>
<GButton
v-if="props.source !== 'panel'"
size="small"
transparent
title="Dock to bottom panel"
@click="onDockTo('bottom')">
<FontAwesomeIcon :icon="faAngleDoubleDown" fixed-width />
</GButton>
<GButton size="small" transparent title="Open in floating window" @click="popOut">
<FontAwesomeIcon :icon="faExternalLinkAlt" fixed-width />
</GButton>
@@ -3,11 +3,12 @@ import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { faClock, faPlus, faTimes, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
import { computed, onMounted } from "vue";
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useConfirmDialog } from "@/composables/confirmDialog";
import { useToast } from "@/composables/toast";
import { useActiveContext } from "@/composables/useActiveContext";
import { useSidebarSelection } from "@/composables/useSidebarSelection";
import { useChatStore } from "@/stores/chatStore";
import { errorMessageAsString } from "@/utils/simple-error";
@@ -26,6 +27,12 @@ const Toast = useToast();
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const { activeContext } = useActiveContext();
const notebookPageId = computed(() => {
const ctx = activeContext.value;
return ctx?.contextType === "notebook" ? ctx.pageId : undefined;
});
const { chatHistory, loading } = storeToRefs(chatStore);
@@ -47,13 +54,17 @@ const currentExchangeId = computed(() => {
}
});
onMounted(async () => {
try {
await chatStore.loadHistory();
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
});
watch(
notebookPageId,
async (pageId) => {
try {
await chatStore.loadHistory(pageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
},
{ immediate: true },
);
function handleItemClick(item: ChatHistoryItem, index: number, event: MouseEvent) {
if (handleSelectionClick(item, index, event)) {
@@ -13,7 +13,11 @@ import ChatModeSelector from "./ChatModeSelector.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
const mockPush = vi.fn();
let mockRoute: { path: string; params: Record<string, string> } = { path: "/", params: {} };
let mockRoute: { path: string; params: Record<string, string>; query: Record<string, string> } = {
path: "/",
params: {},
query: {},
};
vi.mock("vue-router/composables", () => ({
useRouter: () => ({ push: mockPush }),
@@ -41,7 +45,7 @@ function getButtons(wrapper: Wrapper<Vue>) {
describe("ChatModeSelector", () => {
beforeEach(() => {
mockRoute = { path: "/", params: {} };
mockRoute = { path: "/", params: {}, query: {} };
vi.clearAllMocks();
});
@@ -53,7 +57,7 @@ describe("ChatModeSelector", () => {
describe("button active states", () => {
it("full view button is pressed when in center mode on /galaxyai route", async () => {
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" } };
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
await nextTick();
@@ -100,6 +104,28 @@ describe("ChatModeSelector", () => {
});
});
// ── notebook context ──────────────────────────────────────────────────────
describe("notebook context", () => {
it("hides full view button when in notebook context", async () => {
mockRoute = {
path: "/histories/hist-1/page/page-1",
params: { historyId: "hist-1", pageId: "page-1" },
query: {},
};
const { wrapper } = mountComponent();
await nextTick();
// Only 2 buttons should be rendered (side panel + bottom panel)
expect(getButtons(wrapper).length).toBe(2);
});
it("shows full view button when not in notebook context", async () => {
const { wrapper } = mountComponent();
await nextTick();
expect(getButtons(wrapper).length).toBe(3);
});
});
// ── openCenterChat ─────────────────────────────────────────────────────────
describe("openCenterChat", () => {
@@ -134,21 +160,21 @@ describe("ChatModeSelector", () => {
describe("openDockedChat — navigation", () => {
it("pushes '/' when on /galaxyai root route", async () => {
mockRoute = { path: "/galaxyai", params: {} };
mockRoute = { path: "/galaxyai", params: {}, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).toHaveBeenCalledWith("/");
});
it("pushes '/' when on /galaxyai/:id route", async () => {
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" } };
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" }, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).toHaveBeenCalledWith("/");
});
it("does not navigate when on an unrelated route", async () => {
mockRoute = { path: "/histories", params: {} };
mockRoute = { path: "/histories", params: {}, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).not.toHaveBeenCalled();
@@ -173,7 +199,7 @@ describe("ChatModeSelector", () => {
});
it("uses routedChatId when coming from center mode with exchangeId in route", async () => {
mockRoute = { path: "/galaxyai/routed-chat", params: { exchangeId: "routed-chat" } };
mockRoute = { path: "/galaxyai/routed-chat", params: { exchangeId: "routed-chat" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
await getButtons(wrapper).at(1).trigger("click");
@@ -181,7 +207,7 @@ describe("ChatModeSelector", () => {
});
it("uses activeChatId fallback when in center mode but no exchangeId in route", async () => {
mockRoute = { path: "/galaxyai", params: {} };
mockRoute = { path: "/galaxyai", params: {}, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
store.activeChatId = "active-chat";
@@ -222,7 +248,7 @@ describe("ChatModeSelector", () => {
});
it("does NOT use routedChatId when not in center mode even with exchangeId in route", async () => {
mockRoute = { path: "/galaxyai/some-chat", params: { exchangeId: "some-chat" } };
mockRoute = { path: "/galaxyai/some-chat", params: { exchangeId: "some-chat" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "right";
store.activeChatId = "active-chat";
@@ -5,6 +5,7 @@ import { storeToRefs } from "pinia";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useActiveContext } from "@/composables/useActiveContext";
import { useChatStore } from "@/stores/chatStore.js";
import GButton from "../BaseComponents/GButton.vue";
@@ -14,6 +15,9 @@ const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const { activeContext } = useActiveContext();
const isNotebookContext = computed(() => activeContext.value?.contextType === "notebook");
const { activeChatId, isBottomPanelOpen, isCenterMode, isRightPanelOpen } = storeToRefs(chatStore);
@@ -45,7 +49,14 @@ function openDockedChat(location: "right" | "bottom") {
<template>
<GButtonGroup class="chat-mode-selector" size="small">
<GButton color="blue" outline :pressed="isOnCenter" title="Enable Full View" tooltip @click="openCenterChat">
<GButton
v-if="!isNotebookContext"
color="blue"
outline
:pressed="isOnCenter"
title="Enable Full View"
tooltip
@click="openCenterChat">
<FontAwesomeIcon :icon="faExpand" />
</GButton>
<GButton
+24 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useChatStore } from "@/stores/chatStore";
@@ -10,14 +11,35 @@ import GalaxyAI from "@/components/GalaxyAI.vue";
const chatStore = useChatStore();
const { activeChatId } = storeToRefs(chatStore);
const route = useRoute();
const router = useRouter();
const collapsed = ref(false);
function dockTo(location: "right" | "bottom") {
chatStore.dockChat(location, activeChatId.value);
if (route.path.startsWith("/galaxyai")) {
router.push("/");
}
}
// Expand collapsed panel if a chat is selected from the sidebar
watch(
activeChatId,
(chatId) => {
if (chatId && collapsed.value) {
collapsed.value = false;
}
},
{ immediate: true },
);
</script>
<template>
<div class="chat-panel" :class="collapsed ? 'collapsed' : 'expanded'">
<div class="chat-panel-header">
<span class="chat-panel-title">GalaxyAI</span>
<ChatActions source="panel" :collapsed.sync="collapsed" />
<ChatActions source="panel" :collapsed.sync="collapsed" @dock-to="dockTo" />
</div>
<div v-show="!collapsed" class="chat-panel-body">
<GalaxyAI :exchange-id="activeChatId || undefined" panel />
+1 -1
View File
@@ -125,7 +125,7 @@ function stsUrl(config: any) {
{{ page.title || page.name }}
</Heading>
<div class="page-content">
<div class="page-content h-100">
<Markdown
v-if="page.content_format === 'markdown'"
:markdown-config="page"
-11
View File
@@ -24,8 +24,6 @@ export const PAGE_LABELS: Record<
viewButton: string;
createButton: string;
historyCounterTooltip: string;
assistantName: string;
assistantWelcome: string;
chatPlaceholder: string;
newConversation: string;
}
@@ -42,10 +40,6 @@ export const PAGE_LABELS: Record<
viewButton: "View notebook",
createButton: "Create Notebook",
historyCounterTooltip: "Galaxy Notebooks",
assistantName: "Markdown Assistant",
assistantWelcome:
"I'm the Markdown Assistant. I can help you edit this notebook — " +
"ask me to rewrite sections, add content, fix formatting, or analyze your history datasets.",
chatPlaceholder: "Ask about your history or request notebook edits...",
newConversation: "Starting a new conversation. How can I help with this notebook?",
},
@@ -60,11 +54,6 @@ export const PAGE_LABELS: Record<
viewButton: "View report",
createButton: "Create Report",
historyCounterTooltip: "",
assistantName: "Markdown Assistant",
assistantWelcome:
"I'm the Markdown Assistant. I can help you edit this report — " +
"ask me to rewrite sections, add content, fix formatting, " +
"or browse your current history's datasets.",
chatPlaceholder: "Ask about your history or request report edits...",
newConversation: "Starting a new conversation. How can I help with this report?",
},
@@ -1,231 +0,0 @@
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PageChatHistoryList from "./PageChatHistoryList.vue";
const MOCK_ITEMS = [
{
id: "enc1",
query: "First question",
response: "First answer",
agent_type: "page_assistant",
timestamp: "2025-06-15T10:00:00Z",
feedback: null,
message_count: 2,
},
{
id: "enc2",
query: "Second question",
response: "Second answer",
agent_type: "page_assistant",
timestamp: "2025-06-14T09:00:00Z",
feedback: 1,
message_count: 4,
},
{
id: "enc3",
query: "Third question",
response: "Third answer",
agent_type: "page_assistant",
timestamp: "2025-06-13T08:00:00Z",
feedback: null,
message_count: 2,
},
];
function mountList(props: Record<string, unknown> = {}) {
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");
});
});
});
@@ -1,234 +0,0 @@
<script setup lang="ts">
import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { faClock, faComments, faTimes, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert } from "bootstrap-vue";
import { computed, watch } from "vue";
import type { components } from "@/api";
import { useSidebarSelection } from "@/composables/useSidebarSelection";
import SidebarList from "@/components/Common/SidebarList.vue";
import UtcDate from "@/components/UtcDate.vue";
type ChatHistoryItem = components["schemas"]["ChatHistoryItemResponse"];
const props = defineProps<{
items: ChatHistoryItem[];
isLoading: boolean;
error: string | null;
activeExchangeId: string | null;
}>();
const emit = defineEmits<{
(e: "select", item: ChatHistoryItem): void;
(e: "delete", ids: string[]): void;
(e: "dismiss-error"): void;
}>();
const itemsRef = computed(() => props.items);
const {
selectionMode,
selectedIds,
allSelected,
toggleSelectionMode,
toggleSelectAll,
handleSelectionClick,
pruneAfterDelete,
} = useSidebarSelection(itemsRef, (item) => item.id);
function itemKey(item: ChatHistoryItem): string {
return item.id;
}
function itemClass(item: ChatHistoryItem): Record<string, boolean> {
return {
active: !selectionMode.value && item.id === props.activeExchangeId,
selected: selectedIds.value.has(item.id),
};
}
function onSelect(item: ChatHistoryItem, index: number, event: MouseEvent) {
if (handleSelectionClick(item, index, event)) {
return;
}
emit("select", item);
}
function deleteSelected() {
if (selectedIds.value.size === 0) {
return;
}
emit("delete", Array.from(selectedIds.value));
}
// Items are owned by the parent (store) and removed asynchronously after
// the "delete" emit. Prune stale selections once the prop actually updates.
watch(itemsRef, () => {
if (selectionMode.value) {
pruneAfterDelete();
}
});
defineExpose({ selectionMode, toggleSelectionMode });
</script>
<template>
<div class="page-chat-history-list" data-description="page chat history list">
<div class="history-toolbar d-flex align-items-center justify-content-between px-2 py-1 border-bottom">
<span class="toolbar-label">History</span>
<button
class="btn btn-sm"
:class="selectionMode ? 'btn-outline-secondary' : 'btn-outline-danger'"
:title="selectionMode ? 'Cancel selection' : 'Select chats to delete'"
data-description="toggle selection button"
@click="toggleSelectionMode">
<FontAwesomeIcon :icon="selectionMode ? faTimes : faTrash" fixed-width />
</button>
</div>
<div v-if="selectionMode && items.length > 0" class="selection-toolbar">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events vuejs-accessibility/no-static-element-interactions -->
<span class="select-all-toggle" @click="toggleSelectAll">
<FontAwesomeIcon :icon="allSelected ? faCheckSquare : faSquare" fixed-width />
{{ allSelected ? "Deselect all" : "Select all" }}
</span>
<button
class="btn btn-sm btn-danger"
:disabled="selectedIds.size === 0"
data-description="delete selected button"
@click="deleteSelected">
Delete {{ selectedIds.size > 0 ? selectedIds.size : "" }}
</button>
</div>
<BAlert
v-if="props.error"
variant="danger"
dismissible
show
class="m-2 mb-0"
data-description="chat history error"
@dismissed="emit('dismiss-error')">
{{ props.error }}
</BAlert>
<SidebarList
:items="items"
:is-loading="isLoading"
loading-message="Loading history..."
empty-message="No conversations yet."
:item-key="itemKey"
:item-class="itemClass"
@select="onSelect">
<template v-slot:item="{ item }">
<span v-if="selectionMode" class="history-checkbox">
<FontAwesomeIcon :icon="selectedIds.has(item.id) ? faCheckSquare : faSquare" fixed-width />
</span>
<div class="history-content">
<div class="history-query">{{ item.query }}</div>
<div class="history-meta">
<span v-if="item.message_count" class="history-messages">
<FontAwesomeIcon :icon="faComments" fixed-width />
{{ item.message_count }}
</span>
<span v-if="item.timestamp" class="history-time">
<FontAwesomeIcon :icon="faClock" fixed-width />
<UtcDate :date="item.timestamp" mode="elapsed" />
</span>
</div>
</div>
</template>
</SidebarList>
</div>
</template>
<style scoped>
.page-chat-history-list {
max-height: 100%;
overflow-y: auto;
}
.history-toolbar {
font-size: 0.75rem;
font-weight: 600;
}
.toolbar-label {
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--text-muted, #6c757d);
}
.selection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--border-color, #dee2e6);
font-size: 0.75rem;
}
.select-all-toggle {
cursor: pointer;
color: var(--text-muted, #6c757d);
display: flex;
align-items: center;
gap: 0.25rem;
}
.select-all-toggle:hover {
color: var(--text-color, #212529);
}
:deep(.sidebar-item.active) {
background: rgba(var(--brand-primary-rgb, 0, 123, 255), 0.08);
border-left: 3px solid var(--brand-primary, #007bff);
padding-left: calc(0.5rem - 3px);
}
:deep(.sidebar-item.selected) {
background: rgba(var(--brand-primary-rgb, 0, 123, 255), 0.06);
}
.history-checkbox {
flex-shrink: 0;
color: var(--text-muted, #6c757d);
padding-top: 0.1rem;
}
.history-content {
flex: 1;
min-width: 0;
}
.history-query {
font-size: 0.8rem;
color: var(--text-color, #212529);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}
.history-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
color: var(--text-muted, #6c757d);
}
.history-messages {
display: flex;
align-items: center;
gap: 0.2rem;
}
.history-time {
display: flex;
align-items: center;
gap: 0.2rem;
}
</style>
@@ -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");
});
});
});
@@ -1,570 +0,0 @@
<script setup lang="ts">
/**
* Page-specific chat panel.
* Talks to the page_assistant agent, shows diff views for edit proposals,
* and applies accepted edits to the page via the store.
*/
import { faBook, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton, BSkeleton } from "bootstrap-vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { ChatMessage } from "@/components/GalaxyAI/chatTypes";
import { generateId, scrollToBottom } from "@/components/GalaxyAI/chatUtils";
import { PAGE_LABELS } from "@/components/Page/constants";
import { type AgentResponse, type EditProposal, useAgentActions } from "@/composables/agentActions";
import { useMarkdown } from "@/composables/markdown";
import { type PageEditorMode, usePageEditorStore } from "@/stores/pageEditorStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { applySectionEdit } from "./sectionDiffUtils";
import PageChatHistoryList from "./PageChatHistoryList.vue";
import ProposalDiffView from "./ProposalDiffView.vue";
import SectionPatchView from "./SectionPatchView.vue";
import ChatInput from "@/components/GalaxyAI/ChatInput.vue";
import ChatMessageCell from "@/components/GalaxyAI/ChatMessageCell.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const props = withDefaults(
defineProps<{
historyId?: string;
pageId: string;
pageContent: string;
}>(),
{ historyId: "" },
);
const isHistoryAttached = computed(() => !!props.historyId);
const chatMode = computed<PageEditorMode>(() => (isHistoryAttached.value ? "history" : "standalone"));
const chatLabels = computed(() => PAGE_LABELS[chatMode.value]);
const assistantName = computed(() => chatLabels.value.assistantName);
const AGENT_TYPE = "page_assistant";
const store = usePageEditorStore();
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true, removeNewlinesAfterList: true });
const { processingAction, handleAction } = useAgentActions();
const query = ref("");
const messages = ref<ChatMessage[]>([]);
const busy = ref(false);
const chatContainer = ref<HTMLElement>();
const currentChatId = ref<string | null>(null);
const dismissedProposals = ref(new Set<string>());
onMounted(async () => {
await loadPageChat();
if (messages.value.length === 0) {
messages.value.push({
id: generateId(),
role: "assistant",
content: chatLabels.value.assistantWelcome,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "high",
feedback: null,
isSystemMessage: true,
});
}
});
async function loadPageChat() {
store.chatError = null;
// Try store-cached exchange ID first (avoids API round-trip on panel reopen)
const storedExchangeId = store.getCurrentChatExchangeId(props.pageId);
if (storedExchangeId !== null) {
try {
await loadConversation(storedExchangeId);
if (messages.value.length > 0) {
return;
}
} catch (e) {
console.warn("Failed to load cached chat exchange:", e);
store.clearCurrentChatExchangeId(props.pageId);
}
}
// Fall back to API
try {
const { data, error } = await GalaxyApi().GET("/api/chat/page/{page_id}/history", {
params: { path: { page_id: props.pageId }, query: { limit: 1 } },
});
if (data && !error && data.length > 0) {
await loadConversation(data[0]!.id);
}
} catch (e) {
console.warn("Failed to load page chat history:", e);
store.chatError = errorMessageAsString(e);
}
}
async function loadConversation(exchangeId: string) {
const { data } = await GalaxyApi().GET("/api/chat/exchange/{exchange_id}/messages", {
params: { path: { exchange_id: exchangeId } },
});
if (data && data.length > 0) {
messages.value = data.map((msg: any, index: number) => {
const m: ChatMessage = {
id: `hist-${msg.role}-${exchangeId}-${index}`,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
feedback: null,
};
if (msg.role === "assistant") {
m.agentType = msg.agent_type || AGENT_TYPE;
m.confidence = msg.agent_response?.confidence || "medium";
m.feedback = msg.feedback === 1 ? "up" : msg.feedback === 0 ? "down" : null;
if (msg.agent_response) {
m.agentResponse = msg.agent_response;
m.suggestions = msg.agent_response.suggestions || [];
}
}
return m;
});
currentChatId.value = exchangeId;
store.setCurrentChatExchangeId(props.pageId, exchangeId);
dismissedProposals.value = new Set(store.getDismissedProposals(props.pageId));
}
}
async function submitQuery() {
if (!query.value.trim()) {
return;
}
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: query.value,
timestamp: new Date(),
feedback: null,
};
messages.value.push(userMessage);
const currentQuery = query.value;
query.value = "";
await nextTick();
scrollToBottom(chatContainer.value);
busy.value = true;
const wasNewExchange = currentChatId.value === null;
try {
const { data, error } = await GalaxyApi().POST("/api/chat", {
params: { query: { agent_type: AGENT_TYPE } },
body: {
query: currentQuery,
context: null,
exchange_id: currentChatId.value,
page_id: props.pageId,
},
});
if (error) {
const errStr = errorMessageAsString(error, "Failed to get response.");
messages.value.push({
id: generateId(),
role: "assistant",
content: `Error: ${errStr}`,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "low",
feedback: null,
});
} else if (data) {
if (data.exchange_id) {
currentChatId.value = data.exchange_id;
store.setCurrentChatExchangeId(props.pageId, data.exchange_id);
}
if (wasNewExchange) {
store.loadPageChatHistory(props.pageId);
}
const agentResponse = data.agent_response as AgentResponse | undefined;
messages.value.push({
id: generateId(),
role: "assistant",
content: data.response || "No response received",
timestamp: new Date(),
agentType: agentResponse?.agent_type || AGENT_TYPE,
confidence: agentResponse?.confidence || "medium",
feedback: null,
agentResponse: agentResponse,
suggestions: agentResponse?.suggestions || [],
});
}
} catch (e) {
messages.value.push({
id: generateId(),
role: "assistant",
content: "Unexpected error occurred. Please try again.",
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "low",
feedback: null,
});
} finally {
busy.value = false;
await nextTick();
scrollToBottom(chatContainer.value);
}
}
watch(busy, (isBusy) => {
if (isBusy) {
nextTick(() => scrollToBottom(chatContainer.value));
}
});
async function sendFeedback(messageId: string, value: "up" | "down") {
const message = messages.value.find((m) => m.id === messageId);
if (!message || !currentChatId.value) {
return;
}
message.feedback = value;
try {
const feedbackValue = value === "up" ? 1 : 0;
const { error } = await GalaxyApi().PUT("/api/chat/exchange/{exchange_id}/feedback", {
params: { path: { exchange_id: currentChatId.value } },
body: feedbackValue,
});
if (error) {
message.feedback = null;
}
} catch {
message.feedback = null;
}
}
function djb2Hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (h * 33 + s.charCodeAt(i)) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
function isProposalStale(msg: ChatMessage): boolean {
const meta = msg.agentResponse?.metadata;
const originalHash = meta?.original_content_hash as string | undefined;
if (!originalHash) {
return false;
}
return originalHash !== djb2Hash(props.pageContent);
}
function getEditProposal(msg: ChatMessage): EditProposal | null {
const meta = msg.agentResponse?.metadata;
const editMode = meta?.edit_mode as EditProposal["mode"] | undefined;
if (!editMode) {
return null;
}
return {
mode: editMode,
content: (meta?.content as string) || (meta?.new_section_content as string) || "",
target_section_heading: meta?.target_section_heading as string | undefined,
new_section_content: meta?.new_section_content as string | undefined,
};
}
function isProposalVisible(msg: ChatMessage): boolean {
if (dismissedProposals.value.has(msg.id)) {
return false;
}
const proposal = getEditProposal(msg);
if (!proposal) {
return false;
}
// Content-based: if full_replacement content matches current doc, already applied
if (proposal.mode === "full_replacement" && proposal.content === props.pageContent) {
return false;
}
return true;
}
function getProposalMode(msg: ChatMessage): string {
return getEditProposal(msg)?.mode || "";
}
function buildProposedContent(msg: ChatMessage): string {
const proposal = getEditProposal(msg);
if (!proposal) {
return props.pageContent;
}
if (proposal.mode === "full_replacement") {
return proposal.content;
}
// section_patch: reconstruct full doc by replacing the target section
return applySectionEdit(
props.pageContent,
proposal.target_section_heading || "",
proposal.new_section_content || proposal.content,
);
}
async function applyFullReplacement(msg: ChatMessage) {
const proposal = getEditProposal(msg);
if (!proposal) {
return;
}
store.updateContent(proposal.content);
await store.savePage("agent");
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
async function applySectionPatched(patchedContent: string, msg: ChatMessage) {
store.updateContent(patchedContent);
await store.savePage("agent");
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
function dismissProposal(msg: ChatMessage) {
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
function toggleHistory() {
store.toggleChatHistory();
if (store.showChatHistory) {
store.loadPageChatHistory(props.pageId);
}
}
async function handleHistorySelect(item: { id: string }) {
try {
await loadConversation(item.id);
store.showChatHistory = false;
await nextTick();
scrollToBottom(chatContainer.value);
} catch (e) {
store.chatHistoryError = errorMessageAsString(e);
}
}
function resetToNewChat() {
messages.value = [
{
id: generateId(),
role: "assistant",
content: chatLabels.value.newConversation,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "high",
feedback: null,
isSystemMessage: true,
},
];
currentChatId.value = null;
store.setCurrentChatExchangeId(props.pageId, null);
query.value = "";
dismissedProposals.value = new Set();
store.clearDismissedProposals(props.pageId);
}
async function handleHistoryDelete(ids: string[]) {
try {
await store.deletePageChatExchanges(props.pageId, ids);
if (currentChatId.value && ids.includes(currentChatId.value)) {
resetToNewChat();
}
} catch (e) {
store.chatHistoryError = errorMessageAsString(e);
}
}
async function deleteCurrentExchange() {
if (!currentChatId.value) {
return;
}
try {
await store.deletePageChatExchanges(props.pageId, [currentChatId.value]);
resetToNewChat();
} catch (e) {
store.chatError = errorMessageAsString(e);
}
}
function startNewConversation() {
resetToNewChat();
}
</script>
<template>
<div class="page-chat-panel d-flex flex-column h-100" data-description="page chat panel">
<div class="chat-panel-header d-flex align-items-center justify-content-between p-2 border-bottom">
<span class="d-flex align-items-center gap-2">
<FontAwesomeIcon :icon="faBook" fixed-width />
<strong>{{ assistantName }}</strong>
</span>
<span class="d-flex align-items-center gap-1">
<BButton
variant="outline-secondary"
size="sm"
:pressed="store.showChatHistory"
data-description="chat history button"
@click="toggleHistory">
<FontAwesomeIcon :icon="faHistory" fixed-width />
</BButton>
<BButton
v-if="currentChatId"
variant="outline-danger"
size="sm"
title="Delete current conversation"
data-description="delete conversation button"
@click="deleteCurrentExchange">
<FontAwesomeIcon :icon="faTrash" fixed-width />
</BButton>
<BButton
variant="outline-primary"
size="sm"
data-description="new conversation button"
@click="startNewConversation">
New Chat
</BButton>
</span>
</div>
<div class="chat-body d-flex flex-grow-1" style="min-height: 0">
<div ref="chatContainer" class="chat-panel-messages flex-grow-1 overflow-auto">
<BAlert
v-if="store.chatError"
variant="danger"
dismissible
show
data-description="chat error alert"
@dismissed="store.chatError = null">
{{ store.chatError }}
</BAlert>
<ChatMessageCell
v-for="msg in messages"
:key="msg.id"
:message="msg"
:render-markdown="renderMarkdown"
:processing-action="processingAction"
@feedback="sendFeedback"
@handle-action="handleAction">
<template v-if="isProposalVisible(msg)" v-slot:after-content>
<ProposalDiffView
v-if="getProposalMode(msg) === 'full_replacement'"
:original="pageContent"
:proposed="buildProposedContent(msg)"
:stale="isProposalStale(msg)"
@accept="applyFullReplacement(msg)"
@reject="dismissProposal(msg)" />
<SectionPatchView
v-else-if="getProposalMode(msg) === 'section_patch'"
:original="pageContent"
:proposed="buildProposedContent(msg)"
:stale="isProposalStale(msg)"
@accept="applySectionPatched($event, msg)"
@reject="dismissProposal(msg)" />
</template>
</ChatMessageCell>
<div v-if="busy" class="loading-cell" data-description="chat loading indicator">
<div class="cell-label">
<FontAwesomeIcon :icon="faBook" fixed-width />
<span>{{ assistantName }}</span>
</div>
<div class="cell-content">
<BSkeleton animation="wave" width="85%" />
<BSkeleton animation="wave" width="55%" />
<BSkeleton animation="wave" width="70%" />
</div>
</div>
<div v-if="!busy && messages.length === 0" class="text-muted text-center p-4">
<LoadingSpan message="Loading conversation..." />
</div>
</div>
<PageChatHistoryList
v-if="store.showChatHistory"
class="chat-history-sidebar border-left"
:items="store.pageChatHistory"
:is-loading="store.isLoadingChatHistory"
:error="store.chatHistoryError"
:active-exchange-id="currentChatId"
data-description="chat history sidebar"
@select="handleHistorySelect"
@delete="handleHistoryDelete"
@dismiss-error="store.chatHistoryError = null" />
</div>
<div class="chat-panel-footer p-2 border-top">
<ChatInput v-model="query" :busy="busy" :placeholder="chatLabels.chatPlaceholder" @submit="submitQuery" />
</div>
</div>
</template>
<style scoped>
.page-chat-panel {
background: var(--body-bg, #fff);
min-width: 0;
}
.chat-panel-header {
background: var(--panel-header-bg, #f8f9fa);
font-size: 0.85rem;
}
.chat-panel-header .gap-2 {
gap: 0.5rem;
}
.chat-panel-messages {
padding: 0.75rem;
min-width: 0;
}
.chat-history-sidebar {
width: 220px;
flex-shrink: 0;
overflow-y: auto;
}
.chat-panel-footer {
background: var(--panel-header-bg, #f8f9fa);
}
.loading-cell {
margin-bottom: 1rem;
opacity: 0.7;
}
.loading-cell .cell-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.375rem;
padding-left: 0.25rem;
color: var(--text-muted, #6c757d);
}
.loading-cell .cell-content {
border-left: 3px solid var(--brand-secondary, #6c757d);
background: var(--panel-bg-color, #f8f9fa);
padding: 0.75rem 1rem;
border-radius: 4px;
}
</style>
@@ -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();
@@ -1,14 +1,5 @@
<script setup lang="ts">
import {
faArrowLeft,
faComments,
faEdit,
faEye,
faHistory,
faSave,
faSpinner,
faUsers,
} from "@fortawesome/free-solid-svg-icons";
import { faArrowLeft, faEdit, faEye, faHistory, faSave, faSpinner, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BBadge, BButton } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
@@ -16,18 +7,15 @@ import { useRouter } from "vue-router/composables";
import { getGalaxyInstance } from "@/app";
import type { RouterPushOptions } from "@/components/History/Content/router-push-options";
import { PAGE_LABELS, PERMISSIONS_LABELS } from "@/components/Page/constants";
import { useConfig } from "@/composables/config";
import { PAGE_LABELS } from "@/components/Page/constants";
import { useWindowAwareNavigation } from "@/composables/windowAwareNavigation";
import { useHistoryStore } from "@/stores/historyStore";
import { type PageEditorMode, usePageEditorStore } from "@/stores/pageEditorStore";
import ObjectPermissionsModal from "./ObjectPermissionsModal.vue";
import PageChatPanel from "./PageChatPanel.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";
@@ -41,9 +29,6 @@ const router = useRouter();
const { pushToFrameOrPage } = useWindowAwareNavigation();
const store = usePageEditorStore();
const historyStore = useHistoryStore();
const { config } = useConfig();
const agentsAvailable = computed(() => !!config.value?.llm_api_configured);
const editorMode = computed<PageEditorMode>(() => (props.historyId ? "history" : "standalone"));
const isStandalone = computed(() => editorMode.value === "standalone");
@@ -265,16 +250,6 @@ function handleRevisionRestore(revisionId: string) {
<FontAwesomeIcon :icon="faEye" />
Preview
</BButton>
<BButton
v-if="agentsAvailable"
:variant="store.showChatPanel ? 'primary' : 'outline-primary'"
size="sm"
class="mr-2"
data-description="page chat button"
@click="store.toggleChatPanel">
<FontAwesomeIcon :icon="faComments" />
Chat
</BButton>
<template v-if="isStandalone">
<ObjectPermissionsModal
id="object-permissions-modal"
@@ -316,38 +291,22 @@ function handleRevisionRestore(revisionId: string) {
</div>
<div class="page-body d-flex flex-grow-1 overflow-hidden">
<SplitView v-if="store.showChatPanel">
<template v-slot:left>
<div class="page-editor-pane">
<MarkdownEditor
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
:hide-toolbox="true"
@update="handleContentUpdate" />
</div>
</template>
<template v-slot:right>
<PageChatPanel :history-id="historyId" :page-id="pageId" :page-content="store.currentContent" />
</template>
</SplitView>
<template v-else>
<div class="page-content flex-grow-1 overflow-auto">
<MarkdownEditor
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
@update="handleContentUpdate" />
</div>
<div v-if="store.showRevisions" class="page-revision-panel border-left">
<PageRevisionList
:revisions="store.revisions"
:is-loading="store.isLoadingRevisions"
:is-reverting="store.isReverting"
@select="handleRevisionSelect"
@restore="handleRevisionRestore" />
</div>
</template>
<div class="page-content flex-grow-1 overflow-auto">
<MarkdownEditor
class="h-100"
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
@update="handleContentUpdate" />
</div>
<div v-if="store.showRevisions" class="page-revision-panel border-left">
<PageRevisionList
:revisions="store.revisions"
:is-loading="store.isLoadingRevisions"
:is-reverting="store.isReverting"
@select="handleRevisionSelect"
@restore="handleRevisionRestore" />
</div>
</div>
</template>
</div>
@@ -361,9 +320,6 @@ function handleRevisionRestore(revisionId: string) {
.page-display-toolbar {
background: var(--panel-header-bg);
}
.page-content {
padding: 1rem;
}
.page-revision-panel {
width: 300px;
min-width: 300px;
@@ -215,3 +215,11 @@ export function diffStats(changes: Change[]): DiffStats {
}
return { additions, deletions };
}
export function djb2Hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (h * 33 + s.charCodeAt(i)) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
@@ -118,6 +118,32 @@ describe("useActiveContext", () => {
jobId: "job-7",
});
});
it("detects notebook context from notebook editor route", () => {
const { activeContext } = withRoute(
"/histories/test-history-id/pages/test-page-id",
{},
{ historyId: "test-history-id", pageId: "test-page-id" },
);
expect(activeContext.value).toEqual({
contextType: "notebook",
pageId: "test-page-id",
historyId: "test-history-id",
});
});
it("detects notebook context from page editor route", () => {
const { activeContext } = withRoute("/pages/editor", { id: "test-page-id" }, {});
expect(activeContext.value).toEqual({
contextType: "notebook",
pageId: "test-page-id",
});
});
it("returns null for page editor route without id", () => {
const { activeContext } = withRoute("/pages/editor");
expect(activeContext.value).toBeNull();
});
});
describe("contextLabel", () => {
@@ -160,5 +186,14 @@ describe("useActiveContext", () => {
const { contextLabel } = withRoute("/jobs/j-5", {}, { jobId: "j-5" });
expect(contextLabel.value).toBe("Job: j-5");
});
it("labels notebook context", () => {
const { contextLabel } = withRoute(
"/histories/test-history-id/pages/test-page-id",
{},
{ historyId: "test-history-id", pageId: "test-page-id" },
);
expect(contextLabel.value).toBe("History Notebook: test-page-id");
});
});
});
+19 -1
View File
@@ -8,7 +8,8 @@ export type ActiveContext =
| { contextType: "dataset"; datasetId: string }
| { contextType: "workflow_editor"; workflowId: string }
| { contextType: "workflow_run"; workflowId: string }
| { contextType: "job"; jobId: string; toolId?: string };
| { contextType: "job"; jobId: string; toolId?: string }
| { contextType: "notebook"; pageId: string; historyId?: string };
export function useActiveContext() {
const route = useRoute();
@@ -67,6 +68,21 @@ export function useActiveContext() {
};
}
if (path.startsWith("/histories/") && params.historyId && params.pageId) {
return {
contextType: "notebook",
pageId: String(params.pageId),
historyId: String(params.historyId),
};
}
if (path === "/pages/editor" && query.id) {
return {
contextType: "notebook",
pageId: String(query.id),
};
}
return null;
});
@@ -86,6 +102,8 @@ export function useActiveContext() {
return `Running workflow: ${ctx.workflowId}`;
case "job":
return `Job: ${ctx.jobId}`;
case "notebook":
return ctx.historyId ? `History Notebook: ${ctx.pageId}` : `Page: ${ctx.pageId}`;
default:
return null;
}
@@ -0,0 +1,303 @@
import { createTestingPinia } from "@pinia/testing";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { djb2Hash } from "@/components/PageEditor/sectionDiffUtils";
import type { ActiveContext } from "@/composables/useActiveContext";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import { usePageProposals } from "./usePageProposals";
// Mock useUserLocalStorage so localStorage isn't touched in tests.
const mockPersistedDismissed = ref<Record<string, string[]>>({});
vi.mock("@/composables/userLocalStorage", () => ({
useUserLocalStorage: () => mockPersistedDismissed,
}));
const PAGE_ID = "page-abc";
const PAGE_CONTENT = "# Intro\nSome intro text\n# Methods\nSome methods text";
const PAGE_CONTENT_HASH = djb2Hash(PAGE_CONTENT);
function makeNotebookContext(pageId = PAGE_ID): ActiveContext {
return { contextType: "notebook", pageId, historyId: "hist-1" };
}
function makeMsg(overrides: Record<string, unknown> = {}) {
return {
id: "msg-1",
role: "assistant" as const,
content: "Here is my suggestion.",
timestamp: new Date(),
feedback: null,
...overrides,
};
}
function makeProposalMsg(
mode: "full_replacement" | "section_patch",
content: string,
extras: Record<string, unknown> = {},
) {
return makeMsg({
agentResponse: {
agent_type: "page_assistant",
confidence: "high",
suggestions: [],
metadata: {
edit_mode: mode,
content,
original_content_hash: PAGE_CONTENT_HASH,
...extras,
},
},
});
}
describe("usePageProposals", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn }));
mockPersistedDismissed.value = {};
});
afterEach(() => {
vi.restoreAllMocks();
});
function setup(initialContext: ActiveContext | null = makeNotebookContext()) {
const activeContext = ref<ActiveContext | null>(initialContext);
const store = usePageEditorStore();
store.currentContent = PAGE_CONTENT;
vi.mocked(store.savePage).mockResolvedValue(undefined);
const composable = usePageProposals(activeContext);
return { activeContext, store, ...composable };
}
describe("pageContent", () => {
it("returns store content when in notebook context", () => {
const { pageContent } = setup();
expect(pageContent.value).toBe(PAGE_CONTENT);
});
it("returns empty string when context is null", () => {
const { pageContent } = setup(null);
expect(pageContent.value).toBe("");
});
it("returns empty string for non-notebook context types", () => {
const { pageContent } = setup({ contextType: "tool", toolId: "bwa" });
expect(pageContent.value).toBe("");
});
});
describe("loadForPage / clear", () => {
it("loads dismissed proposals from localStorage for a page", () => {
mockPersistedDismissed.value[PAGE_ID] = ["msg-1", "msg-2"];
const { loadForPage, dismissedProposals } = setup();
loadForPage(PAGE_ID);
expect(dismissedProposals.value.has("msg-1")).toBe(true);
expect(dismissedProposals.value.has("msg-2")).toBe(true);
});
it("clear resets the in-memory set", () => {
mockPersistedDismissed.value[PAGE_ID] = ["msg-1"];
const { loadForPage, clear, dismissedProposals } = setup();
loadForPage(PAGE_ID);
expect(dismissedProposals.value.size).toBe(1);
clear();
expect(dismissedProposals.value.size).toBe(0);
});
});
describe("getEditProposal", () => {
it("returns null for messages without agentResponse", () => {
const { getEditProposal } = setup();
expect(getEditProposal(makeMsg())).toBeNull();
});
it("returns null when edit_mode is absent", () => {
const { getEditProposal } = setup();
const msg = makeMsg({ agentResponse: { metadata: {} } });
expect(getEditProposal(msg)).toBeNull();
});
it("returns full_replacement proposal", () => {
const { getEditProposal } = setup();
const msg = makeProposalMsg("full_replacement", "new content");
const proposal = getEditProposal(msg);
expect(proposal?.mode).toBe("full_replacement");
expect(proposal?.content).toBe("new content");
});
it("returns section_patch proposal with target heading", () => {
const { getEditProposal } = setup();
const msg = makeProposalMsg("section_patch", "", {
target_section_heading: "Methods",
new_section_content: "## Methods\nNew methods text",
});
const proposal = getEditProposal(msg);
expect(proposal?.mode).toBe("section_patch");
expect(proposal?.target_section_heading).toBe("Methods");
});
});
describe("isProposalStale", () => {
it("returns false when no original_content_hash in metadata", () => {
const { isProposalStale } = setup();
const msg = makeMsg({
agentResponse: { metadata: { edit_mode: "full_replacement", content: "x" } },
});
expect(isProposalStale(msg)).toBe(false);
});
it("returns false when hash matches current content", () => {
const { isProposalStale } = setup();
const msg = makeProposalMsg("full_replacement", "x");
expect(isProposalStale(msg)).toBe(false);
});
it("returns true when hash does not match current content", () => {
const { store, isProposalStale } = setup();
store.currentContent = "# Changed content";
const msg = makeProposalMsg("full_replacement", "x"); // hash is for original PAGE_CONTENT
expect(isProposalStale(msg)).toBe(true);
});
});
describe("isProposalVisible", () => {
it("returns false when not in notebook context", () => {
const { isProposalVisible } = setup(null);
const msg = makeProposalMsg("full_replacement", "new");
expect(isProposalVisible(msg)).toBe(false);
});
it("returns false when message is dismissed", () => {
const { isProposalVisible, dismissProposal } = setup();
const msg = makeProposalMsg("full_replacement", "new");
dismissProposal(msg);
expect(isProposalVisible(msg)).toBe(false);
});
it("returns false when message has no proposal", () => {
const { isProposalVisible } = setup();
expect(isProposalVisible(makeMsg())).toBe(false);
});
it("returns false for full_replacement when content already matches page", () => {
const { isProposalVisible } = setup();
const msg = makeProposalMsg("full_replacement", PAGE_CONTENT);
expect(isProposalVisible(msg)).toBe(false);
});
it("returns true for a fresh full_replacement proposal", () => {
const { isProposalVisible } = setup();
const msg = makeProposalMsg("full_replacement", "brand new content");
expect(isProposalVisible(msg)).toBe(true);
});
it("returns true for a section_patch proposal", () => {
const { isProposalVisible } = setup();
const msg = makeProposalMsg("section_patch", "", {
target_section_heading: "Methods",
new_section_content: "## Methods\nUpdated",
});
expect(isProposalVisible(msg)).toBe(true);
});
});
describe("buildProposedContent", () => {
it("returns pageContent when no proposal", () => {
const { buildProposedContent } = setup();
expect(buildProposedContent(makeMsg())).toBe(PAGE_CONTENT);
});
it("returns proposal content directly for full_replacement", () => {
const { buildProposedContent } = setup();
const msg = makeProposalMsg("full_replacement", "completely new doc");
expect(buildProposedContent(msg)).toBe("completely new doc");
});
it("applies section patch to produce full document", () => {
const { buildProposedContent } = setup();
const msg = makeProposalMsg("section_patch", "", {
target_section_heading: "Methods",
new_section_content: "# Methods\nReplaced methods",
});
const result = buildProposedContent(msg);
expect(result).toContain("Replaced methods");
expect(result).toContain("Intro");
});
});
describe("dismissProposal", () => {
it("adds message ID to dismissed set", () => {
const { dismissedProposals, dismissProposal } = setup();
const msg = makeProposalMsg("full_replacement", "x");
dismissProposal(msg);
expect(dismissedProposals.value.has(msg.id)).toBe(true);
});
it("persists dismissed ID to localStorage", () => {
const { dismissProposal } = setup();
const msg = makeProposalMsg("full_replacement", "x");
dismissProposal(msg);
expect(mockPersistedDismissed.value[PAGE_ID]).toContain(msg.id);
});
it("does not persist to localStorage when not in notebook context", () => {
const { activeContext, dismissProposal } = setup();
activeContext.value = null;
dismissProposal(makeProposalMsg("full_replacement", "x"));
expect(mockPersistedDismissed.value[PAGE_ID]).toBeUndefined();
});
});
describe("applyFullReplacement", () => {
it("updates store content and saves", async () => {
const { store, applyFullReplacement } = setup();
const msg = makeProposalMsg("full_replacement", "new page content");
await applyFullReplacement(msg);
await flushPromises();
expect(store.updateContent).toHaveBeenCalledWith("new page content");
expect(store.savePage).toHaveBeenCalledWith("agent");
});
it("marks message as dismissed after applying", async () => {
const { dismissedProposals, applyFullReplacement } = setup();
const msg = makeProposalMsg("full_replacement", "new page content");
await applyFullReplacement(msg);
expect(dismissedProposals.value.has(msg.id)).toBe(true);
});
it("does nothing when message has no proposal", async () => {
const { store, applyFullReplacement } = setup();
await applyFullReplacement(makeMsg());
expect(store.updateContent).not.toHaveBeenCalled();
});
});
describe("applySectionPatched", () => {
it("updates store with patched content and saves", async () => {
const { store, applySectionPatched } = setup();
const msg = makeProposalMsg("section_patch", "");
await applySectionPatched("# Patched doc", msg);
await flushPromises();
expect(store.updateContent).toHaveBeenCalledWith("# Patched doc");
expect(store.savePage).toHaveBeenCalledWith("agent");
});
it("marks message as dismissed after applying", async () => {
const { dismissedProposals, applySectionPatched } = setup();
const msg = makeMsg();
await applySectionPatched("patched", msg);
expect(dismissedProposals.value.has(msg.id)).toBe(true);
});
});
});
+147
View File
@@ -0,0 +1,147 @@
import { computed, type Ref, ref } from "vue";
import type { ChatMessage } from "@/components/GalaxyAI/chatTypes";
import { applySectionEdit, djb2Hash } from "@/components/PageEditor/sectionDiffUtils";
import type { EditProposal } from "@/composables/agentActions";
import type { ActiveContext } from "@/composables/useActiveContext";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
import { usePageEditorStore } from "@/stores/pageEditorStore";
/**
* Encapsulates proposal rendering state and actions for the GalaxyAI panel
* when operating in notebook (page_assistant) context.
*
* Owns the `dismissedProposals` set and all helpers needed to render
* ProposalDiffView / SectionPatchView inside ChatMessageCell.
*
* Dismissed proposal IDs are persisted in localStorage keyed by pageId so they
* survive page reloads for the same conversation.
*/
export function usePageProposals(activeContext: Readonly<Ref<ActiveContext | null>>) {
const pageEditorStore = usePageEditorStore();
/** Persisted map of pageId → dismissed message ID list. */
const persistedDismissed = useUserLocalStorage<Record<string, string[]>>("page-chat-dismissed-proposals", {});
/** Live page content — empty when not in notebook context. */
const pageContent = computed(() =>
activeContext.value?.contextType === "notebook" ? pageEditorStore.currentContent : "",
);
/** In-memory working set for the current conversation. */
const dismissedProposals = ref(new Set<string>());
/** Load the persisted dismissed set for a page after fetching a conversation. */
function loadForPage(pageId: string) {
dismissedProposals.value = new Set(persistedDismissed.value[pageId] ?? []);
}
/** Clear the in-memory set (new chat or leaving notebook context). */
function clear() {
dismissedProposals.value = new Set();
}
function persistDismiss(pageId: string, messageId: string) {
const current = persistedDismissed.value[pageId] ?? [];
if (!current.includes(messageId)) {
persistedDismissed.value = { ...persistedDismissed.value, [pageId]: [...current, messageId] };
}
}
function getEditProposal(msg: ChatMessage): EditProposal | null {
const meta = msg.agentResponse?.metadata;
const editMode = meta?.edit_mode as EditProposal["mode"] | undefined;
if (!editMode) {
return null;
}
return {
mode: editMode,
content: (meta?.content as string) || (meta?.new_section_content as string) || "",
target_section_heading: meta?.target_section_heading as string | undefined,
new_section_content: meta?.new_section_content as string | undefined,
};
}
function isProposalStale(msg: ChatMessage): boolean {
const originalHash = msg.agentResponse?.metadata?.original_content_hash as string | undefined;
return !!originalHash && originalHash !== djb2Hash(pageContent.value);
}
function isProposalVisible(msg: ChatMessage): boolean {
if (activeContext.value?.contextType !== "notebook") {
return false;
}
if (dismissedProposals.value.has(msg.id)) {
return false;
}
const proposal = getEditProposal(msg);
if (!proposal) {
return false;
}
if (proposal.mode === "full_replacement" && proposal.content === pageContent.value) {
return false;
}
return true;
}
function buildProposedContent(msg: ChatMessage): string {
const proposal = getEditProposal(msg);
if (!proposal) {
return pageContent.value;
}
if (proposal.mode === "full_replacement") {
return proposal.content;
}
return applySectionEdit(
pageContent.value,
proposal.target_section_heading || "",
proposal.new_section_content || proposal.content,
);
}
async function applyFullReplacement(msg: ChatMessage) {
const proposal = getEditProposal(msg);
if (!proposal) {
return;
}
pageEditorStore.updateContent(proposal.content);
await pageEditorStore.savePage("agent");
dismissedProposals.value.add(msg.id);
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
persistDismiss(ctx.pageId, msg.id);
}
}
async function applySectionPatched(patchedContent: string, msg: ChatMessage) {
pageEditorStore.updateContent(patchedContent);
await pageEditorStore.savePage("agent");
dismissedProposals.value.add(msg.id);
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
persistDismiss(ctx.pageId, msg.id);
}
}
function dismissProposal(msg: ChatMessage) {
dismissedProposals.value.add(msg.id);
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
persistDismiss(ctx.pageId, msg.id);
}
}
return {
pageContent,
dismissedProposals,
loadForPage,
clear,
getEditProposal,
isProposalStale,
isProposalVisible,
buildProposedContent,
applyFullReplacement,
applySectionPatched,
dismissProposal,
};
}
+21 -5
View File
@@ -11,7 +11,7 @@ export type ChatLocation = "center" | "right" | "bottom";
export const useChatStore = defineStore("chatStore", () => {
const chatLocation = useUserLocalStorage<ChatLocation>("chat-location", "center");
const chatVisible = useUserLocalStorage("chat-visible", false);
const activeChatId = ref<string | null>(null);
const cachedActiveChatId = useUserLocalStorage<string>("active-chat-id", "");
const chatHistory = ref<ChatHistoryItem[]>([]);
const loading = ref(false);
@@ -19,6 +19,17 @@ export const useChatStore = defineStore("chatStore", () => {
const isBottomPanelOpen = computed(() => chatLocation.value === "bottom" && chatVisible.value);
const isCenterMode = computed(() => chatLocation.value === "center");
const activeChatId = computed({
get: () => cachedActiveChatId.value || null,
set: (id: string | null) => {
if (id) {
cachedActiveChatId.value = id;
} else {
cachedActiveChatId.value = "";
}
},
});
function deleteChats(ids: Set<string>) {
chatHistory.value = chatHistory.value.filter((item) => !ids.has(item.id));
// If the chat currently in view was deleted, drop the reference so we don't
@@ -71,11 +82,16 @@ export const useChatStore = defineStore("chatStore", () => {
}
}
async function loadHistory() {
async function loadHistory(pageId?: string) {
loading.value = true;
const { data, error } = await GalaxyApi().GET("/api/chat/history", {
params: { query: { limit: 50 } },
});
const { data, error } = pageId
? await GalaxyApi().GET("/api/chat/page/{page_id}/history", {
params: { path: { page_id: pageId }, query: { limit: 50 } },
})
: await GalaxyApi().GET("/api/chat/history", {
params: { query: { limit: 50 } },
});
loading.value = false;
-381
View File
@@ -637,295 +637,6 @@ describe("usePageEditorStore", () => {
});
});
describe("panel toggle mutual exclusion", () => {
it("toggleChatPanel opens and closes chat", () => {
const store = usePageEditorStore();
expect(store.showChatPanel).toBe(false);
store.toggleChatPanel();
expect(store.showChatPanel).toBe(true);
store.toggleChatPanel();
expect(store.showChatPanel).toBe(false);
});
it("toggleChatPanel closes revisions and clears selectedRevision when opening chat", () => {
const store = usePageEditorStore();
store.$patch({ showRevisions: true });
store.selectedRevision = {
id: "rev-1",
page_id: TEST_PAGE_ID,
content: "# Old",
content_format: "markdown",
edit_source: "user",
create_time: "2024-01-01T00:00:00",
update_time: "2024-01-01T00:00:00",
} as any;
store.toggleChatPanel();
expect(store.showChatPanel).toBe(true);
expect(store.showRevisions).toBe(false);
expect(store.selectedRevision).toBeNull();
});
it("toggleChatPanel does not touch revisions when closing chat", () => {
const store = usePageEditorStore();
store.$patch({ showChatPanel: true, showRevisions: false });
store.toggleChatPanel();
expect(store.showChatPanel).toBe(false);
expect(store.showRevisions).toBe(false);
});
it("toggleRevisions closes chat when opening revisions", () => {
useDefaultHandlers();
const store = usePageEditorStore();
store.$patch({ historyId: TEST_HISTORY_ID, showChatPanel: true });
store.currentPage = TEST_PAGE_DETAILS;
store.toggleRevisions();
expect(store.showRevisions).toBe(true);
expect(store.showChatPanel).toBe(false);
});
it("toggleRevisions does not touch chat when closing revisions", () => {
const store = usePageEditorStore();
store.$patch({ showRevisions: true, showChatPanel: false });
store.toggleRevisions();
expect(store.showRevisions).toBe(false);
expect(store.showChatPanel).toBe(false);
});
it("$reset clears showChatPanel", () => {
const store = usePageEditorStore();
store.$patch({ showChatPanel: true });
store.$reset();
expect(store.showChatPanel).toBe(false);
});
it("clearCurrentPage clears showChatPanel", () => {
const store = usePageEditorStore();
store.$patch({ showChatPanel: true });
store.clearCurrentPage();
expect(store.showChatPanel).toBe(false);
});
});
describe("chat exchange persistence", () => {
it("get/set/clear exchange ID", () => {
const store = usePageEditorStore();
expect(store.getCurrentChatExchangeId("p-1")).toBeNull();
store.setCurrentChatExchangeId("p-1", "42");
expect(store.getCurrentChatExchangeId("p-1")).toBe("42");
store.setCurrentChatExchangeId("p-2", "99");
expect(store.getCurrentChatExchangeId("p-2")).toBe("99");
store.clearCurrentChatExchangeId("p-1");
expect(store.getCurrentChatExchangeId("p-1")).toBeNull();
expect(store.getCurrentChatExchangeId("p-2")).toBe("99");
});
it("setCurrentChatExchangeId with null stores null", () => {
const store = usePageEditorStore();
store.setCurrentChatExchangeId("p-1", "42");
store.setCurrentChatExchangeId("p-1", null);
expect(store.getCurrentChatExchangeId("p-1")).toBeNull();
});
it("clearCurrentPage preserves chat exchange ID", async () => {
useDefaultHandlers();
const store = usePageEditorStore();
store.$patch({ historyId: TEST_HISTORY_ID });
await store.loadPageById(TEST_PAGE_ID);
store.setCurrentChatExchangeId(TEST_PAGE_ID, "abc123exchangeid");
store.clearCurrentPage();
expect(store.getCurrentChatExchangeId(TEST_PAGE_ID)).toBe("abc123exchangeid");
});
it("deleteCurrentPage clears chat exchange ID", async () => {
server.use(
http.get("/api/pages/{id}", ({ response }: any) => {
return response(200).json(TEST_PAGE_DETAILS);
}) as any,
http.get("/api/pages", ({ response }: any) => {
return response(200).json([]);
}) as any,
http.delete("/api/pages/{id}", ({ response }: any) => {
return response(204).empty();
}) as any,
);
const store = usePageEditorStore();
store.$patch({ historyId: TEST_HISTORY_ID });
await store.loadPageById(TEST_PAGE_ID);
store.setCurrentChatExchangeId(TEST_PAGE_ID, "77");
await store.deleteCurrentPage();
expect(store.getCurrentChatExchangeId(TEST_PAGE_ID)).toBeNull();
});
});
describe("chatError", () => {
it("is null initially", () => {
const store = usePageEditorStore();
expect(store.chatError).toBeNull();
});
it("can be set directly", () => {
const store = usePageEditorStore();
store.chatError = "Failed to load chat";
expect(store.chatError).toBe("Failed to load chat");
});
it("is cleared by clearCurrentPage", () => {
const store = usePageEditorStore();
store.chatError = "some error";
store.clearCurrentPage();
expect(store.chatError).toBeNull();
});
it("is cleared by $reset", () => {
const store = usePageEditorStore();
store.chatError = "some error";
store.$reset();
expect(store.chatError).toBeNull();
});
});
describe("chat history state", () => {
const MOCK_HISTORY = [
{
id: "enc1",
query: "What is this?",
response: "It is a page.",
agent_type: "page_assistant",
timestamp: "2025-06-15T10:00:00Z",
feedback: null,
message_count: 2,
},
{
id: "enc2",
query: "Summarize",
response: "Summary here.",
agent_type: "page_assistant",
timestamp: "2025-06-14T09:00:00Z",
feedback: 1,
message_count: 4,
},
];
it("initial state is empty / not loading / no error", () => {
const store = usePageEditorStore();
expect(store.pageChatHistory).toEqual([]);
expect(store.isLoadingChatHistory).toBe(false);
expect(store.showChatHistory).toBe(false);
expect(store.chatHistoryError).toBeNull();
});
it("loadPageChatHistory populates pageChatHistory on success", async () => {
server.use(
http.get("/api/chat/page/{page_id}/history", ({ response }: any) => {
return response(200).json(MOCK_HISTORY);
}) as any,
);
const store = usePageEditorStore();
await store.loadPageChatHistory(TEST_PAGE_ID);
expect(store.pageChatHistory).toEqual(MOCK_HISTORY);
expect(store.isLoadingChatHistory).toBe(false);
expect(store.chatHistoryError).toBeNull();
});
it("loadPageChatHistory sets chatHistoryError on failure", async () => {
server.use(
http.get("/api/chat/page/{page_id}/history", ({ response }: any) => {
return response("5XX").json({ err_msg: "DB down", err_code: 500 }, { status: 500 });
}) as any,
);
const store = usePageEditorStore();
await store.loadPageChatHistory(TEST_PAGE_ID);
expect(store.pageChatHistory).toEqual([]);
expect(store.chatHistoryError).toBeTruthy();
expect(store.isLoadingChatHistory).toBe(false);
});
it("loadPageChatHistory clears previous error on retry", async () => {
server.use(
http.get("/api/chat/page/{page_id}/history", ({ response }: any) => {
return response(200).json(MOCK_HISTORY);
}) as any,
);
const store = usePageEditorStore();
store.$patch({ chatHistoryError: "old error" });
await store.loadPageChatHistory(TEST_PAGE_ID);
expect(store.chatHistoryError).toBeNull();
});
it("toggleChatHistory toggles showChatHistory", () => {
const store = usePageEditorStore();
expect(store.showChatHistory).toBe(false);
store.toggleChatHistory();
expect(store.showChatHistory).toBe(true);
store.toggleChatHistory();
expect(store.showChatHistory).toBe(false);
});
it("clearCurrentPage resets chat history state", () => {
const store = usePageEditorStore();
store.$patch({
pageChatHistory: MOCK_HISTORY as any,
showChatHistory: true,
chatHistoryError: "some error",
});
store.clearCurrentPage();
expect(store.pageChatHistory).toEqual([]);
expect(store.showChatHistory).toBe(false);
expect(store.chatHistoryError).toBeNull();
});
it("$reset clears chat history state", () => {
const store = usePageEditorStore();
store.$patch({
pageChatHistory: MOCK_HISTORY as any,
showChatHistory: true,
chatHistoryError: "some error",
});
store.$reset();
expect(store.pageChatHistory).toEqual([]);
expect(store.showChatHistory).toBe(false);
expect(store.chatHistoryError).toBeNull();
});
});
describe("synchronous actions", () => {
it("updateContent updates currentContent", () => {
const store = usePageEditorStore();
@@ -1254,98 +965,6 @@ describe("usePageEditorStore", () => {
});
});
describe("deletePageChatExchanges", () => {
const MOCK_HISTORY = [
{
id: "enc1",
query: "First question",
response: "First answer",
agent_type: "page_assistant",
timestamp: "2025-06-15T10:00:00Z",
feedback: null,
message_count: 2,
},
{
id: "enc2",
query: "Second question",
response: "Second answer",
agent_type: "page_assistant",
timestamp: "2025-06-14T09:00:00Z",
feedback: 1,
message_count: 4,
},
{
id: "enc3",
query: "Third question",
response: "Third answer",
agent_type: "page_assistant",
timestamp: "2025-06-13T08:00:00Z",
feedback: null,
message_count: 2,
},
];
it("removes deleted items from pageChatHistory", async () => {
server.use(
http.put("/api/chat/exchanges/batch/delete", ({ response }: any) => {
return response(200).json({});
}) as any,
);
const store = usePageEditorStore();
store.$patch({ pageChatHistory: MOCK_HISTORY as any });
await store.deletePageChatExchanges(TEST_PAGE_ID, ["enc1", "enc3"]);
expect(store.pageChatHistory).toHaveLength(1);
expect(store.pageChatHistory[0]!.id).toBe("enc2");
});
it("clears active exchange when it is in the delete set", async () => {
server.use(
http.put("/api/chat/exchanges/batch/delete", ({ response }: any) => {
return response(200).json({});
}) as any,
);
const store = usePageEditorStore();
store.$patch({ pageChatHistory: MOCK_HISTORY as any });
store.setCurrentChatExchangeId(TEST_PAGE_ID, "enc2");
await store.deletePageChatExchanges(TEST_PAGE_ID, ["enc2"]);
expect(store.getCurrentChatExchangeId(TEST_PAGE_ID)).toBeNull();
});
it("preserves active exchange when it is NOT in the delete set", async () => {
server.use(
http.put("/api/chat/exchanges/batch/delete", ({ response }: any) => {
return response(200).json({});
}) as any,
);
const store = usePageEditorStore();
store.$patch({ pageChatHistory: MOCK_HISTORY as any });
store.setCurrentChatExchangeId(TEST_PAGE_ID, "enc2");
await store.deletePageChatExchanges(TEST_PAGE_ID, ["enc1"]);
expect(store.getCurrentChatExchangeId(TEST_PAGE_ID)).toBe("enc2");
expect(store.pageChatHistory).toHaveLength(2);
});
it("throws and leaves pageChatHistory unchanged on API error", async () => {
server.use(
http.put("/api/chat/exchanges/batch/delete", ({ response }: any) => {
return response("5XX").json({ err_msg: "Server error", err_code: 500 }, { status: 500 });
}) as any,
);
const store = usePageEditorStore();
store.$patch({ pageChatHistory: MOCK_HISTORY as any });
await expect(store.deletePageChatExchanges(TEST_PAGE_ID, ["enc1"])).rejects.toThrow();
expect(store.pageChatHistory).toHaveLength(3);
});
});
describe("standalone mode", () => {
const STANDALONE_PAGE: HistoryPageDetails = {
...TEST_PAGE_DETAILS,
-109
View File
@@ -1,7 +1,6 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { type components, GalaxyApi } from "@/api";
import {
createHistoryPage,
type CreateHistoryPagePayload,
@@ -22,8 +21,6 @@ import { ERROR_MESSAGES } from "@/components/Page/constants";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
import { errorMessageAsString } from "@/utils/simple-error";
type ChatHistoryItem = components["schemas"]["ChatHistoryItemResponse"];
export type PageEditorMode = "history" | "standalone";
export const usePageEditorStore = defineStore("pageEditor", () => {
@@ -47,12 +44,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
// Per-page chat exchange ID persisted across panel close/reopen
const currentChatExchangeIds = useUserLocalStorage<Record<string, string | null>>("history-page-chat-exchange", {});
// Per-page dismissed proposal message IDs
const dismissedChatProposals = useUserLocalStorage<Record<string, string[]>>(
"history-page-dismissed-proposals",
{},
);
// Revision state
const revisions = ref<PageRevisionSummary[]>([]);
const selectedRevision = ref<PageRevisionDetails | null>(null);
@@ -62,13 +53,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
const previousRevisionContent = ref<string | null>(null);
const revisionViewMode = ref<"preview" | "changes_current" | "changes_previous">("preview");
const showRevisions = ref(false);
const showChatPanel = ref(false);
// Chat history sidebar state
const pageChatHistory = ref<ChatHistoryItem[]>([]);
const isLoadingChatHistory = ref(false);
const showChatHistory = ref(false);
const chatHistoryError = ref<string | null>(null);
const hasPages = computed(() => pages.value.length > 0);
const hasCurrentPage = computed(() => currentPage.value !== null);
@@ -208,7 +192,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
await deleteHistoryPage(deletedId);
clearCurrentPageId(historyId.value);
clearCurrentChatExchangeId(deletedId);
clearDismissedProposals(deletedId);
currentPage.value = null;
originalContent.value = "";
currentContent.value = "";
@@ -240,11 +223,7 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
currentContent.value = "";
originalTitle.value = "";
currentTitle.value = "";
showChatPanel.value = false;
chatError.value = null;
pageChatHistory.value = [];
showChatHistory.value = false;
chatHistoryError.value = null;
clearRevisionState();
}
@@ -278,66 +257,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
currentChatExchangeIds.value = rest;
}
// --- Dismissed proposals persistence ---
function getDismissedProposals(pageId: string): string[] {
return dismissedChatProposals.value[pageId] ?? [];
}
function addDismissedProposal(pageId: string, messageId: string) {
const current = dismissedChatProposals.value[pageId] ?? [];
if (!current.includes(messageId)) {
dismissedChatProposals.value = {
...dismissedChatProposals.value,
[pageId]: [...current, messageId],
};
}
}
function clearDismissedProposals(pageId: string) {
const { [pageId]: _removed, ...rest } = dismissedChatProposals.value;
dismissedChatProposals.value = rest;
}
// --- Chat history actions ---
async function loadPageChatHistory(pageId: string) {
isLoadingChatHistory.value = true;
chatHistoryError.value = null;
try {
const { data, error } = await GalaxyApi().GET("/api/chat/page/{page_id}/history", {
params: { path: { page_id: pageId }, query: { limit: 50 } },
});
if (error) {
chatHistoryError.value = errorMessageAsString(error);
} else {
pageChatHistory.value = data ?? [];
}
} catch (e) {
chatHistoryError.value = errorMessageAsString(e);
} finally {
isLoadingChatHistory.value = false;
}
}
async function deletePageChatExchanges(pageId: string, ids: string[]) {
const { error } = await GalaxyApi().PUT("/api/chat/exchanges/batch/delete", {
body: { ids },
});
if (error) {
throw Error(errorMessageAsString(error));
}
pageChatHistory.value = pageChatHistory.value.filter((item) => !ids.includes(item.id));
const activeId = currentChatExchangeIds.value[pageId];
if (activeId && ids.includes(activeId)) {
setCurrentChatExchangeId(pageId, null);
}
}
function toggleChatHistory() {
showChatHistory.value = !showChatHistory.value;
}
async function resolveCurrentPage(forHistoryId: string): Promise<string> {
// Always populate the list so callers can look up titles
await loadPages(forHistoryId);
@@ -433,21 +352,12 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
function toggleRevisions() {
showRevisions.value = !showRevisions.value;
if (showRevisions.value) {
showChatPanel.value = false;
loadRevisions();
} else {
selectedRevision.value = null;
}
}
function toggleChatPanel() {
showChatPanel.value = !showChatPanel.value;
if (showChatPanel.value && showRevisions.value) {
showRevisions.value = false;
selectedRevision.value = null;
}
}
function clearSelectedRevision() {
selectedRevision.value = null;
previousRevisionContent.value = null;
@@ -480,11 +390,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
error.value = null;
chatError.value = null;
historyId.value = null;
showChatPanel.value = false;
pageChatHistory.value = [];
isLoadingChatHistory.value = false;
showChatHistory.value = false;
chatHistoryError.value = null;
clearRevisionState();
}
@@ -525,18 +430,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
getCurrentChatExchangeId,
setCurrentChatExchangeId,
clearCurrentChatExchangeId,
// Dismissed proposals persistence
getDismissedProposals,
addDismissedProposal,
clearDismissedProposals,
// Chat history state
pageChatHistory,
isLoadingChatHistory,
showChatHistory,
chatHistoryError,
loadPageChatHistory,
deletePageChatExchanges,
toggleChatHistory,
// Revision state
revisionViewMode,
revisions,
@@ -548,7 +441,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
isLoadingRevision,
isReverting,
showRevisions,
showChatPanel,
revisionCount,
hasRevisions,
// Revision actions
@@ -556,7 +448,6 @@ export const usePageEditorStore = defineStore("pageEditor", () => {
loadRevision,
restoreRevision,
toggleRevisions,
toggleChatPanel,
clearSelectedRevision,
$reset,
};
@@ -710,26 +710,6 @@ pages:
save_view_button:
type: data-description
selector: 'page save-view button'
chat_button:
type: data-description
selector: 'page chat button'
chat_panel:
type: data-description
selector: 'page chat panel'
chat_input: '.page-chat-panel #chat-input'
chat_send_button: '.page-chat-panel .send-button'
chat_loading: '.page-chat-panel [data-description="chat loading indicator"]'
chat_query_cell: '.page-chat-panel .entry-query .query-text'
chat_response_content: '.page-chat-panel .entry-response .response-content'
chat_new_conversation:
type: data-description
selector: 'new conversation button'
chat_history_button:
type: data-description
selector: 'chat history button'
chat_history_sidebar:
type: data-description
selector: 'page chat history list'
revision_compare_current_button:
type: data-description
selector: 'revision compare current button'
-26
View File
@@ -2339,32 +2339,6 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
self.send_enter(title_input)
self.sleep_for(self.wait_types.UX_RENDER)
def history_page_open_chat(self):
"""Click chat button in page toolbar, wait for chat panel visible."""
self.components.pages.history.chat_button.wait_for_and_click()
self.components.pages.history.chat_panel.wait_for_visible()
def history_page_chat_send_message(self, text):
"""Type into chat input, click send, wait for response."""
chat = self.components.pages.history
chat.chat_input.wait_for_and_send_keys(text)
chat.chat_send_button.wait_for_and_click()
chat.chat_loading.wait_for_absent_or_hidden()
chat.chat_response_content.wait_for_visible()
def history_page_chat_ensure_new(self):
"""Click new conversation button if messages exist."""
chat = self.components.pages.history
if len(chat.chat_query_cell.all()) > 0 or len(chat.chat_response_content.all()) > 0:
chat.chat_new_conversation.wait_for_and_click()
self._history_page_chat_assert_empty()
@retry_during_transitions
def _history_page_chat_assert_empty(self):
chat = self.components.pages.history
assert len(chat.chat_query_cell.all()) == 0
assert len(chat.chat_response_content.all()) == 0
@retry_during_transitions
def click_history_options(self):
component = self.components.history_panel.options_button_icon
+25
View File
@@ -197,6 +197,26 @@ class ChatAPI:
# being masked as 500.
page_obj = self.chat_manager.get_accessible_page(trans, page_id)
# New unified format: notebook context via interface_context JSON.
# Accepts {"contextType": "notebook", "pageId": "<encoded-id>", "historyId": "<encoded-id>"}
# sent by GalaxyAI when the user is on the page editor route. We decode the
# ID here so the rest of the handler can reuse the existing page_id path unchanged.
# Backward-compatible: legacy payload.page_id still works.
if page_id is None:
interface_context = query_context.get("interface_context") if isinstance(query_context, dict) else None
if (
isinstance(interface_context, dict)
and interface_context.get("contextType") == "notebook"
and interface_context.get("pageId")
):
try:
page_id = trans.security.decode_id(interface_context["pageId"])
page_obj = self.chat_manager.get_accessible_page(trans, page_id)
except Exception:
log.warning(
"Ignoring invalid notebook pageId in interface_context: %s", interface_context.get("pageId")
)
try:
if HAS_AGENTS:
full_context: dict[str, Any] = query_context.copy() if query_context else {}
@@ -239,6 +259,11 @@ class ChatAPI:
if payload and payload.entity_context:
full_context["entities"] = payload.entity_context.model_dump(exclude_none=True)
# When we already know this is a notebook chat, bypass the router
# and go straight to page_assistant — no need for an LLM to decide.
if page_id and agent_type == "auto":
agent_type = "page_assistant"
agent_response = await self._get_agent_response_full(
query_text, agent_type, trans, user, job, full_context
)
@@ -1,4 +1,3 @@
from galaxy_test.base.populators import skip_without_agents
from .framework import (
managed_history,
retry_assertion_during_transitions,
@@ -565,105 +564,3 @@ class TestHistoryPages(SeleniumTestCase):
assert "This History's Notebooks" in back_text
assert "Back to Reports" not in back_text
self.screenshot("history_toolbar_controls")
# --- Page Chat Panel Tests ---
@skip_without_agents
@selenium_test
@managed_history
def test_chat_panel_toggle(self):
"""Open page, click chat button, verify panel visible; click again, verify hidden."""
history_id = self.current_history_id()
self.dataset_populator.new_history_page(history_id, title="Chat Toggle", content="# Test")
self.navigate_to_history_pages()
self.components.pages.history.item.wait_for_and_click()
self.components.pages.history.editor.wait_for_visible()
# Open chat panel
self.history_page_open_chat()
self.screenshot("history_page_chat_open")
# Close chat panel
self.components.pages.history.chat_button.wait_for_and_click()
self.components.pages.history.chat_panel.assert_absent_or_hidden()
self.screenshot("history_page_chat_closed")
@skip_without_agents
@selenium_test
@managed_history
def test_page_chat_greeting_flow(self):
"""Send greeting, verify query cell and response content."""
history_id = self.current_history_id()
self.dataset_populator.new_history_page(history_id, title="Chat Greeting", content="# Test")
self.navigate_to_history_pages()
self.components.pages.history.item.wait_for_and_click()
self.components.pages.history.editor.wait_for_visible()
self.history_page_open_chat()
self.history_page_chat_ensure_new()
self.history_page_chat_send_message("Hello!")
chat = self.components.pages.history
assert chat.chat_query_cell.wait_for_text() == "Hello!"
@retry_assertion_during_transitions
def assert_response():
text = chat.chat_response_content.wait_for_text()
assert len(text) > 0
assert_response()
self.screenshot("history_page_chat_greeting")
@skip_without_agents
@selenium_test
@managed_history
def test_page_chat_multi_turn(self):
"""Send two messages, assert 2 query cells and 2 response cells."""
history_id = self.current_history_id()
self.dataset_populator.new_history_page(history_id, title="Chat Multi", content="# Test")
self.navigate_to_history_pages()
self.components.pages.history.item.wait_for_and_click()
self.components.pages.history.editor.wait_for_visible()
self.history_page_open_chat()
self.history_page_chat_ensure_new()
self.history_page_chat_send_message("Hello!")
self.history_page_chat_send_message("Summarize this history")
chat = self.components.pages.history
@retry_assertion_during_transitions
def assert_two_exchanges():
assert len(chat.chat_query_cell.all()) == 2
assert len(chat.chat_response_content.all()) >= 2
assert_two_exchanges()
self.screenshot("history_page_chat_multi_turn")
@skip_without_agents
@selenium_test
@managed_history
def test_page_chat_new_conversation(self):
"""Send message, click new conversation, assert chat is empty."""
history_id = self.current_history_id()
self.dataset_populator.new_history_page(history_id, title="Chat New Conv", content="# Test")
self.navigate_to_history_pages()
self.components.pages.history.item.wait_for_and_click()
self.components.pages.history.editor.wait_for_visible()
self.history_page_open_chat()
self.history_page_chat_ensure_new()
self.history_page_chat_send_message("Hello!")
# Verify message exists
chat = self.components.pages.history
assert len(chat.chat_query_cell.all()) >= 1
# New conversation
chat.chat_new_conversation.wait_for_and_click()
self._history_page_chat_assert_empty()
self.screenshot("history_page_chat_new_conversation")