mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
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:
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user