diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f02841275b..783d3b6bc5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1948,6 +1948,9 @@ export interface SessionInfo { output_tokens: number; preview: string | null; parent_session_id?: string | null; + /** Owning profile stamped by the list/detail endpoints (the store the row + * was read from). Absent on search-endpoint rows, which carry no stamp. */ + profile?: string; } export interface SessionLatestDescendantResponse { diff --git a/web/src/pages/SessionsPage.test.tsx b/web/src/pages/SessionsPage.test.tsx new file mode 100644 index 0000000000..a621835544 --- /dev/null +++ b/web/src/pages/SessionsPage.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const apiMocks = vi.hoisted(() => ({ + getSessions: vi.fn(), + getSessionMessages: vi.fn(), + getEmptySessionsCount: vi.fn(), + getStatus: vi.fn(), + searchSessions: vi.fn(), + importSessions: vi.fn(), + exportSessionUrl: vi.fn(), + renameSession: vi.fn(), + pruneSessions: vi.fn(), + deleteSession: vi.fn(), + deleteEmptySessions: vi.fn(), + bulkDeleteSessions: vi.fn(), + getProfiles: vi.fn(), + getActiveProfile: vi.fn(), + getSessionStats: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + api: apiMocks, + // ProfileProvider mirrors its selection into the api module. + setManagementProfile: vi.fn(), + getManagementProfile: vi.fn(() => ""), +})); +vi.mock("@/components/PlatformsCard", () => ({ PlatformsCard: () => null })); +vi.mock("@/components/Markdown", () => ({ Markdown: () => null })); + +let container: HTMLDivElement; +let root: Root; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function waitFor(cond: () => boolean, timeoutMs = 5000) { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition never became true"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } +} + +function click(el: Element | null) { + if (!el) throw new Error("element not rendered"); + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); +} + +const button = (label: string) => document.querySelector(`button[aria-label="${label}"]`); + +async function renderSessionsPage(rows: Record[]) { + // Page list uses limit 20; the overview tab's recent-cards fetch uses 50 — + // keep the overview empty so the list view (with row actions) renders. + apiMocks.getSessions.mockImplementation(async (limit: number) => ({ + sessions: limit >= 50 ? [] : rows, + total: limit >= 50 ? 0 : rows.length, + limit, + offset: 0, + })); + const [{ default: SessionsPage }, { I18nProvider }, { SystemActionsProvider }, { ProfileProvider }, { PageHeaderProvider }] = + await Promise.all([ + import("./SessionsPage"), + import("@/i18n"), + import("@/contexts/SystemActions"), + import("@/contexts/ProfileProvider"), + import("@/contexts/PageHeaderProvider"), + ]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + + + + + + + + + + + , + ), + ); + await waitFor(() => Boolean(button("Delete session"))); +} + +beforeEach(() => { + for (const fn of Object.values(apiMocks)) fn.mockReset(); + apiMocks.getStatus.mockResolvedValue({}); + apiMocks.getEmptySessionsCount.mockResolvedValue({ count: 0 }); + apiMocks.getProfiles.mockResolvedValue({ profiles: [] }); + // active === current keeps the management profile "" — the precondition + // under which an unstamped request hits the process's own store. + apiMocks.getActiveProfile.mockResolvedValue({ current: "default", active: "default" }); + apiMocks.getSessionStats.mockResolvedValue({ by_source: {} }); + apiMocks.getSessionMessages.mockResolvedValue({ messages: [] }); + apiMocks.deleteSession.mockResolvedValue({ ok: true }); + apiMocks.renameSession.mockResolvedValue({ ok: true, title: "Renamed" }); + apiMocks.exportSessionUrl.mockReturnValue("/api/sessions/x/export"); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500 }))); + vi.stubGlobal("ResizeObserver", class { disconnect() {} observe() {} unobserve() {} }); + // gsap ticks through rAF; a synchronous callback recurses to death. + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number); + vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id)); + vi.stubGlobal("matchMedia", () => ({ addEventListener() {}, matches: false, media: "", removeEventListener() {} })); + sessionStorage.clear(); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("SessionsPage per-row profile routing (#99387)", () => { + it("sends every per-row request to the row's owning profile, not the management default", async () => { + await renderSessionsPage([ + { id: "sid-guanli", profile: "guanli", source: "cli", model: null, title: "Managed", started_at: 1, ended_at: null, + last_active: 1, is_active: false, message_count: 2, tool_call_count: 0, input_tokens: 1, output_tokens: 1, preview: "hi" }, + ]); + + // expand → transcript read + await act(async () => click(button("Delete session")!.closest("div.cursor-pointer"))); + await waitFor(() => apiMocks.getSessionMessages.mock.calls.length > 0); + expect(apiMocks.getSessionMessages).toHaveBeenCalledWith("sid-guanli", "guanli"); + + await act(async () => click(button("Export session"))); + expect(apiMocks.exportSessionUrl).toHaveBeenCalledWith("sid-guanli", "guanli"); + + await act(async () => click(button("Rename session"))); + const input = document.querySelector('input[placeholder="Session title"]'); + if (!input) throw new Error("rename input not rendered"); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Renamed"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => click(button("Save title"))); + expect(apiMocks.renameSession).toHaveBeenCalledWith("sid-guanli", "Renamed", "guanli"); + + await act(async () => click(button("Delete session"))); + await waitFor(() => Boolean(document.querySelector('[role="alertdialog"]'))); + const confirm = Array.from(document.querySelectorAll('[role="alertdialog"] button')).find( + (b) => b.textContent?.trim() === "Delete", + ); + await act(async () => click(confirm ?? null)); + expect(apiMocks.deleteSession).toHaveBeenCalledWith("sid-guanli", "guanli"); + }); +}); diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 09140f9e41..8115cd5742 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -487,7 +487,7 @@ function SessionRow({ if (!isExpanded || messages !== null) return; let cancelled = false; api - .getSessionMessages(session.id) + .getSessionMessages(session.id, session.profile) .then((resp) => { if (!cancelled) setMessages(resp.messages); }) @@ -497,7 +497,7 @@ function SessionRow({ return () => { cancelled = true; }; - }, [isExpanded, session.id, messages]); + }, [isExpanded, session.id, session.profile, messages]); const sourceKey = session.source?.split(":")[0]; const sourceInfo = (session.source @@ -1274,11 +1274,22 @@ export default function SessionsPage() { }; }, [search, sessionQueryOptions]); + // The profile a listed row was read from — the store that owns it. Every + // per-row request (delete, rename, export, messages) must go there, not to + // the global management profile, which lags the row (it stays "" while the + // sticky active profile equals the dashboard process's own, so the request + // hits the process store — a delete then "succeeds" as already_absent). + // Search rows carry no stamp: undefined falls back to the management profile. + const rowProfile = useCallback( + (id: string) => sessions.find((s) => s.id === id)?.profile, + [sessions], + ); + const sessionDelete = useConfirmDelete({ onDelete: useCallback( async (id: string) => { try { - await api.deleteSession(id); + await api.deleteSession(id, rowProfile(id)); setSessions((prev) => prev.filter((s) => s.id !== id)); setTotal((prev) => prev - 1); if (expandedId === id) setExpandedId(null); @@ -1304,6 +1315,7 @@ export default function SessionsPage() { [ expandedId, refreshEmptyCount, + rowProfile, showToast, loadStats, t.sessions.sessionDeleted, @@ -1373,7 +1385,13 @@ export default function SessionsPage() { } setDeletingSelected(true); try { - const resp = await api.bulkDeleteSessions(ids); + // The selection comes from one listed page, so its rows share one + // owning profile; a mixed selection falls back to the management profile. + const owners = new Set(ids.map(rowProfile)); + const resp = await api.bulkDeleteSessions( + ids, + owners.size === 1 ? [...owners][0] : undefined, + ); showToast( t.sessions.selectedSessionsDeleted.replace( "{count}", @@ -1404,6 +1422,7 @@ export default function SessionsPage() { loadSessions, page, refreshEmptyCount, + rowProfile, selectedIds, showToast, t.sessions.failedToDeleteSelected, @@ -1449,7 +1468,7 @@ export default function SessionsPage() { const handleRename = useCallback( async (id: string, title: string) => { try { - await api.renameSession(id, title); + await api.renameSession(id, title, rowProfile(id)); setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)), ); @@ -1462,13 +1481,13 @@ export default function SessionsPage() { showToast("Failed to rename session", "error"); } }, - [showToast, loadStats], + [rowProfile, showToast, loadStats], ); const handleExport = useCallback( async (id: string) => { try { - const res = await fetch(api.exportSessionUrl(id), { + const res = await fetch(api.exportSessionUrl(id, rowProfile(id)), { credentials: "include", headers: { "X-Hermes-Session-Token": @@ -1488,7 +1507,7 @@ export default function SessionsPage() { showToast("Failed to export session", "error"); } }, - [showToast], + [rowProfile, showToast], ); const handlePrune = useCallback(async () => {