diff --git a/web/package.json b/web/package.json index 665a780c71d..6666773c737 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "typecheck": "tsc -p . --noEmit" + "typecheck": "tsc -p . --noEmit", + "test": "vitest run" }, "dependencies": { "@nous-research/ui": "0.18.2", @@ -48,6 +49,7 @@ "three": "^0.180.0", "typescript": "^6.0.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.5" } } diff --git a/web/src/lib/session-refresh.test.ts b/web/src/lib/session-refresh.test.ts new file mode 100644 index 00000000000..0348835860a --- /dev/null +++ b/web/src/lib/session-refresh.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { shouldRefreshSessions } from "./session-refresh"; + +describe("shouldRefreshSessions", () => { + it("returns false on the first poll (no baseline yet)", () => { + expect(shouldRefreshSessions(null, "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); + }); + + it("returns false when the newest session id is unchanged", () => { + expect(shouldRefreshSessions("s1", "s1")).toBe(false); + }); + + it("returns true when a new session appears at the head of the list", () => { + expect(shouldRefreshSessions("s1", "s2")).toBe(true); + }); +}); diff --git a/web/src/lib/session-refresh.ts b/web/src/lib/session-refresh.ts new file mode 100644 index 00000000000..637c7f00eb1 --- /dev/null +++ b/web/src/lib/session-refresh.ts @@ -0,0 +1,26 @@ +/** + * Decide whether the paginated sessions list should be silently + * re-fetched after an overview poll. + * + * The dashboard's FastAPI server and a terminal CLI are separate + * 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. + * + * 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. + */ +export function shouldRefreshSessions( + prevNewestId: string | null, + currentNewestId: string | null, +): boolean { + return ( + prevNewestId !== null && + currentNewestId !== null && + prevNewestId !== currentNewestId + ); +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 2d70c399af2..1746cc48184 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -30,6 +30,7 @@ import { Archive, } from "lucide-react"; import { api } from "@/lib/api"; +import { shouldRefreshSessions } from "@/lib/session-refresh"; import type { SessionInfo, SessionMessage, @@ -805,8 +806,12 @@ export default function SessionsPage() { }; }, [setEnd]); - const loadSessions = useCallback((p: number) => { - setLoading(true); + const loadSessions = useCallback((p: number, silent = false) => { + // ``silent`` skips the loading spinner so background refreshes + // (triggered when the overview poll detects a new session from + // another process) don't flicker the whole page or drop the user's + // scroll position. + if (!silent) setLoading(true); api .getSessions(PAGE_SIZE, p * PAGE_SIZE) .then((resp) => { @@ -814,7 +819,9 @@ export default function SessionsPage() { setTotal(resp.total); }) .catch(() => {}) - .finally(() => setLoading(false)); + .finally(() => { + if (!silent) setLoading(false); + }); }, []); const loadStats = useCallback(() => { @@ -828,6 +835,15 @@ export default function SessionsPage() { loadStats(); }, [loadStats]); + // Refs for the overview poll's new-session 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); + const pageRef = useRef(page); + pageRef.current = page; + useEffect(() => { loadSessions(page); refreshEmptyCount(); @@ -841,13 +857,27 @@ export default function SessionsPage() { .catch(() => {}); api .getSessions(50) - .then((r) => setOverviewSessions(r.sessions)) + .then((r) => { + 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)) { + loadSessions(pageRef.current, true); + } + newestSeenRef.current = newest; + }) .catch(() => {}); }; loadOverview(); const id = setInterval(loadOverview, 5000); return () => clearInterval(id); - }, []); + }, [loadSessions]); useEffect(() => { const el = logScrollRef.current; diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000000..34baae684e8 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.{ts,tsx}"], + }, +});