diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index e80abbd..367ee88 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -69,6 +69,7 @@ import { type SidechatTranscriptRow, } from './sidechat-transcript.ts' import { api } from './api.ts' +import { usePolling } from './use-polling.ts' import { t } from './locales.ts' import type { SessionScope } from './api.ts' import type { SidebarTab } from './state.ts' @@ -493,17 +494,25 @@ export function SideChatView(props: { } }, [threadId, fetchInfo]) - // Poll while the tab is visible and the thread runs. + // One transcript pull on every input change (attach, visibility flip, + // run-state flip — the last one catches a thread's terminal state once it + // stops running). useEffect(() => { if (!visible || threadId === undefined) return void fetchThread(threadId) - if (!running) return - const timer = window.setInterval(() => { - void fetchThread(threadId) - void fetchInfo(threadId) - }, POLL_MS) - return () => { window.clearInterval(timer) } - }, [visible, threadId, running, fetchThread, fetchInfo]) + // `running` is not read here, but re-triggering this pull on run-state + // flips is load-bearing (see above); the badge fetch rides the ticks. + }, [visible, threadId, running, fetchThread]) + + // Poll while the tab is visible and the thread runs: transcript deltas + + // badge refresh on a fixed cadence. Each pull self-guards (fetchThread + // aborts its predecessor; a late settle keeps the last rows). + const pollTick = useCallback(async (): Promise => { + if (threadId === undefined) return + void fetchThread(threadId) + void fetchInfo(threadId) + }, [threadId, fetchThread, fetchInfo]) + usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS }) useEffect(() => () => { controllerRef.current?.abort() }, []) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 739844f..10ac564 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -53,6 +53,7 @@ import { type TreeJob, } from './subagent-jobs.ts' import { api, type JobOutputResult } from './api.ts' +import { usePolling } from './use-polling.ts' import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './SubagentView.module.css' @@ -180,52 +181,30 @@ function SubagentLiveLines(props: { live: LastActivity | undefined }) { /** * One shared live-preview poller for the whole Subagent tree. Unlike the old * per-card `subagents.history` timers, this sends at most ONE `subagents.live` - * request at a time: a recursive timeout starts only after the previous - * request settles, so a slow host never sees abort/restart storms. + * request at a time (the shared poller's self-scheduling mode arms the next + * tick only after the previous request settles, so a slow host never sees + * abort/restart storms); a response settling after the poller stopped (page + * hidden, tree re-rooted) is dropped via the aborted signal. */ function useSubagentLive( rootId: string | undefined, active: boolean, ): Readonly> { const [live, setLive] = useState>({}) - const controllerRef = useRef(undefined) // A new tree must never inherit another root's live previews. useEffect(() => { setLive({}) }, [rootId]) - useEffect(() => { - if (rootId === undefined || !active) return - const targetRootId = rootId - let disposed = false - let timer: number | undefined - - const schedule = (): void => { - if (disposed) return - timer = window.setTimeout(() => { void load() }, POLL_MS) - } - async function load(): Promise { - if (disposed) return - const controller = new AbortController() - controllerRef.current = controller - try { - const result = await api.subagentsLive(targetRootId, controller.signal) - if (!disposed) setLive(result.live) - } catch { - // Keep the last known live map; the next scheduled poll retries. - } finally { - if (controllerRef.current === controller) controllerRef.current = undefined - if (!disposed) schedule() - } - } - - void load() - return () => { - disposed = true - if (timer !== undefined) window.clearTimeout(timer) - controllerRef.current?.abort() - controllerRef.current = undefined - } - }, [rootId, active]) + const poll = useCallback(async (signal: AbortSignal): Promise => { + if (rootId === undefined) return + const result = await api.subagentsLive(rootId, signal) + if (!signal.aborted) setLive(result.live) + }, [rootId]) + usePolling(rootId !== undefined && active, poll, { + intervalMs: POLL_MS, + mode: 'self-scheduling', + immediate: true, + }) return live } diff --git a/src/client/changes/ChangesTab.tsx b/src/client/changes/ChangesTab.tsx index d46ef3f..e7cb963 100644 --- a/src/client/changes/ChangesTab.tsx +++ b/src/client/changes/ChangesTab.tsx @@ -18,6 +18,7 @@ import type { SidebarSessionEvent } from '../../context-types.ts' import type { TabComponentProps } from '../service.ts' import { t } from '../locales.ts' import { api } from '../api.ts' +import { usePolling } from '../use-polling.ts' import { floatTab, type SidebarDiffRef } from '../state.ts' import { GitLens } from './GitLens.tsx' import { SessionLens } from './SessionLens.tsx' @@ -104,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen seqRef.current = 0 setOpsError(false) }, [scope.sessionId]) - useEffect(() => { - void pull() - if (!visible) return - const timer = window.setInterval(() => { void pull() }, 2_500) - return () => { window.clearInterval(timer) } - }, [visible, pull]) + // One pull on every input change — mount, scope change, and visibility + // flip all re-pull (a hidden tab still catches up on the flip); only the + // poll CADENCE below is gated by visibility. + useEffect(() => { void pull() }, [visible, pull]) + usePolling(visible, pull, { intervalMs: 2_500 }) // tick only forces the re-render; the fold reads the ref directly. void tick const ops = opsRef.current diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index f3be2b1..919bb54 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -17,6 +17,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts' import { api } from '../api.ts' +import { usePolling } from '../use-polling.ts' import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts' import { resolveSidebarPath } from '../produced-files.ts' import { relativeTime, t } from '../locales.ts' @@ -280,11 +281,11 @@ export function GitLens(props: GitLensProps) { const generation = refreshGeneration.current += 1 void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation }) } - useEffect(() => { - if (!visible) return - const timer = window.setInterval(() => { void refresh(true) }, 2_000) - return () => { window.clearInterval(timer) } - }, [visible, refresh]) + /** The silent poll tick (the status-only fast path between worktree + * re-lists, see refresh) — fixed 2s cadence while visible, no initial + * burst (mount and scope changes already refresh above). */ + const pollTick = useCallback((): Promise => refresh(true), [refresh]) + usePolling(visible, pollTick, { intervalMs: 2_000 }) /** Append the next history page (lazy: only when the user asks for more). */ const loadMoreLog = async (): Promise => { diff --git a/src/client/use-polling.ts b/src/client/use-polling.ts new file mode 100644 index 0000000..b69df11 --- /dev/null +++ b/src/client/use-polling.ts @@ -0,0 +1,83 @@ +/** + * The sidebar's ONE polling loop: every timed client fetch — subagent live + * previews, side-chat transcript deltas, git status, session ops — funnels + * here so the cancellation-safety rules are written once instead of four + * times. The contract every poller gets: + * + * - While `enabled`, the task runs on the chosen cadence; the loop restarts + * (and the old one is torn down) whenever `enabled`, the task identity, or + * any option primitive changes — callers express "scope changed" through + * the task's `useCallback` deps, exactly like an effect's dep array. + * - Each enabled run owns ONE AbortSignal for its whole lifetime; teardown + * (unmount, `enabled` flip, identity change) aborts it AND stops all + * scheduling. A task whose fetch settles late must check the signal + * before writing state: an aborted fetch is NOT guaranteed to reject + * (the transport may deliver the response anyway), so the signal is the + * only reliable staleness guard. + * - A rejected task never breaks the loop — the scheduler swallows it and + * the next tick retries. Sites that surface errors (setError banners and + * friends) do so inside the task body; the scheduler always stays silent. + */ +import { useEffect } from 'react' + +/** One poll tick. See the module doc for the signal contract. */ +export type PollingTask = (signal: AbortSignal) => Promise + +export interface UsePollingOptions { + /** Tick cadence in milliseconds. */ + intervalMs: number + /** + * Scheduling mode. `'fixed-interval'` (default) fires ticks on a plain + * `setInterval` cadence — an in-flight task never delays the next tick, + * overlapping tasks guard their own writes (abort controllers or + * generation counters inside the task). `'self-scheduling'` arms the next + * tick only after the previous task settles: at most ONE request in + * flight, ever, so a slow host never sees request storms. + */ + mode?: 'fixed-interval' | 'self-scheduling' + /** Run one task immediately when the poller (re)starts, before the first + * scheduled tick. */ + immediate?: boolean +} + +export function usePolling( + enabled: boolean, + task: PollingTask, + { intervalMs, mode = 'fixed-interval', immediate = false }: UsePollingOptions, +): void { + useEffect(() => { + if (!enabled) return + const controller = new AbortController() + if (mode === 'self-scheduling') { + let disposed = false + let timer: number | undefined + const tick = async (): Promise => { + if (disposed) return + try { + await task(controller.signal) + } catch { + // A failed poll keeps the last view; the next tick retries. + } + if (!disposed) timer = window.setTimeout(() => { void tick() }, intervalMs) + } + if (immediate) void tick() + else timer = window.setTimeout(() => { void tick() }, intervalMs) + return () => { + disposed = true + if (timer !== undefined) window.clearTimeout(timer) + controller.abort() + } + } + const run = (): void => { + // A failed tick keeps the last view; the next tick retries. + task(controller.signal).catch(() => {}) + } + if (immediate) run() + const timer = window.setInterval(run, intervalMs) + return () => { + window.clearInterval(timer) + controller.abort() + } + // Option primitives only: a churned options object must not restart the loop. + }, [enabled, task, intervalMs, mode, immediate]) +}