fix(dashboard): refresh Sessions list in real time when new sessions are created

The dashboard's FastAPI server and a terminal CLI are separate processes
sharing one SQLite session DB; there is no inter-process push channel.
The Sessions page polled the 50 newest sessions every 5s for the
"overview" card but only re-fetched the paginated sessions list on page
change or delete, so a session started in a terminal never appeared in
the list until the user navigated.

Reuse the existing 5s overview poll as a change signal: when the head
session id changes, silently reload the current page (no loading
spinner flicker, no scroll/reset of expanded rows or bulk selection,
which are keyed by id). The detection logic is extracted into a pure
shouldRefreshSessions() helper with unit tests. Adds a minimal vitest
setup for web/ (test script + config).
This commit is contained in:
Alex Yates 2026-06-18 19:06:57 -07:00 committed by kshitijk4poor
parent 5e93075fd5
commit dc5cb0a440
5 changed files with 100 additions and 6 deletions

View file

@ -48,6 +48,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"
}
}

View file

@ -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);
});
});

View file

@ -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
);
}

View file

@ -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<string | null>(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;

16
web/vitest.config.ts Normal file
View file

@ -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}"],
},
});