Skip to content

Commit 68839aa

Browse files
authored
Merge pull request #541 from omdsh-dev/feat/unify-polling
feat(client): 收敛四处轮询习语到共享 use-polling 原语
2 parents aa01140 + 1ec1565 commit 68839aa

5 files changed

Lines changed: 127 additions & 55 deletions

File tree

src/client/SideChatView.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
type SidechatTranscriptRow,
7070
} from './sidechat-transcript.ts'
7171
import { api } from './api.ts'
72+
import { usePolling } from './use-polling.ts'
7273
import { t } from './locales.ts'
7374
import type { SessionScope } from './api.ts'
7475
import type { SidebarTab } from './state.ts'
@@ -493,17 +494,25 @@ export function SideChatView(props: {
493494
}
494495
}, [threadId, fetchInfo])
495496

496-
// Poll while the tab is visible and the thread runs.
497+
// One transcript pull on every input change (attach, visibility flip,
498+
// run-state flip — the last one catches a thread's terminal state once it
499+
// stops running).
497500
useEffect(() => {
498501
if (!visible || threadId === undefined) return
499502
void fetchThread(threadId)
500-
if (!running) return
501-
const timer = window.setInterval(() => {
502-
void fetchThread(threadId)
503-
void fetchInfo(threadId)
504-
}, POLL_MS)
505-
return () => { window.clearInterval(timer) }
506-
}, [visible, threadId, running, fetchThread, fetchInfo])
503+
// `running` is not read here, but re-triggering this pull on run-state
504+
// flips is load-bearing (see above); the badge fetch rides the ticks.
505+
}, [visible, threadId, running, fetchThread])
506+
507+
// Poll while the tab is visible and the thread runs: transcript deltas +
508+
// badge refresh on a fixed cadence. Each pull self-guards (fetchThread
509+
// aborts its predecessor; a late settle keeps the last rows).
510+
const pollTick = useCallback(async (): Promise<void> => {
511+
if (threadId === undefined) return
512+
void fetchThread(threadId)
513+
void fetchInfo(threadId)
514+
}, [threadId, fetchThread, fetchInfo])
515+
usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS })
507516

508517
useEffect(() => () => { controllerRef.current?.abort() }, [])
509518

src/client/SubagentView.tsx

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
type TreeJob,
5454
} from './subagent-jobs.ts'
5555
import { api, type JobOutputResult } from './api.ts'
56+
import { usePolling } from './use-polling.ts'
5657
import { IconStopOutline16 } from './icons.tsx'
5758
import { t } from './locales.ts'
5859
import css from './SubagentView.module.css'
@@ -180,52 +181,30 @@ function SubagentLiveLines(props: { live: LastActivity | undefined }) {
180181
/**
181182
* One shared live-preview poller for the whole Subagent tree. Unlike the old
182183
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
183-
* request at a time: a recursive timeout starts only after the previous
184-
* request settles, so a slow host never sees abort/restart storms.
184+
* request at a time (the shared poller's self-scheduling mode arms the next
185+
* tick only after the previous request settles, so a slow host never sees
186+
* abort/restart storms); a response settling after the poller stopped (page
187+
* hidden, tree re-rooted) is dropped via the aborted signal.
185188
*/
186189
function useSubagentLive(
187190
rootId: string | undefined,
188191
active: boolean,
189192
): Readonly<Record<string, LastActivity>> {
190193
const [live, setLive] = useState<Record<string, LastActivity>>({})
191-
const controllerRef = useRef<AbortController | undefined>(undefined)
192194

193195
// A new tree must never inherit another root's live previews.
194196
useEffect(() => { setLive({}) }, [rootId])
195197

196-
useEffect(() => {
197-
if (rootId === undefined || !active) return
198-
const targetRootId = rootId
199-
let disposed = false
200-
let timer: number | undefined
201-
202-
const schedule = (): void => {
203-
if (disposed) return
204-
timer = window.setTimeout(() => { void load() }, POLL_MS)
205-
}
206-
async function load(): Promise<void> {
207-
if (disposed) return
208-
const controller = new AbortController()
209-
controllerRef.current = controller
210-
try {
211-
const result = await api.subagentsLive(targetRootId, controller.signal)
212-
if (!disposed) setLive(result.live)
213-
} catch {
214-
// Keep the last known live map; the next scheduled poll retries.
215-
} finally {
216-
if (controllerRef.current === controller) controllerRef.current = undefined
217-
if (!disposed) schedule()
218-
}
219-
}
220-
221-
void load()
222-
return () => {
223-
disposed = true
224-
if (timer !== undefined) window.clearTimeout(timer)
225-
controllerRef.current?.abort()
226-
controllerRef.current = undefined
227-
}
228-
}, [rootId, active])
198+
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
199+
if (rootId === undefined) return
200+
const result = await api.subagentsLive(rootId, signal)
201+
if (!signal.aborted) setLive(result.live)
202+
}, [rootId])
203+
usePolling(rootId !== undefined && active, poll, {
204+
intervalMs: POLL_MS,
205+
mode: 'self-scheduling',
206+
immediate: true,
207+
})
229208

230209
return live
231210
}

