Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions web/src/lib/session-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
72 changes: 61 additions & 11 deletions web/src/lib/session-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
27 changes: 14 additions & 13 deletions web/src/pages/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(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;

Expand All @@ -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(() => {});
};
Expand Down
Loading