From 427552e4f910597e8a2b2ba414887d54c25e705b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 1 Sep 2026 00:30:13 +0800 Subject: [PATCH] perf: cut the boot and poll overhead (one settings fetch, one git process per tick, one fold per poll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot: loadPrefs and loadExternalDisable each fetched the settings document, serially, before the first paint — and the second had no timeout, so a stalled wire kept the sidebar unmounted forever. Both decisions now ride ONE fetch (loadBootDecision) under the existing 2s race. GitLens: the 2s silent poll re-listed worktrees on every tick (a second git process per tick for a list that almost never changes, and a fresh worktrees array re-render each time). Silent ticks now fetch only the selected checkout's status; every 15th tick (~30s) re-lists and re-runs auto-selection, so an agent-created linked checkout is still picked up mid-session. ChangesTab: extractFileOps parses every accumulated tool/call (up to 4000 events); the poll ran it once for the badge and the render ran it AGAIN per tick, and the fresh array defeated the downstream memo. The fold now runs once per poll and its result rides a ref shared by badge and render (reset on session switch). --- src/client/changes/ChangesTab.tsx | 13 ++++++++++-- src/client/changes/GitLens.tsx | 19 +++++++++++++++++ src/client/index.tsx | 34 ++++++++++++++++--------------- src/client/prefs.ts | 19 +++++++++++++++++ tests/prefs.spec.ts | 27 +++++++++++++++++++++++- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/src/client/changes/ChangesTab.tsx b/src/client/changes/ChangesTab.tsx index ae7aa22..7726065 100644 --- a/src/client/changes/ChangesTab.tsx +++ b/src/client/changes/ChangesTab.tsx @@ -63,6 +63,12 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen // up). The cursor is the last delivered seq, so each poll ships only // what the accumulator lacks. ──────────────────────────────────────── const eventsRef = useRef([]) + // The fold of eventsRef as of the last poll. extractFileOps parses every + // accumulated tool/call (up to EVENTS_CAP events); running it once per + // poll and REUSING the result across renders (the render used to re-fold + // the whole window, twice per tick, and the fresh array defeated the + // downstream memo on every poll) keeps the 2.5s tick at one fold. + const opsRef = useRef([]) const seqRef = useRef(0) const pollGen = useRef(0) const [opsError, setOpsError] = useState(false) @@ -77,7 +83,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged } if (lastSeq > seqRef.current) seqRef.current = lastSeq - opCounts.set(scope.sessionId, extractFileOps(eventsRef.current).length) + const folded = extractFileOps(eventsRef.current) + opsRef.current = folded + opCounts.set(scope.sessionId, folded.length) setOpsError(false) setTick(value => value + 1) } catch { @@ -89,6 +97,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen useEffect(() => { pollGen.current += 1 eventsRef.current = [] + opsRef.current = [] seqRef.current = 0 setOpsError(false) }, [scope.sessionId]) @@ -100,7 +109,7 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen }, [visible, pull]) // tick only forces the re-render; the fold reads the ref directly. void tick - const ops = extractFileOps(eventsRef.current) + const ops = opsRef.current /** Persist a meta patch onto the tab (lens choice, pane height). */ const patchMeta = (patch: ChangesMeta): void => { diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index afec62b..71b7324 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -76,6 +76,13 @@ interface ConfirmState { * floods the panel at once (the end of the log is reached by paging). */ const LOG_BATCH = 20 +/** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the + * 2s tick only needs the selected checkout's STATUS, and re-listing spawned + * a second git process per tick for a list that almost never changes — a + * linked checkout the agent creates mid-session is picked up within ~30s + * instead of 2s. */ +const WORKTREE_RECHECK_TICKS = 15 + export interface GitLensProps { scope: SessionScope /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */ @@ -123,6 +130,8 @@ export function GitLens(props: GitLensProps) { * callback and re-trigger the mount effect — an N→N+1 fetch loop). */ const chosenPathRef = useRef(undefined) useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree]) + /** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */ + const silentTickCount = useRef(0) const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot } @@ -160,6 +169,15 @@ export function GitLens(props: GitLensProps) { refreshInFlight.current = true let generation = refreshGeneration.current try { + // Fast path for the ordinary silent tick: the selected checkout's + // STATUS is all that changes between worktree re-lists (see + // WORKTREE_RECHECK_TICKS) — one git process instead of two. + if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) { + const statusResult = await api.gitStatus(gitScope, chosenPathRef.current) + if (generation === refreshGeneration.current) setStatus(statusResult) + return + } + silentTickCount.current = 0 const listed = await api.gitWorktrees(scope) if (generation !== refreshGeneration.current) return setWorktrees(listed) @@ -214,6 +232,7 @@ export function GitLens(props: GitLensProps) { refreshInFlight.current = false worktreeChosenByUser.current = false chosenPathRef.current = undefined + silentTickCount.current = 0 setSelectedWorktree(undefined) }, [scope.sessionId, scope.cwd]) useEffect(() => { void refresh() }, [refresh]) diff --git a/src/client/index.tsx b/src/client/index.tsx index 30033f9..7ba5b31 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -21,7 +21,7 @@ import { registerOpenPathInterception, registerTurnTailInterception } from './in import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts' -import { loadExternalDisable, loadPrefs } from './prefs.ts' +import { loadBootDecision } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en, @@ -275,24 +275,26 @@ export function apply(ctx: Context): void { } const sync = async (): Promise => { if (disposed) return - // Resolve the user's side card prefs BEFORE the first session seeds, - // so a brand-new conversation opens (or stays closed) at the chosen - // width from first paint. A settings route failure falls back to the - // schema defaults; the sidebar still mounts (a stalled wire gives up - // after the timeout and mounts on the defaults). - const prefs = await Promise.race([ - loadPrefs(api), + // Resolve the user's side card prefs and the external-disable flag + // from ONE settings fetch BEFORE the first session seeds, so a + // brand-new conversation opens (or stays closed) at the chosen width + // from first paint. A settings route failure falls back to the schema + // defaults; the sidebar still mounts (a stalled wire gives up after + // the timeout and mounts on the defaults — the external-disable check + // rides the same fetch, so one round trip covers both decisions). + const decision = await Promise.race([ + loadBootDecision(api), new Promise(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }), ]) - if (prefs !== null) sidebarStore.setPrefs(prefs) if (disposed) return - // Mutual exclusion with the dsh-web-ui family right panel: while the - // aionui-panel provider is selected, the sidebar must not mount at - // all. Re-evaluated on every settings-document update (live switch). - const suspended = await loadExternalDisable(api) - if (disposed) return - sidebarStore.setSuspended(suspended) - if (suspended) unmount() + if (decision !== null) { + sidebarStore.setPrefs(decision.prefs) + // Mutual exclusion with the dsh-web-ui family right panel: while the + // aionui-panel provider is selected, the sidebar must not mount at + // all. Re-evaluated on every settings-document update (live switch). + sidebarStore.setSuspended(decision.suspended) + } + if (decision?.suspended) unmount() else mount() } void sync() diff --git a/src/client/prefs.ts b/src/client/prefs.ts index be4d482..082523a 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -217,3 +217,22 @@ export async function loadExternalDisable(settings: SidebarSettingsClient): Prom return false } } + +/** The boot decision both the prefs and the external-disable flag need: + * ONE settings fetch answers both (the boot path used to await + * {@link loadPrefs} and {@link loadExternalDisable} serially — two round + * trips of the same document before the first paint, and the second had no + * timeout, so a stalled wire could keep the sidebar unmounted forever). */ +export interface BootDecision { + prefs: SidebarPrefs + suspended: boolean +} + +export async function loadBootDecision(settings: SidebarSettingsClient): Promise { + try { + const view = await settings.settingsGet() + return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true } + } catch { + return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false } + } +} diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 449f55b..fe48adb 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' +import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' import { allLeaves, createSidebarStore, defaultWidthFor, makeDefaultState, setWidth } from '../src/client/state.ts' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' @@ -416,3 +416,28 @@ describe('external disable (aionui-panel provider choice)', () => { expect(await loadExternalDisable(rejecting())).toBe(false) }) }) + +describe('boot decision (one fetch for prefs + external disable)', () => { + it('answers both decisions from a single settingsGet call', async () => { + let calls = 0 + const counting = (): SidebarSettingsClient => ({ + settingsGet: async () => { calls += 1; return { value: { openByDefault: true, defaultWidthPercent: 40 }, revision: 1, externalDisable: true } }, + settingsUpdate: async () => ({ value: {}, revision: 2 }), + }) + const decision = await loadBootDecision(counting()) + expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1) + expect(decision.suspended).toBe(true) + expect(decision.prefs.openByDefault).toBe(true) + }) + + it('falls back to the defaults + not suspended on any failure', async () => { + const decision = await loadBootDecision(rejecting()) + expect(decision).toEqual({ prefs: SIDEBAR_PREFS_DEFAULTS, suspended: false }) + }) + + it('reads suspended false when the flag is absent', async () => { + const decision = await loadBootDecision(wire({ defaultWidthPercent: 50 })) + expect(decision.suspended).toBe(false) + expect(decision.prefs.defaultWidthPercent).toBe(50) + }) +})