refactor(client): 四处轮询站点迁移到 use-polling,语义逐一保持

- SubagentView useSubagentLive:self-scheduling(上一请求 settle 后才排
  下一次,不重叠)+ 每代 AbortSignal + signal.aborted 拦迟到写入,行为
  与原 disposed 标志等价(teardown 同步置 disposed 并 abort)。
- SideChatView:立即拉取 effect 与 2s 定时拆分——输入变化(含 running
  翻转)仍先拉一次转录,运行中间隔 tick 拉转录+徽章;fetchThread 自带
  的逐次 abort 与卸载 abort 原样保留。
- GitLens:可见时 2s 固定静默 tick,无初始突发(mount/scope 变更的完整
  刷新在别处);refresh 身份变化照旧重启间隔相位。
- ChangesTab:输入变化即拉一次(含隐藏态翻转),可见时 2.5s 固定间隔;
  pull 内部 pollGen 代际守卫不动。

守护 spec 全绿:subagent-live-polling + subagent-jobs-view 13/13、
sidechat-view + sidechat-transcript 29/29、git-view-worktree +
changes-tab + changes-ops 13/13;断言零改动。
This commit is contained in:
Menghuan1918
2026-09-03 20:35:59 +08:00
parent 6ef11ae010
commit 1ec1565b13
4 changed files with 44 additions and 55 deletions
+15 -6
View File
@@ -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.
}, [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<void> => {
if (threadId === undefined) return
void fetchThread(threadId) void fetchThread(threadId)
void fetchInfo(threadId) void fetchInfo(threadId)
}, POLL_MS) }, [threadId, fetchThread, fetchInfo])
return () => { window.clearInterval(timer) } usePolling(visible && running && threadId !== undefined, pollTick, { intervalMs: POLL_MS })
}, [visible, threadId, running, fetchThread, fetchInfo])
useEffect(() => () => { controllerRef.current?.abort() }, []) useEffect(() => () => { controllerRef.current?.abort() }, [])
+15 -36
View File
@@ -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
} }
+6 -6
View File
@@ -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
+6 -5
View File
@@ -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> => {