Merge pull request #541 from omdsh-dev/feat/unify-polling
feat(client): 收敛四处轮询习语到共享 use-polling 原语
This commit is contained in:
@@ -69,6 +69,7 @@ import {
|
|||||||
type SidechatTranscriptRow,
|
type SidechatTranscriptRow,
|
||||||
} from './sidechat-transcript.ts'
|
} from './sidechat-transcript.ts'
|
||||||
import { api } from './api.ts'
|
import { api } from './api.ts'
|
||||||
|
import { usePolling } from './use-polling.ts'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import type { SessionScope } from './api.ts'
|
import type { SessionScope } from './api.ts'
|
||||||
import type { SidebarTab } from './state.ts'
|
import type { SidebarTab } from './state.ts'
|
||||||
@@ -493,17 +494,25 @@ export function SideChatView(props: {
|
|||||||
}
|
}
|
||||||
}, [threadId, fetchInfo])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!visible || threadId === undefined) return
|
if (!visible || threadId === undefined) return
|
||||||
void fetchThread(threadId)
|
void fetchThread(threadId)
|
||||||
if (!running) return
|
// `running` is not read here, but re-triggering this pull on run-state
|
||||||
const timer = window.setInterval(() => {
|
// flips is load-bearing (see above); the badge fetch rides the ticks.
|
||||||
void fetchThread(threadId)
|
}, [visible, threadId, running, fetchThread])
|
||||||
void fetchInfo(threadId)
|
|
||||||
}, POLL_MS)
|
// Poll while the tab is visible and the thread runs: transcript deltas +
|
||||||
return () => { window.clearInterval(timer) }
|
// badge refresh on a fixed cadence. Each pull self-guards (fetchThread
|
||||||
}, [visible, threadId, running, fetchThread, fetchInfo])
|
// aborts its predecessor; a late settle keeps the last rows).
|
||||||
|
const pollTick = useCallback(async (): Promise<void> => {
|
||||||
|
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() }, [])
|
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||||
|
|
||||||
|
|||||||
+15
-36
@@ -53,6 +53,7 @@ import {
|
|||||||
type TreeJob,
|
type TreeJob,
|
||||||
} from './subagent-jobs.ts'
|
} from './subagent-jobs.ts'
|
||||||
import { api, type JobOutputResult } from './api.ts'
|
import { api, type JobOutputResult } from './api.ts'
|
||||||
|
import { usePolling } from './use-polling.ts'
|
||||||
import { IconStopOutline16 } from './icons.tsx'
|
import { IconStopOutline16 } from './icons.tsx'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import css from './SubagentView.module.css'
|
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
|
* 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`
|
* 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 at a time (the shared poller's self-scheduling mode arms the next
|
||||||
* request settles, so a slow host never sees abort/restart storms.
|
* 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(
|
function useSubagentLive(
|
||||||
rootId: string | undefined,
|
rootId: string | undefined,
|
||||||
active: boolean,
|
active: boolean,
|
||||||
): Readonly<Record<string, LastActivity>> {
|
): Readonly<Record<string, LastActivity>> {
|
||||||
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
||||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
|
||||||
|
|
||||||
// A new tree must never inherit another root's live previews.
|
// A new tree must never inherit another root's live previews.
|
||||||
useEffect(() => { setLive({}) }, [rootId])
|
useEffect(() => { setLive({}) }, [rootId])
|
||||||
|
|
||||||
useEffect(() => {
|
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||||
if (rootId === undefined || !active) return
|
if (rootId === undefined) return
|
||||||
const targetRootId = rootId
|
const result = await api.subagentsLive(rootId, signal)
|
||||||
let disposed = false
|
if (!signal.aborted) setLive(result.live)
|
||||||
let timer: number | undefined
|
}, [rootId])
|
||||||
|
usePolling(rootId !== undefined && active, poll, {
|
||||||
const schedule = (): void => {
|
intervalMs: POLL_MS,
|
||||||
if (disposed) return
|
mode: 'self-scheduling',
|
||||||
timer = window.setTimeout(() => { void load() }, POLL_MS)
|
immediate: true,
|
||||||
}
|
})
|
||||||
async function load(): Promise<void> {
|
|
||||||
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])
|
|
||||||
|
|
||||||
return live
|
return live
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { SidebarSessionEvent } from '../../context-types.ts'
|
|||||||
import type { TabComponentProps } from '../service.ts'
|
import type { TabComponentProps } from '../service.ts'
|
||||||
import { t } from '../locales.ts'
|
import { t } from '../locales.ts'
|
||||||
import { api } from '../api.ts'
|
import { api } from '../api.ts'
|
||||||
|
import { usePolling } from '../use-polling.ts'
|
||||||
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
import { floatTab, type SidebarDiffRef } from '../state.ts'
|
||||||
import { GitLens } from './GitLens.tsx'
|
import { GitLens } from './GitLens.tsx'
|
||||||
import { SessionLens } from './SessionLens.tsx'
|
import { SessionLens } from './SessionLens.tsx'
|
||||||
@@ -104,12 +105,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
|
|||||||
seqRef.current = 0
|
seqRef.current = 0
|
||||||
setOpsError(false)
|
setOpsError(false)
|
||||||
}, [scope.sessionId])
|
}, [scope.sessionId])
|
||||||
useEffect(() => {
|
// One pull on every input change — mount, scope change, and visibility
|
||||||
void pull()
|
// flip all re-pull (a hidden tab still catches up on the flip); only the
|
||||||
if (!visible) return
|
// poll CADENCE below is gated by visibility.
|
||||||
const timer = window.setInterval(() => { void pull() }, 2_500)
|
useEffect(() => { void pull() }, [visible, pull])
|
||||||
return () => { window.clearInterval(timer) }
|
usePolling(visible, pull, { intervalMs: 2_500 })
|
||||||
}, [visible, pull])
|
|
||||||
// tick only forces the re-render; the fold reads the ref directly.
|
// tick only forces the re-render; the fold reads the ref directly.
|
||||||
void tick
|
void tick
|
||||||
const ops = opsRef.current
|
const ops = opsRef.current
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
|
||||||
import { api } from '../api.ts'
|
import { api } from '../api.ts'
|
||||||
|
import { usePolling } from '../use-polling.ts'
|
||||||
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
|
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
|
||||||
import { resolveSidebarPath } from '../produced-files.ts'
|
import { resolveSidebarPath } from '../produced-files.ts'
|
||||||
import { relativeTime, t } from '../locales.ts'
|
import { relativeTime, t } from '../locales.ts'
|
||||||
@@ -280,11 +281,11 @@ export function GitLens(props: GitLensProps) {
|
|||||||
const generation = refreshGeneration.current += 1
|
const generation = refreshGeneration.current += 1
|
||||||
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
|
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
/** The silent poll tick (the status-only fast path between worktree
|
||||||
if (!visible) return
|
* re-lists, see refresh) — fixed 2s cadence while visible, no initial
|
||||||
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
* burst (mount and scope changes already refresh above). */
|
||||||
return () => { window.clearInterval(timer) }
|
const pollTick = useCallback((): Promise<void> => refresh(true), [refresh])
|
||||||
}, [visible, refresh])
|
usePolling(visible, pollTick, { intervalMs: 2_000 })
|
||||||
|
|
||||||
/** Append the next history page (lazy: only when the user asks for more). */
|
/** Append the next history page (lazy: only when the user asks for more). */
|
||||||
const loadMoreLog = async (): Promise<void> => {
|
const loadMoreLog = async (): Promise<void> => {
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
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])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user