fix(dashboard): route every per-row session request at the row's owning profile (salvage #99387)

The Sessions page listed rows stamped with their owning profile but sent
delete/bulk-delete to the global management profile, which stays "" while
the sticky active profile equals the dashboard process's own — so the
request opened the process store, missed, and returned a false
`already_absent` success while the row survived in profiles/<p>/state.db.

Same class at three sibling sites the PR didn't touch: renameSession,
exportSessionUrl and the expanded-row getSessionMessages read. One
`rowProfile(id)` owner now feeds all four (+ bulk delete); unstamped rows
(search results) fall back to the management profile as before.

Test trimmed to one jsdom scenario driving all four row actions.

Co-authored-by: Teknium <teknium@nousresearch.com>
This commit is contained in:
liuhao1024
2026-09-02 03:42:11 -07:00
committed by Teknium
parent 2ce6f5c538
commit 74b00d7a97
3 changed files with 184 additions and 8 deletions

View File

@@ -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 {

View File

@@ -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<string, unknown>[]) {
// 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(
<I18nProvider>
<MemoryRouter>
<SystemActionsProvider>
<ProfileProvider>
<PageHeaderProvider pluginTabs={[]}>
<SessionsPage />
</PageHeaderProvider>
</ProfileProvider>
</SystemActionsProvider>
</MemoryRouter>
</I18nProvider>,
),
);
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<HTMLInputElement>('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");
});
});

View File

@@ -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 () => {