From 1ec1565b135187a870c8841f48ee15c4dd3ad299 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 20:33:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20=E5=9B=9B=E5=A4=84=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E7=AB=99=E7=82=B9=E8=BF=81=E7=A7=BB=E5=88=B0=20use-po?= =?UTF-8?q?lling=EF=BC=8C=E8=AF=AD=E4=B9=89=E9=80=90=E4=B8=80=E4=BF=9D?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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;断言零改动。 --- src/client/SideChatView.tsx | 25 ++++++++++----- src/client/SubagentView.tsx | 51 +++++++++---------------------- src/client/changes/ChangesTab.tsx | 12 ++++---- src/client/changes/GitLens.tsx | 11 ++++--- 4 files changed, 44 insertions(+), 55 deletions(-) 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 => {