From 6380f7498df678bdbcdccda52e77890992d6c466 Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Wed, 1 Jul 2026 17:05:32 +0000 Subject: [PATCH] fix(web): refresh active session rows when metadata changes --- web/src/lib/session-refresh.test.ts | 44 ++++++++++++++++-- web/src/lib/session-refresh.ts | 72 ++++++++++++++++++++++++----- web/src/pages/SessionsPage.tsx | 27 +++++------ 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/web/src/lib/session-refresh.test.ts b/web/src/lib/session-refresh.test.ts index 0348835860a3..983935ff3b83 100644 --- a/web/src/lib/session-refresh.test.ts +++ b/web/src/lib/session-refresh.test.ts @@ -2,20 +2,54 @@ import { describe, it, expect } from "vitest"; import { shouldRefreshSessions } from "./session-refresh"; describe("shouldRefreshSessions", () => { + const session = { + id: "s1", + title: "Active chat", + last_active: 1_782_902_400, + is_active: true, + message_count: 0, + tool_call_count: 0, + preview: "", + }; + it("returns false on the first poll (no baseline yet)", () => { - expect(shouldRefreshSessions(null, "s2")).toBe(false); + expect(shouldRefreshSessions(null, [{ ...session, id: "s2" }])).toBe( + false, + ); }); it("returns false when the current response has no sessions", () => { - expect(shouldRefreshSessions("s1", null)).toBe(false); - expect(shouldRefreshSessions(null, null)).toBe(false); + expect(shouldRefreshSessions([session], [])).toBe(false); + expect(shouldRefreshSessions(null, [])).toBe(false); }); it("returns false when the newest session id is unchanged", () => { - expect(shouldRefreshSessions("s1", "s1")).toBe(false); + expect(shouldRefreshSessions([session], [{ ...session }])).toBe(false); }); it("returns true when a new session appears at the head of the list", () => { - expect(shouldRefreshSessions("s1", "s2")).toBe(true); + expect( + shouldRefreshSessions([session], [{ ...session, id: "s2" }]), + ).toBe(true); + }); + + it("returns true when sessions appear after an empty baseline", () => { + expect(shouldRefreshSessions([], [session])).toBe(true); + }); + + it("returns true when an active session changes without a new newest id", () => { + expect( + shouldRefreshSessions( + [session], + [ + { + ...session, + message_count: 2, + preview: "Hello there", + last_active: 1_782_902_460, + }, + ], + ), + ).toBe(true); }); }); diff --git a/web/src/lib/session-refresh.ts b/web/src/lib/session-refresh.ts index 637c7f00eb1f..fb01a1769c92 100644 --- a/web/src/lib/session-refresh.ts +++ b/web/src/lib/session-refresh.ts @@ -6,21 +6,71 @@ * processes that share the same SQLite session DB. There is no * inter-process push channel, so the Sessions page polls the 50 newest * sessions every few seconds (the "overview" poll). When that poll - * surfaces a session id at the head of the list that we have not seen - * before, a new session was created in another process and the - * paginated list is stale — refresh it. + * surfaces a new session at the head of the list, or detects visible + * changes to an active session already in the list, the paginated list + * is stale and should be refreshed silently. * - * Returns false on the very first poll (no baseline yet) and when - * either id is null (empty DB / transient empty response), so we never - * trigger a spurious reload on mount or while the DB is empty. + * Returns false on the very first poll (no baseline yet) and when the + * current response is empty, so we never trigger a spurious reload on + * mount or while the DB is empty. */ +export interface SessionRefreshSnapshotItem { + id: string; + title?: string | null; + last_active?: number | null; + ended_at?: number | null; + is_active?: boolean; + message_count?: number | null; + tool_call_count?: number | null; + input_tokens?: number | null; + output_tokens?: number | null; + preview?: string | null; +} + export function shouldRefreshSessions( - prevNewestId: string | null, - currentNewestId: string | null, + prevSessions: readonly SessionRefreshSnapshotItem[] | null, + currentSessions: readonly SessionRefreshSnapshotItem[], +): boolean { + if (prevSessions === null || currentSessions.length === 0) { + return false; + } + if (prevSessions.length === 0) { + return true; + } + + const prevNewestId = prevSessions[0]?.id ?? null; + const currentNewestId = currentSessions[0]?.id ?? null; + if (prevNewestId === null || currentNewestId === null) { + return false; + } + if (prevNewestId !== currentNewestId) { + return true; + } + + const prevById = new Map(prevSessions.map((session) => [session.id, session])); + return currentSessions.some((current) => { + const previous = prevById.get(current.id); + if (!previous || (!previous.is_active && !current.is_active)) { + return false; + } + + return hasVisibleSessionChange(previous, current); + }); +} + +function hasVisibleSessionChange( + previous: SessionRefreshSnapshotItem, + current: SessionRefreshSnapshotItem, ): boolean { return ( - prevNewestId !== null && - currentNewestId !== null && - prevNewestId !== currentNewestId + previous.title !== current.title || + previous.last_active !== current.last_active || + previous.ended_at !== current.ended_at || + previous.is_active !== current.is_active || + previous.message_count !== current.message_count || + previous.tool_call_count !== current.tool_call_count || + previous.input_tokens !== current.input_tokens || + previous.output_tokens !== current.output_tokens || + previous.preview !== current.preview ); } diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 1746cc481843..83de7543f250 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { api } from "@/lib/api"; import { shouldRefreshSessions } from "@/lib/session-refresh"; +import type { SessionRefreshSnapshotItem } from "@/lib/session-refresh"; import type { SessionInfo, SessionMessage, @@ -835,12 +836,15 @@ export default function SessionsPage() { loadStats(); }, [loadStats]); - // Refs for the overview poll's new-session detection. The poll effect + // Refs for the overview poll's stale-list detection. The poll effect // below is mounted once with stable deps, so it reads the current page - // and the last-seen newest session id through refs instead of capturing - // stale values. ``newestSeenRef`` starts null so the first poll sets a - // baseline without triggering a redundant reload (mount already loads). - const newestSeenRef = useRef(null); + // and the last overview snapshot through refs instead of capturing + // stale values. ``overviewSnapshotRef`` starts null so the first poll + // sets a baseline without triggering a redundant reload (mount already + // loads). + const overviewSnapshotRef = useRef< + readonly SessionRefreshSnapshotItem[] | null + >(null); const pageRef = useRef(page); pageRef.current = page; @@ -861,16 +865,13 @@ export default function SessionsPage() { setOverviewSessions(r.sessions); // The dashboard server and a terminal CLI are separate // processes sharing one session DB — there is no push channel, - // so we detect sessions created in another process here. The - // overview poll already fetches the 50 newest sessions, so we - // reuse its head id as a cheap change signal: when it changes, - // silently refresh the paginated list so the new session shows - // up in real time without a visible loading flicker. - const newest = r.sessions[0]?.id ?? null; - if (shouldRefreshSessions(newestSeenRef.current, newest)) { + // so we detect new sessions and active-session changes from the + // overview poll and silently refresh the paginated list without + // a visible loading flicker. + if (shouldRefreshSessions(overviewSnapshotRef.current, r.sessions)) { loadSessions(pageRef.current, true); } - newestSeenRef.current = newest; + overviewSnapshotRef.current = r.sessions; }) .catch(() => {}); };