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) + }) +})