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:
committed by
kshitijk4poor
parent
5e93075fd5
commit
dc5cb0a440
+2
-1
@@ -48,6 +48,7 @@
|
|||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { shouldRefreshSessions } from "@/lib/session-refresh";
|
||||||
import type {
|
import type {
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
@@ -805,8 +806,12 @@ export default function SessionsPage() {
|
|||||||
};
|
};
|
||||||
}, [setEnd]);
|
}, [setEnd]);
|
||||||
|
|
||||||
const loadSessions = useCallback((p: number) => {
|
const loadSessions = useCallback((p: number, silent = false) => {
|
||||||
setLoading(true);
|
// ``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
|
api
|
||||||
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
|
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
@@ -814,7 +819,9 @@ export default function SessionsPage() {
|
|||||||
setTotal(resp.total);
|
setTotal(resp.total);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStats = useCallback(() => {
|
const loadStats = useCallback(() => {
|
||||||
@@ -828,6 +835,15 @@ export default function SessionsPage() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
loadSessions(page);
|
loadSessions(page);
|
||||||
refreshEmptyCount();
|
refreshEmptyCount();
|
||||||
@@ -841,13 +857,27 @@ export default function SessionsPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
api
|
api
|
||||||
.getSessions(50)
|
.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(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
loadOverview();
|
loadOverview();
|
||||||
const id = setInterval(loadOverview, 5000);
|
const id = setInterval(loadOverview, 5000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, [loadSessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = logScrollRef.current;
|
const el = logScrollRef.current;
|
||||||
|
|||||||
@@ -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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user