src/client/changes/ChangesTab.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
1818
import type { TabComponentProps } from '../service.ts'
1919
import { t } from '../locales.ts'
2020
import { api } from '../api.ts'
21+
import { usePolling } from '../use-polling.ts'
2122
import { floatTab, type SidebarDiffRef } from '../state.ts'
2223
import { GitLens } from './GitLens.tsx'
2324
import { SessionLens } from './SessionLens.tsx'
@@ -104,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
104105
seqRef.current = 0
105106
setOpsError(false)
106107
}, [scope.sessionId])
107-
useEffect(() => {
108-
void pull()
109-
if (!visible) return
110-
const timer = window.setInterval(() => { void pull() }, 2_500)
111-
return () => { window.clearInterval(timer) }
112-
}, [visible, pull])
108+
// One pull on every input change — mount, scope change, and visibility
109+
// flip all re-pull (a hidden tab still catches up on the flip); only the
110+
// poll CADENCE below is gated by visibility.
111+
useEffect(() => { void pull() }, [visible, pull])
112+
usePolling(visible, pull, { intervalMs: 2_500 })
113113
// tick only forces the re-render; the fold reads the ref directly.
114114
void tick
115115
const ops = opsRef.current

src/client/changes/GitLens.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@deepseek-ai/dsh-client-ui-primitives'
1818
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
1919
import { api } from '../api.ts'
20+
import { usePolling } from '../use-polling.ts'
2021
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
2122
import { resolveSidebarPath } from '../produced-files.ts'
2223
import { relativeTime, t } from '../locales.ts'
@@ -280,11 +281,11 @@ export function GitLens(props: GitLensProps) {
280281
const generation = refreshGeneration.current += 1
281282
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
282283
}
283-
useEffect(() => {
284-
if (!visible) return
285-
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
286-
return () => { window.clearInterval(timer) }
287-
}, [visible, refresh])
284+
/** The silent poll tick (the status-only fast path between worktree
285+
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
286+
* burst (mount and scope changes already refresh above). */
287+
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
288+
usePolling(visible, pollTick, { intervalMs: 2_000 })
288289

289290
/** Append the next history page (lazy: only when the user asks for more). */
290291
const loadMoreLog = async (): Promise<void> => {

src/client/use-polling.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* The sidebar's ONE polling loop: every timed client fetch — subagent live
3+
* previews, side-chat transcript deltas, git status, session ops — funnels
4+
* here so the cancellation-safety rules are written once instead of four
5+
* times. The contract every poller gets:
6+
*
7+
* - While `enabled`, the task runs on the chosen cadence; the loop restarts
8+
* (and the old one is torn down) whenever `enabled`, the task identity, or
9+
* any option primitive changes — callers express "scope changed" through
10+
* the task's `useCallback` deps, exactly like an effect's dep array.
11+
* - Each enabled run owns ONE AbortSignal for its whole lifetime; teardown
12+
* (unmount, `enabled` flip, identity change) aborts it AND stops all
13+
* scheduling. A task whose fetch settles late must check the signal
14+
* before writing state: an aborted fetch is NOT guaranteed to reject
15+
* (the transport may deliver the response anyway), so the signal is the
16+
* only reliable staleness guard.
17+
* - A rejected task never breaks the loop — the scheduler swallows it and
18+
* the next tick retries. Sites that surface errors (setError banners and
19+
* friends) do so inside the task body; the scheduler always stays silent.
20+
*/
21+
import { useEffect } from 'react'
22+
23+
/** One poll tick. See the module doc for the signal contract. */
24+
export type PollingTask = (signal: AbortSignal) => Promise<void>
25+
26+
export interface UsePollingOptions {
27+
/** Tick cadence in milliseconds. */
28+
intervalMs: number
29+
/**
30+
* Scheduling mode. `'fixed-interval'` (default) fires ticks on a plain
31+
* `setInterval` cadence — an in-flight task never delays the next tick,
32+
* overlapping tasks guard their own writes (abort controllers or
33+
* generation counters inside the task). `'self-scheduling'` arms the next
34+
* tick only after the previous task settles: at most ONE request in
35+
* flight, ever, so a slow host never sees request storms.
36+
*/
37+
mode?: 'fixed-interval' | 'self-scheduling'
38+
/** Run one task immediately when the poller (re)starts, before the first
39+
* scheduled tick. */
40+
immediate?: boolean
41+
}
42+
43+
export function usePolling(
44+
enabled: boolean,
45+
task: PollingTask,
46+
{ intervalMs, mode = 'fixed-interval', immediate = false }: UsePollingOptions,
47+
): void {
48+
useEffect(() => {
49+
if (!enabled) return
50+
const controller = new AbortController()
51+
if (mode === 'self-scheduling') {
52+
let disposed = false
53+
let timer: number | undefined
54+
const tick = async (): Promise<void> => {
55+
if (disposed) return
56+
try {
57+
await task(controller.signal)
58+
} catch {
59+
// A failed poll keeps the last view; the next tick retries.
60+
}
61+
if (!disposed) timer = window.setTimeout(() => { void tick() }, intervalMs)
62+
}
63+
if (immediate) void tick()
64+
else timer = window.setTimeout(() => { void tick() }, intervalMs)
65+
return () => {
66+
disposed = true
67+
if (timer !== undefined) window.clearTimeout(timer)
68+
controller.abort()
69+
}
70+
}
71+
const run = (): void => {
72+
// A failed tick keeps the last view; the next tick retries.
73+
task(controller.signal).catch(() => {})
74+
}
75+
if (immediate) run()
76+
const timer = window.setInterval(run, intervalMs)
77+
return () => {
78+
window.clearInterval(timer)
79+
controller.abort()
80+
}
81+
// Option primitives only: a churned options object must not restart the loop.
82+
}, [enabled, task, intervalMs, mode, immediate])
83+
}

0 commit comments

Comments
 (0)