perf(client): cut needless polling for local users

- ConversationView: an ended session (SessionDetail, SessionReplay pass
  `ended`) polls its transcript every 60 s instead of 3 s and stops
  rescanning sub-agents; new_event, reconnect and show still fetch at once.
- Workspace: GET /api/run follows run-moving WS messages (lane run
  columns, session created/status change, runs_interrupted) with a 30 s
  fallback, instead of a blind 5 s poll.
- StatusBar: session counts re-read only when a session's
  status/waiting/archived state changes (60 s fallback); costs re-read at
  most every 30 s while work is happening and every 2 min otherwise.
- Dashboard recent activity: 60 s safety poll (was 10 s) plus remote
  import messages; WS still prepends new events.
- New lib/sessionChange.ts matcher with tests.
This commit is contained in:
2026-10-16 10:15:26 +07:00
parent ecbbb4dd8f
commit 57c423cdb1
8 changed files with 187 additions and 76 deletions
+25 -24
View File
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
@@ -1044,9 +1047,10 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
// fallback poll below, and a fresh-but-identical payload would otherwise
// re-render the lane strip, the lane card and the whole console area for
// nothing.
const refreshList = useCallback(() => {
api.run
.list()
@@ -1061,33 +1065,30 @@ export function Workspace() {
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
}, [activeRuns]);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
// status flips appear within seconds without needing a manual refresh.
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
// starting, ending or learning its session in slot 0 moves the lane's run
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
// shows as a new session, one ending as a session status change, and a pane
// dying under its agent as `runs_interrupted`. This page's own starts, stops
// and a shown terminal's exit call `refreshList` directly
// (`onRunsChanged`). What none of those cover — a shell tab opened or closed
// from another browser, server-boot reconciliation — is caught by the
// 30 s fallback poll, on reconnect, and when the tab is shown or focused.
const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf));
const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`));
useEffect(() => {
refreshList();
const tick = setInterval(() => {
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useLiveRefresh(refreshList, {
intervalMs: 30_000,
throttleMs: 1500,
match: (msg) =>
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
});
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
return () => window.removeEventListener("focus", onFocus);
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;