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
|
* 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
|
* 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,
|
* 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
|
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
|
||||||
* whenever the URL names another workspace or session. Each figure carries its
|
* 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
|
* 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
|
* 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 —
|
* 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 { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
|
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
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;
|
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_POLL_MS = 5 * 60_000;
|
||||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||||
claude: "Claude",
|
claude: "Claude",
|
||||||
@@ -384,51 +394,57 @@ export function StatusBar({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(loadCounts, [loadCounts]);
|
useEffect(loadCounts, [loadCounts]);
|
||||||
useLiveRefresh(loadCounts, {
|
// Only a frame that moves a session between the counted states (a new
|
||||||
intervalMs: 15_000,
|
// session, a status / waiting / archived change) re-reads: a session row is
|
||||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
// 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(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
costGen.current++;
|
||||||
// Another workspace or session: never show the previous one's figure.
|
// Another workspace or session: never show the previous one's figure.
|
||||||
setLaneCost(null);
|
setLaneCost(null);
|
||||||
setSessionCost(null);
|
setSessionCost(null);
|
||||||
const tick = () => {
|
if (document.visibilityState === "visible") loadCosts();
|
||||||
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);
|
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
costGen.current++;
|
||||||
clearInterval(id);
|
|
||||||
document.removeEventListener("visibilitychange", tick);
|
|
||||||
};
|
};
|
||||||
}, [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 (
|
return (
|
||||||
<footer
|
<footer
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
* 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).
|
* 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>
|
* @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
|
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||||
// the conversation from missed/late WebSocket frames.
|
// the conversation from missed/late WebSocket frames.
|
||||||
const POLL_INTERVAL_MS = 3000;
|
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
|
// Rescan the transcripts list periodically so new subagents that spawn
|
||||||
// mid-session appear in the dropdown without a page reload.
|
// mid-session appear in the dropdown without a page reload.
|
||||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||||
@@ -110,6 +117,8 @@ interface ConversationViewProps {
|
|||||||
onContext?: (tokens: number, model: string | null) => void;
|
onContext?: (tokens: number, model: string | null) => void;
|
||||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||||
onNotStarted?: (notStarted: boolean) => void;
|
onNotStarted?: (notStarted: boolean) => void;
|
||||||
|
/** The session has ended (and no pane holds it): poll slowly, if at all. */
|
||||||
|
ended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationViewPane({
|
function ConversationViewPane({
|
||||||
@@ -124,6 +133,7 @@ function ConversationViewPane({
|
|||||||
stopped = 0,
|
stopped = 0,
|
||||||
onContext,
|
onContext,
|
||||||
onNotStarted,
|
onNotStarted,
|
||||||
|
ended = false,
|
||||||
}: ConversationViewProps) {
|
}: ConversationViewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
@@ -188,14 +198,18 @@ function ConversationViewPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadTranscripts();
|
loadTranscripts();
|
||||||
const interval = window.setInterval(() => {
|
// An ended session spawns no new sub-agents: the load above (re-run when
|
||||||
if (isShown(onScreen)) loadTranscripts();
|
// it ends, for the last ones) is enough.
|
||||||
}, TRANSCRIPTS_REFRESH_MS);
|
const interval = ended
|
||||||
|
? undefined
|
||||||
|
: window.setInterval(() => {
|
||||||
|
if (isShown(onScreen)) loadTranscripts();
|
||||||
|
}, TRANSCRIPTS_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId, ended]);
|
||||||
|
|
||||||
// Sync external initialTranscriptId to internal state
|
// Sync external initialTranscriptId to internal state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,9 +369,12 @@ function ConversationViewPane({
|
|||||||
let interval: number | null = null;
|
let interval: number | null = null;
|
||||||
function start() {
|
function start() {
|
||||||
if (interval !== null) return;
|
if (interval !== null) return;
|
||||||
interval = window.setInterval(() => {
|
interval = window.setInterval(
|
||||||
if (isShown(onScreen)) fetchNewMessages();
|
() => {
|
||||||
}, POLL_INTERVAL_MS);
|
if (isShown(onScreen)) fetchNewMessages();
|
||||||
|
},
|
||||||
|
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
if (interval !== null) {
|
if (interval !== null) {
|
||||||
@@ -382,7 +399,7 @@ function ConversationViewPane({
|
|||||||
stop();
|
stop();
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
};
|
};
|
||||||
}, [fetchNewMessages, onScreen]);
|
}, [fetchNewMessages, onScreen, ended]);
|
||||||
|
|
||||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ export function SessionReplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView sessionId={sessionId} fill />
|
<ConversationView
|
||||||
|
sessionId={sessionId}
|
||||||
|
fill
|
||||||
|
ended={!livePane && !!session && session.status !== "active"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{livePane ? (
|
{livePane ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @file sessionChange.test.ts
|
||||||
|
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
|
||||||
|
* and on a change of the keyed columns, never on a re-broadcast that moved none.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sessionChangeMatcher } from "../sessionChange";
|
||||||
|
import type { WSMessage } from "../types";
|
||||||
|
|
||||||
|
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
|
||||||
|
|
||||||
|
describe("sessionChangeMatcher", () => {
|
||||||
|
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
|
||||||
|
const m = sessionChangeMatcher();
|
||||||
|
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
|
||||||
|
expect(
|
||||||
|
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
|
||||||
|
).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
|
||||||
|
const m = sessionChangeMatcher();
|
||||||
|
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", null))).toBe(true);
|
||||||
|
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @file sessionChange.ts
|
||||||
|
* @description `session_created` / `session_updated` matcher for live-refresh
|
||||||
|
* callers that care about only a few session columns. A session row is
|
||||||
|
* re-broadcast on nearly every hook (last activity, name, tokens…), so
|
||||||
|
* matching the bare message types made every such frame refetch; this one
|
||||||
|
* fires on a new session, or when the chosen key of a session actually
|
||||||
|
* changed. Each call keeps its own memory of the keys it has seen.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Session, WSMessage } from "./types";
|
||||||
|
|
||||||
|
/** The columns that move a session between the active / waiting / archived counts. */
|
||||||
|
export const sessionStateKeyOf = (s: Partial<Session>): string =>
|
||||||
|
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
|
||||||
|
|
||||||
|
export function sessionChangeMatcher(
|
||||||
|
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
|
||||||
|
): (msg: WSMessage) => boolean {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
return (msg) => {
|
||||||
|
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
|
||||||
|
const s = msg.data as Partial<Session> | undefined;
|
||||||
|
// A frame we cannot key is taken as a change: refetching is the safe side.
|
||||||
|
if (!s || typeof s.id !== "string") return true;
|
||||||
|
const key = keyOf(s);
|
||||||
|
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
|
||||||
|
seen.set(s.id, key);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
useLiveRefresh(load, { intervalMs: 10000 });
|
// New events arrive over the WebSocket (prepended below); a reload is only
|
||||||
|
// for what the socket cannot carry: a remote source's import, a gap while it
|
||||||
|
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
|
||||||
|
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
|
||||||
|
|
||||||
// A new event is prepended at once (no reload); the poll reconciles.
|
// A new event is prepended at once (no reload); the reloads reconcile.
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
eventBus.subscribe((msg: WSMessage) => {
|
eventBus.subscribe((msg: WSMessage) => {
|
||||||
|
|||||||
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
|
|||||||
|
|
||||||
{visitedTabs.has("conversation") && (
|
{visitedTabs.has("conversation") && (
|
||||||
<div hidden={activeTab !== "conversation"}>
|
<div hidden={activeTab !== "conversation"}>
|
||||||
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
|
<ConversationView
|
||||||
|
sessionId={session.id}
|
||||||
|
initialTranscriptId={pendingTranscriptId}
|
||||||
|
ended={session.status !== "active"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
|
|||||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||||
import { confirmDiscard } from "../lib/confirm";
|
import { confirmDiscard } from "../lib/confirm";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
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 { useWorkLanes } from "../hooks/useLanes";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import {
|
import {
|
||||||
@@ -1044,9 +1047,10 @@ export function Workspace() {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Goes through `keepIfSame`: this fires every 5 s below, and a
|
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
|
||||||
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
// fallback poll below, and a fresh-but-identical payload would otherwise
|
||||||
// lane card and the whole console area on every tick for nothing.
|
// re-render the lane strip, the lane card and the whole console area for
|
||||||
|
// nothing.
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
@@ -1061,33 +1065,30 @@ export function Workspace() {
|
|||||||
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
|
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
|
||||||
}, [activeRuns]);
|
}, [activeRuns]);
|
||||||
|
|
||||||
// Background poll so the run list and history reflect external changes
|
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
|
||||||
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
// starting, ending or learning its session in slot 0 moves the lane's run
|
||||||
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
|
||||||
// status flips appear within seconds without needing a manual refresh.
|
// 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(() => {
|
useEffect(() => {
|
||||||
refreshList();
|
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]);
|
}, [refreshList]);
|
||||||
|
useLiveRefresh(refreshList, {
|
||||||
// Refresh whenever the tab regains focus / visibility - typical when the
|
intervalMs: 30_000,
|
||||||
// user comes back from running `claude` in a terminal and wants to see the
|
throttleMs: 1500,
|
||||||
// current state of every run without waiting for the next poll.
|
match: (msg) =>
|
||||||
|
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFocus = () => refreshList();
|
const onFocus = () => refreshList();
|
||||||
const onVis = () => {
|
|
||||||
if (document.visibilityState === "visible") refreshList();
|
|
||||||
};
|
|
||||||
window.addEventListener("focus", onFocus);
|
window.addEventListener("focus", onFocus);
|
||||||
document.addEventListener("visibilitychange", onVis);
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
return () => {
|
|
||||||
window.removeEventListener("focus", onFocus);
|
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
|
||||||
};
|
|
||||||
}, [refreshList]);
|
}, [refreshList]);
|
||||||
|
|
||||||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user