mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
5e93075fd5
commit
dc5cb0a440
5 changed files with 100 additions and 6 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
web/src/lib/session-refresh.test.ts
Normal file
21
web/src/lib/session-refresh.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
web/src/lib/session-refresh.ts
Normal file
26
web/src/lib/session-refresh.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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
16
web/vitest.config.ts
Normal 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}"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue