From 57c423cdb1fd3e34d789d8d7e2eb72a71c43db63 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 16 Oct 2026 10:15:26 +0700 Subject: [PATCH] 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. --- client/src/components/StatusBar.tsx | 96 +++++++++++-------- .../conversation/ConversationView.tsx | 33 +++++-- client/src/components/lanes/SessionReplay.tsx | 6 +- .../src/lib/__tests__/sessionChange.test.ts | 34 +++++++ client/src/lib/sessionChange.ts | 32 +++++++ client/src/pages/Dashboard.tsx | 7 +- client/src/pages/SessionDetail.tsx | 6 +- client/src/pages/Workspace.tsx | 49 +++++----- 8 files changed, 187 insertions(+), 76 deletions(-) create mode 100644 client/src/lib/__tests__/sessionChange.test.ts create mode 100644 client/src/lib/sessionChange.ts diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index 20116d12..86bd0a9e 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -9,8 +9,12 @@ * Workspace page, the open workspace's total (`?lane=` on the same call: the * sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for * the URL's `session`, or the focused pane's session from the page's run poll, - * `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and - * whenever the URL names another workspace or session. Each figure carries its + * `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another + * workspace or session, then at most every 30 s while events or session + * updates arrive (spend only moves with work) and every 2 min otherwise, while + * the tab is visible. The session counts re-read only when a session is + * created or its status / waiting / archived state changes (every 60 s + * otherwise). Each figure carries its * token total, and hovering it lists tokens and cost per model. At the right * end, the plan quota spent per native tool (`GET /api/pricing/quota`, every * 5 min while visible; a click forces a re-read): one "N% used" chip per tool — @@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format"; import { AiToolIcon } from "./AiToolIcon"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { useSpin } from "../hooks/useSpin"; +import { sessionChangeMatcher } from "../lib/sessionChange"; import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree"; +/** Spend moves only with work: at most this often while events arrive… */ const COST_POLL_MS = 30_000; +/** …and this often regardless (date rollover, budget period, a missed frame). */ +const COST_FALLBACK_MS = 120_000; +/** The counts' safety poll; session frames that change a count drive the rest. */ +const COUNTS_FALLBACK_MS = 60_000; const QUOTA_POLL_MS = 5 * 60_000; const QUOTA_TOOL_LABEL: Record = { claude: "Claude", @@ -384,51 +394,57 @@ export function StatusBar({ .catch(() => undefined); }, []); useEffect(loadCounts, [loadCounts]); - useLiveRefresh(loadCounts, { - intervalMs: 15_000, - match: (msg) => msg.type === "session_updated" || msg.type === "session_created", - }); + // Only a frame that moves a session between the counted states (a new + // session, a status / waiting / archived change) re-reads: a session row is + // re-broadcast on almost every hook. + const [countsChanged] = useState(() => sessionChangeMatcher()); + useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged }); + // Bumped on every lane/session switch: an answer for the previous one is dropped. + const costGen = useRef(0); + const loadCosts = useCallback(() => { + const gen = costGen.current; + const live = () => gen === costGen.current; + api.pricing + .totalCost({ date: localDate() }) + .then((r) => live() && setToday(r)) + .catch(() => undefined); + api.budgets + .status() + .then(({ budgets }) => { + const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct); + if (live()) setBudget(hit[0] ?? null); + }) + .catch(() => undefined); + if (lane !== null) { + api.pricing + .totalCost({ lane }) + .then((r) => live() && setLaneCost(r)) + .catch(() => undefined); + } + if (sessionId) { + api.pricing + .sessionCost(sessionId) + .then((r) => live() && setSessionCost(r)) + .catch(() => undefined); + } + }, [lane, sessionId]); useEffect(() => { - let live = true; + costGen.current++; // Another workspace or session: never show the previous one's figure. setLaneCost(null); setSessionCost(null); - const tick = () => { - if (document.visibilityState !== "visible") return; - api.pricing - .totalCost({ date: localDate() }) - .then((r) => live && setToday(r)) - .catch(() => undefined); - api.budgets - .status() - .then(({ budgets }) => { - const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct); - if (live) setBudget(hit[0] ?? null); - }) - .catch(() => undefined); - if (lane !== null) { - api.pricing - .totalCost({ lane }) - .then((r) => live && setLaneCost(r)) - .catch(() => undefined); - } - if (sessionId) { - api.pricing - .sessionCost(sessionId) - .then((r) => live && setSessionCost(r)) - .catch(() => undefined); - } - }; - tick(); - const id = setInterval(tick, COST_POLL_MS); - document.addEventListener("visibilitychange", tick); + if (document.visibilityState === "visible") loadCosts(); return () => { - live = false; - clearInterval(id); - document.removeEventListener("visibilitychange", tick); + costGen.current++; }; - }, [lane, sessionId]); + }, [loadCosts]); + useLiveRefresh(loadCosts, { + intervalMs: COST_FALLBACK_MS, + throttleMs: COST_POLL_MS, + match: (msg) => + msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created", + }); return (