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:
@@ -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<ToolQuota["id"], string> = {
|
||||
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 (
|
||||
<footer
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* A caller that knows the session has ended passes `ended`: nothing more is
|
||||
* being written, so the catch-up poll drops from 3 s to 60 s and the
|
||||
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
|
||||
* the view being shown again still fetch the tail at once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -79,6 +83,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
|
||||
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||
// the conversation from missed/late WebSocket frames.
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
// An ended session writes nothing more: a slow safety poll only, in case it was
|
||||
// resumed somewhere whose frames this view missed.
|
||||
const ENDED_POLL_INTERVAL_MS = 60000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
@@ -110,6 +117,8 @@ interface ConversationViewProps {
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
/** The session has ended (and no pane holds it): poll slowly, if at all. */
|
||||
ended?: boolean;
|
||||
}
|
||||
|
||||
function ConversationViewPane({
|
||||
@@ -124,6 +133,7 @@ function ConversationViewPane({
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
ended = false,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
@@ -188,14 +198,18 @@ function ConversationViewPane({
|
||||
}
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
// An ended session spawns no new sub-agents: the load above (re-run when
|
||||
// it ends, for the last ones) is enough.
|
||||
const interval = ended
|
||||
? undefined
|
||||
: window.setInterval(() => {
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, ended]);
|
||||
|
||||
// Sync external initialTranscriptId to internal state
|
||||
useEffect(() => {
|
||||
@@ -355,9 +369,12 @@ function ConversationViewPane({
|
||||
let interval: number | null = null;
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
interval = window.setInterval(
|
||||
() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
},
|
||||
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
function stop() {
|
||||
if (interval !== null) {
|
||||
@@ -382,7 +399,7 @@ function ConversationViewPane({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
}, [fetchNewMessages, onScreen, ended]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
|
||||
@@ -132,7 +132,11 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
ended={!livePane && !!session && session.status !== "active"}
|
||||
/>
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user