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 => {