From ca28767f564e16c609c70b6e6dc0e2f76f3f00dd Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 22 Jul 2026 02:31:42 +0200 Subject: [PATCH] perf(desktop): hydrate history progressively on first launch --- app/electron/cli.test.ts | 8 +++ app/electron/cli.ts | 7 +- app/electron/main.test.ts | 54 ++++++++++++++- app/electron/main.ts | 100 +++++++++++++++++++++++++--- app/renderer/App.test.tsx | 33 +++++++++ app/renderer/App.tsx | 57 +++++++++++++--- app/renderer/components/SegTabs.tsx | 12 ++-- app/renderer/components/Splash.tsx | 4 ++ app/renderer/components/TopBar.tsx | 12 +++- app/renderer/lib/types.ts | 5 ++ app/renderer/styles/plain.css | 5 +- src/main.ts | 1 + src/menubar-json.ts | 3 + src/parser.ts | 94 +++++++++++++++++++++++--- src/session-cache.ts | 15 +++++ src/usage-aggregator.ts | 37 +++++++--- tests/cli-durable-totals.test.ts | 9 ++- tests/parser-hydration-lock.test.ts | 48 ++++++++++++- 18 files changed, 455 insertions(+), 49 deletions(-) diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index 138cdcc..fc81af0 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -357,6 +357,14 @@ describe('spawnCli coalescing (read-only)', () => { expect(readFileSync(countFile, 'utf8')).toBe('x') // exactly one spawn }) + it('does not reuse a result when the command-specific environment changes', async () => { + const countFile = join(dir, 'env-spawns') + fakeBin('env-counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({fast:process.env.CODEBURN_FAST_START==='1'}))`) + await expect(spawnCli(['status'], { extraEnv: { CODEBURN_FAST_START: '1' } })).resolves.toEqual({ fast: true }) + await expect(spawnCli(['status'])).resolves.toEqual({ fast: false }) + expect(readFileSync(countFile, 'utf8')).toBe('xx') + }) + it('spawns again once the 5s result cache has expired', async () => { vi.useFakeTimers({ toFake: ['Date'] }) try { diff --git a/app/electron/cli.ts b/app/electron/cli.ts index 467454c..7e54a8d 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -400,7 +400,12 @@ export function spawnCli( const spec = spawnSpecFor(target, args) if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv } - const key = JSON.stringify([spec.bin, ...spec.args]) + // Environment can change command semantics even when argv is identical. + // In particular, the desktop's cold first paint sets CODEBURN_FAST_START, + // then immediately launches the same argv without it to finish full history. + // Treat those as distinct flights/cache entries or the background request + // would incorrectly reuse the partial first-paint payload and never run. + const key = JSON.stringify([spec.bin, ...spec.args, opts.extraEnv ?? null]) const cached = readCache.get(key) if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value) const existing = readInflight.get(key) diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index aeb69a1..705381d 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -420,7 +420,7 @@ describe('createBridgeHandlers (cold-start warmup)', () => { await handlers['codeburn:getOverview']!('30days', 'all') expect(opts[0]?.timeoutMs).toBe(10 * 60_000) - expect((opts[0]?.extraEnv as Record | undefined)?.CODEBURN_PROGRESS).toBe('1') + expect(opts[0]?.extraEnv).toEqual({ CODEBURN_PROGRESS: '1', CODEBURN_FAST_START: '1' }) expect(typeof opts[0]?.onStderr).toBe('function') expect(emitProgress).toHaveBeenCalledWith({ kind: 'done' }) @@ -429,6 +429,58 @@ describe('createBridgeHandlers (cold-start warmup)', () => { expect(opts[1]?.extraEnv).toBeUndefined() }) + it('returns a cold payload before progressively hydrating and unlocking larger periods', async () => { + const calls: Array<{ args: string[]; opts?: Record }> = [] + const spawnCli = vi.fn(async (args: string[], opts?: Record) => { + calls.push({ args, opts }) + return calls.length === 1 + ? { indexing: true, current: { cost: 1 } } + : { current: { cost: 1 } } + }) + const emitProgress = vi.fn() + const handlers = createBridgeHandlers(base({ spawnCli, emitProgress })) + + const result = await handlers['codeburn:getOverview']!('today', 'all') + expect(result).toMatchObject({ ok: true, value: { indexing: true } }) + await vi.waitFor(() => expect(spawnCli).toHaveBeenCalledTimes(9)) + expect(calls[1]?.args).toEqual(expect.arrayContaining(['--from', '--to'])) + expect(calls[1]?.opts).toMatchObject({ + timeoutMs: 10 * 60_000, + priority: 'background', + extraEnv: { CODEBURN_FAST_START: '1' }, + }) + expect(calls[3]?.args).toContain('week') + expect(calls[7]?.args).toContain('all') + expect(calls[8]?.args).toContain('lifetime') + expect(calls[8]?.opts?.extraEnv).toBeUndefined() + expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: 'today' }) + expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: 'week' }) + expect(emitProgress).toHaveBeenCalledWith({ kind: 'period-ready', period: '30days' }) + await vi.waitFor(() => expect(emitProgress).toHaveBeenCalledWith({ kind: 'history-ready' })) + }) + + it('keeps overview polls range-limited while progressive history is running', async () => { + let releaseStage!: () => void + const stageGate = new Promise(resolve => { releaseStage = resolve }) + let call = 0 + const opts: Array | undefined> = [] + const spawnCli = vi.fn(async (_args: string[], options?: Record) => { + opts.push(options) + call++ + if (call === 1) return { indexing: true, current: { cost: 1 } } + if (call === 2) await stageGate + return { indexing: true, current: { cost: 1 } } + }) + const handlers = createBridgeHandlers(base({ spawnCli, emitProgress: vi.fn() })) + + await handlers['codeburn:getOverview']!('today', 'all') + await vi.waitFor(() => expect(spawnCli).toHaveBeenCalledTimes(2)) + await handlers['codeburn:getOverview']!('today', 'all') + expect(opts[2]?.extraEnv).toEqual({ CODEBURN_FAST_START: '1' }) + releaseStage() + await vi.waitFor(() => expect(spawnCli.mock.calls.length).toBeGreaterThanOrEqual(10)) + }) + it('drops a warmed overview to background priority only when the prefetch flag is set', async () => { const opts: Array | undefined> = [] const spawnCli = vi.fn(async (_args: string[], o?: Record) => { opts.push(o); return { current: { cost: 1 } } }) diff --git a/app/electron/main.ts b/app/electron/main.ts index 14a848b..690ed55 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -71,12 +71,12 @@ export function createBeforeQuitHandler(deps: BeforeQuitDeps): (event: BeforeQui // `kind` survives contextBridge serialization. preload.ts unwraps it. export type Envelope = { ok: true; value: T } | { ok: false; error: { kind: string; message: string } } -// The first overview fetch after boot hydrates a cold cache from scratch (a full -// history parse). That can far exceed the 45s read timeout, and killing it means -// the cache never persists, so every later poll restarts the scan — perpetual -// slowness. Give the first (cold) overview a long window; revert to the default -// once it succeeds. Sections gate their own first poll on this one resolving so -// the cold hydration runs ONCE, not once per section in parallel. +// The first overview fetch asks the CLI for a range-limited fast start. On a +// cold install it returns accurate data for the visible range, marked +// `indexing`, then we expand the durable cache in progressive background stages. +// Warm caches ignore the fast-start hint and take the normal path. Keep a long +// timeout so unusually large individual stages can finish instead of being +// killed and restarted. const WARMUP_TIMEOUT_MS = 10 * 60_000 // Wire marker for CLI scan-progress lines (src/parser.ts: PROGRESS_LINE_PREFIX). const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS ' @@ -126,6 +126,43 @@ function rangeArgs(range: DateRange | undefined): string[] { return range ? ['--from', range.from, '--to', range.to] : [] } +function localDateKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function trailingRange(days: number): DateRange { + const end = new Date() + end.setHours(12, 0, 0, 0) + const start = new Date(end) + start.setDate(start.getDate() - Math.max(0, days - 1)) + return { from: localDateKey(start), to: localDateKey(end) } +} + +type HydrationStage = { + id: string + label: string + period: string + range?: DateRange + unlock?: string + final?: boolean +} + +/** Small-to-large cold-cache expansion. The first two hidden day stages make + * the later 7-day scan incremental; only periods represented by visible tabs + * emit `period-ready` and become interactive in the renderer. */ +export function progressiveHydrationStages(): HydrationStage[] { + return [ + { id: 'yesterday', label: 'Today + yesterday', period: 'today', range: trailingRange(2) }, + { id: 'day-before', label: 'Last 3 days', period: 'today', range: trailingRange(3) }, + { id: 'week', label: 'Last 7 days', period: 'week', unlock: 'week' }, + { id: '14days', label: 'Last 14 days', period: 'today', range: trailingRange(14) }, + { id: '30days', label: 'Last 30 days', period: '30days', unlock: '30days' }, + { id: 'month', label: 'This month', period: 'month', unlock: 'month' }, + { id: 'all', label: 'Last 6 months', period: 'all', unlock: 'all' }, + { id: 'lifetime', label: 'Lifetime', period: 'lifetime', unlock: 'lifetime', final: true }, + ] +} + function configSourceArgs(source: string | null): string[] { return source ? ['--claude-config-source', source] : [] } @@ -240,6 +277,11 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re // overview fetch runs cold (long timeout + progress streaming); the shared // spawnCli coalescing means concurrent same-arg re-polls join one child. let overviewWarmed = false + // While a cold install expands from Today to Lifetime, ordinary overview + // polls must stay range-limited too. Otherwise the first 30-second poll would + // launch the old monolithic full-history scan alongside the staged worker. + let historyHydrating = false + let historyHydrationJob: Promise | null = null // cold_start is a once-per-launch metric. Because coalesced re-polls each // re-enter the cold branch (and overviewWarmed only flips on success, so it // never guards a still-failing warmup), emitting inline would record one row @@ -285,16 +327,58 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re const priority: SpawnPriority | undefined = background ? 'background' : undefined try { const args = buildOverviewArgs(period, provider, range, configSource) - if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) } + if (overviewWarmed) { + const warmOpts = historyHydrating + ? { extraEnv: { CODEBURN_FAST_START: '1' }, ...(priority ? { priority } : {}) } + : (priority ? { priority } : undefined) + return { ok: true, value: await deps.spawnCli(args, warmOpts) } + } const value = await deps.spawnCli(args, { timeoutMs: WARMUP_TIMEOUT_MS, - extraEnv: { CODEBURN_PROGRESS: '1' }, + extraEnv: { CODEBURN_PROGRESS: '1', CODEBURN_FAST_START: '1' }, onStderr: makeProgressReader(emitProgress), ...(priority ? { priority } : {}), }) overviewWarmed = true emitProgress({ kind: 'done' }) emitColdStart(false) + // A cold fast-start deliberately leaves the session cache incomplete. + // Grow it from small ranges to large ones so Today remains responsive and + // each visible period can unlock as soon as its own stage is accurate. + if ((value as { indexing?: boolean } | null)?.indexing === true) { + emitProgress({ kind: 'period-ready', period }) + if (!historyHydrationJob) { + historyHydrating = true + historyHydrationJob = (async () => { + let lifetimeReady = false + try { + for (const stage of progressiveHydrationStages()) { + emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'start' }) + const stageArgs = buildOverviewArgs(stage.period, 'all', stage.range) + try { + await deps.spawnCli(stageArgs, { + timeoutMs: WARMUP_TIMEOUT_MS, + priority: 'background', + // Every intermediate stage must leave the cache incomplete; + // Lifetime is the sole full-hydration/finalization pass. + ...(stage.final ? {} : { extraEnv: { CODEBURN_FAST_START: '1' } }), + }) + emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'ready' }) + if (stage.unlock) emitProgress({ kind: 'period-ready', period: stage.unlock }) + if (stage.final) lifetimeReady = true + } catch (err) { + emitProgress({ kind: 'history-stage', id: stage.id, label: stage.label, state: 'error' }) + telemetry?.track('cli_error', cliErrorProps(err, 'status')) + } + } + } finally { + historyHydrating = false + historyHydrationJob = null + if (lifetimeReady) emitProgress({ kind: 'history-ready' }) + } + })() + } + } return { ok: true, value } } catch (err) { const error = toEnvelopeError(err) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 81c7e09..5eeba4f 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -16,7 +16,9 @@ vi.stubGlobal('localStorage', { clear: () => stored.clear(), }) +const progress = vi.hoisted(() => ({ listeners: new Set<(event: any) => void>() })) const mocks = vi.hoisted(() => ({ + onProgress: vi.fn(), getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), @@ -119,6 +121,11 @@ function withConfigs(payload: MenubarPayload): MenubarPayload { function installDefaultMocks() { for (const mock of Object.values(mocks)) mock.mockReset() + progress.listeners.clear() + mocks.onProgress.mockImplementation((cb: (event: any) => void) => { + progress.listeners.add(cb) + return () => { progress.listeners.delete(cb) } + }) mocks.getOverview.mockResolvedValue(overviewPayload()) mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] }) mocks.getOptimizeReport.mockResolvedValue({ @@ -173,6 +180,10 @@ function installDefaultMocks() { mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' }) } +function emitProgress(event: any) { + for (const listener of progress.listeners) listener(event) +} + describe('App shortcuts', () => { beforeEach(() => { installDefaultMocks() @@ -202,6 +213,28 @@ describe('App shortcuts', () => { await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')) }) + it('disables unfinished periods and unlocks each tab when its history stage is ready', async () => { + localStorage.setItem('codeburn.defaultPeriod', 'today') + mocks.getOverview.mockResolvedValue({ ...overviewPayload(), indexing: true }) + render() + + const today = await screen.findByRole('tab', { name: 'Today' }) + const week = screen.getByRole('tab', { name: '7D' }) + const lifetime = screen.getByRole('tab', { name: 'Life' }) + await waitFor(() => expect(today).not.toHaveAttribute('aria-disabled')) + expect(week).toHaveAttribute('aria-disabled', 'true') + expect(lifetime).toHaveAttribute('aria-disabled', 'true') + expect(mocks.getActReport).not.toHaveBeenCalled() + + act(() => emitProgress({ kind: 'period-ready', period: 'week' })) + await waitFor(() => expect(week).not.toHaveAttribute('aria-disabled')) + fireEvent.click(week) + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all')) + + act(() => emitProgress({ kind: 'history-ready' })) + await waitFor(() => expect(lifetime).not.toHaveAttribute('aria-disabled')) + }) + it('switches sections with command-number shortcuts', async () => { render() diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 20a3b94..aaa71a2 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -224,6 +224,7 @@ function AppMain() { const [refreshToken, setRefreshToken] = useState(0) const [now, setNow] = useState(() => Date.now()) const [, setCurrencyTick] = useState(0) + const [readyPeriods, setReadyPeriods] = useState>(() => new Set()) // Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv // stays flag-free; only add --claude-config-source once a config is picked. @@ -238,6 +239,40 @@ function AppMain() { ) const refreshOverview = overview.refresh + // Cold history expands progressively. Unlock a range as soon as its stage is + // ready; after Lifetime finalizes, refresh once to replace the partial payload + // and remove the indexing marker without waiting for the polling cadence. + useEffect(() => { + if (!codeburn || typeof codeburn.onProgress !== 'function') return + return codeburn.onProgress(event => { + if (event.kind === 'period-ready') { + setReadyPeriods(current => { + if (current.has(event.period)) return current + const next = new Set(current) + next.add(event.period) + return next + }) + } else if (event.kind === 'history-ready') { + setReadyPeriods(new Set(STANDARD_PERIODS)) + refreshOverview() + } + }) + }, [refreshOverview]) + + useEffect(() => { + if (!overview.data) return + if (!overview.data.indexing) { + setReadyPeriods(new Set(STANDARD_PERIODS)) + return + } + setReadyPeriods(current => { + if (current.has(period)) return current + const next = new Set(current) + next.add(period) + return next + }) + }, [overview.data, period]) + // Boot readiness: the overview poll is the single cold-cache warmer (long // timeout + progress). Other sections gate their first CLI spawn on this so a // cold first run hydrates ONCE here instead of fanning out into a parallel @@ -276,7 +311,7 @@ function AppMain() { // fails we still emit the snapshot, just without the model x category cross. const snapshotDayRef = useRef(null) useEffect(() => { - if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return + if (!overview.data || overview.data.indexing || provider !== 'all' || customRange || claudeConfigSource) return const today = localDateKey(new Date()) if (snapshotDayRef.current === today) return snapshotDayRef.current = today @@ -365,7 +400,7 @@ function AppMain() { overviewBusyRef.current = overview.loading const warmedKeys = useRef>(new Set()) useEffect(() => { - if (!ready || overview.data == null || customRange || claudeConfigSource) return + if (!ready || overview.data == null || overview.data.indexing || customRange || claudeConfigSource) return const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider) if (targets.length === 0) return let cancelled = false @@ -393,10 +428,10 @@ function AppMain() { } const start = setTimeout(() => { void warm() }, PREFETCH_START_DELAY_MS) return () => { cancelled = true; clearTimeout(start) } - // `overview.data == null` (a boolean) gates on first-resolution without - // re-running every poll; the data content itself is intentionally not a dep. + // `overview.data == null` gates first-resolution; `indexing` re-arms exactly + // once when the background lifetime scan replaces the partial payload. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null]) + }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null, overview.data?.indexing]) // Warm the first view of each heavyweight section after the overview paints. // These run one at a time at background priority; the main-process scheduler @@ -405,7 +440,7 @@ function AppMain() { // showing a skeleton while a fresh process starts. const warmedSectionKeys = useRef>(new Set()) useEffect(() => { - if (!ready || overview.data == null || customRange || claudeConfigSource) return + if (!ready || overview.data == null || overview.data.indexing || customRange || claudeConfigSource) return const tasks: Array<{ key: string; fetch: () => Promise }> = [ { key: sessionsReportKey(period, provider), @@ -448,7 +483,7 @@ function AppMain() { } const start = setTimeout(() => { void warm() }, SECTION_PREFETCH_START_DELAY_MS) return () => { cancelled = true; clearTimeout(start) } - }, [ready, period, provider, customRange, claudeConfigSource, overview.data == null]) + }, [ready, period, provider, customRange, claudeConfigSource, overview.data == null, overview.data?.indexing]) useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) @@ -502,7 +537,7 @@ function AppMain() { }, [refreshVisible, navigate]) const onPeriodChange = (value: string) => { - if (isPeriod(value)) { + if (isPeriod(value) && (!overview.data?.indexing || readyPeriods.has(value))) { setCustomRange(null) setPeriod(value) } @@ -570,10 +605,12 @@ function AppMain() { claudeConfigs={claudeConfigs} configSource={claudeConfigSource} onConfigSelect={onConfigSelect} + readyPeriods={readyPeriods} + indexingPeriods={overview.data?.indexing === true} />
{section === 'overview' ? ( - + ) : section === 'sessions' ? ( ) : section === 'pullRequests' ? ( @@ -612,7 +649,7 @@ function StatusLine({ polled }: { polled: ReturnType - {polled.data.current.label} {formatUsd(polled.data.current.cost)} + {polled.data.indexing ? 'Indexing history' : polled.data.current.label} {formatUsd(polled.data.current.cost)} ) } diff --git a/app/renderer/components/SegTabs.tsx b/app/renderer/components/SegTabs.tsx index ed0c1ce..a0b139f 100644 --- a/app/renderer/components/SegTabs.tsx +++ b/app/renderer/components/SegTabs.tsx @@ -1,4 +1,4 @@ -export type SegOption = { value: string; label: string } +export type SegOption = { value: string; label: string; disabled?: boolean; loading?: boolean } /** The `.seg` segmented control used for period and lens switching. */ export function SegTabs({ @@ -17,18 +17,20 @@ export function SegTabs({ {options.map(opt => ( onChange(opt.value)} + aria-disabled={opt.disabled || undefined} + tabIndex={opt.disabled ? -1 : 0} + onClick={() => { if (!opt.disabled) onChange(opt.value) }} onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { + if (!opt.disabled && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault() onChange(opt.value) } }} > + {opt.loading &&