fix(app): cold start survives, first-run shows per-provider progress

Root cause of the field-reported 45s timeout + perpetual slowness: on
a cold cache all six sections spawned different CLI subcommands at
once, each running its own full-history parse (~11-31s, ~3GB RSS),
contending past the 45s kill so the rebuilt cache never persisted and
every poll restarted from zero.

- the first overview fetch runs as a warmup: 10-minute timeout,
  re-arms until it succeeds, reverts to 45s after; section polls gate
  on that first resolution (usePolled gains enabled), so cold
  hydration happens exactly once
- warmup streams the CLI's progress protocol; the splash shows 'First
  run: indexing your usage history' with a per-provider ingest list
  (logo, live counts, check on done), static under reduced motion,
  generic fallback without events; warm launches unchanged
- overview spawns pass --no-timeline (desktop never renders it)

Measured on real data: cold 31s once (was: killed at 45s forever),
warm 2.6s. 295/295 app, 1797 root.
This commit is contained in:
iamtoruk 2026-07-16 10:51:18 -07:00
parent ded4e4e844
commit 55bc53e6be
18 changed files with 407 additions and 30 deletions

View file

@ -217,7 +217,7 @@ export function resolveCodeburnPath(): string | null {
return target.kind === 'bundled' ? target.entry : target.bin
}
function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number): Promise<unknown> {
function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: (chunk: string) => void): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
const child = spawn(spec.bin, spec.args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spec.env })
activeChildren.add(child)
@ -252,7 +252,13 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number): Promise<u
}
child.stdout.on('data', chunk => { stdout += chunk; bump(chunk.length) })
child.stderr.on('data', chunk => { stderr += chunk; bump(chunk.length) })
child.stderr.on('data', chunk => {
stderr += chunk
bump(chunk.length)
// Live stderr for the cold-start warmup: forwards CLI scan-progress lines
// to the splash. Never fires for ordinary reads (onStderr unset).
if (onStderr) { try { onStderr(chunk.toString()) } catch { /* forwarder must not kill the read */ } }
})
child.on('error', err => {
finish(() => reject(new CliError('not-found', err.message)))
@ -286,18 +292,24 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number): Promise<u
* Read-only, so concurrent identical calls share one child and a 5s result cache
* absorbs same-cadence pollers. Never use this for config-mutating commands.
*/
export function spawnCli(args: string[], opts: { timeoutMs?: number } = {}): Promise<unknown> {
export function spawnCli(
args: string[],
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv } = {},
): Promise<unknown> {
const target = resolveTarget()
if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found'))
const spec = spawnSpecFor(target, args)
if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv }
const key = JSON.stringify([spec.bin, ...spec.args])
const cached = readCache.get(key)
if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value)
const existing = readInflight.get(key)
// A same-cadence re-poll during a slow cold warmup coalesces onto the one
// in-flight child (which already carries onStderr); no second cold parse.
if (existing) return existing
const flight = runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
const flight = runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr)
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
.finally(() => { readInflight.delete(key) })
readInflight.set(key, flight)

View file

@ -63,8 +63,8 @@ const CHANNELS = [
] as const
const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--provider', 'claude'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'] },
{ channel: 'codeburn:getPlans', args: ['week'], argv: ['status', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getActReport', args: [], argv: ['act', 'report', '--json'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'] },
@ -77,9 +77,9 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
@ -154,7 +154,7 @@ describe('createBridgeHandlers (IPC wiring)', () => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers['codeburn:getOverview']!('30days', 'all')
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days'])
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'])
expect(res).toEqual({ ok: true, value: { current: { cost: 12.34 } } })
})
@ -268,3 +268,55 @@ describe('createApplicationMenuTemplate', () => {
expect(roles).not.toContain('forceReload')
})
})
describe('createBridgeHandlers (cold-start warmup)', () => {
const base = (extra: object) => ({ spawnCli: vi.fn(), spawnCliAction: vi.fn(), resolveCodeburnPath: () => '/bin/codeburn', getQuota: vi.fn(async () => []), ...extra })
it('gives the first overview a long timeout + progress env, then reverts once warmed', async () => {
const opts: Array<Record<string, unknown> | undefined> = []
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return { current: { cost: 1 } } })
const emitProgress = vi.fn()
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress }))
await handlers['codeburn:getOverview']!('30days', 'all')
expect(opts[0]?.timeoutMs).toBe(10 * 60_000)
expect((opts[0]?.extraEnv as Record<string, string> | undefined)?.CODEBURN_PROGRESS).toBe('1')
expect(typeof opts[0]?.onStderr).toBe('function')
expect(emitProgress).toHaveBeenCalledWith({ kind: 'done' })
await handlers['codeburn:getOverview']!('30days', 'all')
expect(opts[1]?.timeoutMs).toBeUndefined()
expect(opts[1]?.extraEnv).toBeUndefined()
})
it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => {
const opts: Array<{ timeoutMs?: number } | undefined> = []
let n = 0
const spawnCli = vi.fn(async (_args: string[], o?: { timeoutMs?: number }) => {
opts.push(o)
if (++n === 1) throw new CliError('timeout', 'timed out')
return { current: { cost: 1 } }
})
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress: vi.fn() }))
expect(await handlers['codeburn:getOverview']!('30days', 'all')).toMatchObject({ ok: false })
expect(await handlers['codeburn:getOverview']!('30days', 'all')).toMatchObject({ ok: true })
expect(opts[0]?.timeoutMs).toBe(10 * 60_000)
expect(opts[1]?.timeoutMs).toBe(10 * 60_000)
})
it('parses CLI scan-progress stderr lines and forwards them to emitProgress', async () => {
const spawnCli = vi.fn(async (_args: string[], o?: { onStderr?: (chunk: string) => void }) => {
// A split line proves the reader buffers across chunks.
o?.onStderr?.('CODEBURN_PROGRESS {"kind":"providers","providers":["claude","codex"]}\nCODEBURN_PROG')
o?.onStderr?.('RESS {"kind":"tick","provider":"claude","done":5,"total":10}\nnoise line\n')
return { current: { cost: 1 } }
})
const emitProgress = vi.fn()
const handlers = createBridgeHandlers(base({ spawnCli, emitProgress }))
await handlers['codeburn:getOverview']!('30days', 'all')
expect(emitProgress).toHaveBeenCalledWith({ kind: 'providers', providers: ['claude', 'codex'] })
expect(emitProgress).toHaveBeenCalledWith({ kind: 'tick', provider: 'claude', done: 5, total: 10 })
})
})

View file

@ -8,6 +8,41 @@ import { getQuota, sanitizeError } from './quota'
// `kind` survives contextBridge serialization. preload.ts unwraps it.
export type Envelope<T = unknown> = { 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.
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 '
// IPC channel carrying cold-start scan-progress events to the splash.
export const PROGRESS_CHANNEL = 'codeburn:progress'
/** Line-buffer a spawn's stderr and forward each parsed scan-progress event. */
export function makeProgressReader(emit: (event: unknown) => void): (chunk: string) => void {
let buffer = ''
return chunk => {
buffer += chunk
let nl = buffer.indexOf('\n')
while (nl >= 0) {
const line = buffer.slice(0, nl)
buffer = buffer.slice(nl + 1)
if (line.startsWith(PROGRESS_LINE_PREFIX)) {
try { emit(JSON.parse(line.slice(PROGRESS_LINE_PREFIX.length))) } catch { /* ignore malformed line */ }
}
nl = buffer.indexOf('\n')
}
}
}
function broadcastProgress(event: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(PROGRESS_CHANNEL, event)
}
}
function providerArgs(provider: string | undefined): string[] {
return provider && provider !== 'all' ? ['--provider', provider] : []
}
@ -86,10 +121,12 @@ function toEnvelopeError(err: unknown): { kind: string; message: string } {
}
type Deps = {
spawnCli: (args: string[], opts?: { timeoutMs?: number }) => Promise<unknown>
spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv }) => Promise<unknown>
spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise<ActionResult>
resolveCodeburnPath: () => string | null
getQuota: typeof getQuota
/** Forward cold-start scan-progress events to the renderer splash. */
emitProgress?: (event: unknown) => void
}
type Handler = (...args: any[]) => Promise<Envelope>
@ -99,7 +136,13 @@ type Handler = (...args: any[]) => Promise<Envelope>
* shell) and returns a result envelope. Pure + injectable so the wiring is
* unit-testable without launching Electron.
*/
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota }): Record<string, Handler> {
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress }): Record<string, Handler> {
const emitProgress = deps.emitProgress ?? (() => {})
// Flips true after the first overview fetch succeeds. Until then, every
// overview fetch runs cold (long timeout + progress streaming); the shared
// spawnCli coalescing means concurrent same-arg re-polls join one child.
let overviewWarmed = false
const run = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
try {
return { ok: true, value: await deps.spawnCli(build(...args)) }
@ -107,6 +150,31 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
return { ok: false, error: toEnvelopeError(err) }
}
}
// The desktop never renders the granular timeline, so it always passes
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null) => {
try {
const args = buildOverviewArgs(period, provider, range, configSource)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args) }
const value = await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,
extraEnv: { CODEBURN_PROGRESS: '1' },
onStderr: makeProgressReader(emitProgress),
})
overviewWarmed = true
emitProgress({ kind: 'done' })
return { ok: true, value }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
}
}
const runAction = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
try {
const result = await deps.spawnCliAction(build(...args))
@ -121,9 +189,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
try { return { ok: true, value: await deps.getQuota({ force: Boolean(force) }) } }
catch (error) { return { ok: false, error: { kind: 'nonzero', message: sanitizeError(error) } } }
},
'codeburn:getOverview': run((period: string, provider: string, range?: DateRange, configSource?: string | null) => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]),
'codeburn:getOverview': getOverview,
'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [

View file

@ -51,6 +51,12 @@ const bridge = {
chooseDirectory: () => invoke('codeburn:chooseDirectory'),
cliStatus: () => invoke('codeburn:cliStatus'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
// Cold-start scan progress (main → renderer). Returns an unsubscribe fn.
onProgress: (cb: (event: unknown) => void) => {
const listener = (_e: unknown, event: unknown) => cb(event)
ipcRenderer.on('codeburn:progress', listener)
return () => { ipcRenderer.removeListener('codeburn:progress', listener) }
},
platform: process.platform,
}

View file

@ -112,6 +112,13 @@ export function App() {
)
const refreshOverview = overview.refresh
// 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
// full-history parse per section. Flips true the moment overview has data OR a
// (resolved) error; after that everything polls normally.
const ready = overview.data != null || overview.error != null
useEffect(() => {
let saved: string | null = null
try { saved = globalThis.localStorage?.getItem('codeburn.theme') ?? null } catch { /* storage can be unavailable */ }
@ -235,7 +242,7 @@ export function App() {
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} />
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} />
) : (
@ -257,17 +264,17 @@ export function App() {
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} />
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} ready={ready} />
) : section === 'optimize' ? (
<OptimizeContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
<OptimizeContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} ready={ready} />
) : section === 'models' ? (
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} />
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'compare' ? (
<Compare period={period} provider={provider} range={customRange} refreshToken={refreshToken} />
<Compare period={period} provider={provider} range={customRange} refreshToken={refreshToken} ready={ready} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}

View file

@ -2,6 +2,14 @@
import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// The splash captures `codeburn` at import; mock it so a test can drive the
// progress callback the splash subscribes with.
let progressCb: ((event: unknown) => void) | undefined
vi.mock('../lib/ipc', () => ({
codeburn: { onProgress: (cb: (event: unknown) => void) => { progressCb = cb; return () => { progressCb = undefined } } },
normalizeCliError: (err: unknown) => err,
}))
import { Splash } from './Splash'
import { mockMatchMedia as mockReducedMotion } from '../lib/testMatchMedia'
@ -72,6 +80,32 @@ describe('Splash', () => {
expect(splashEl()).not.toBeInTheDocument()
})
it('reveals the per-provider indexing list on real cold-scan progress', () => {
render(<Splash hasData={false} hasError={false} />)
expect(splashEl()).toBeInTheDocument()
// No detail before any progress arrives.
expect(document.querySelector('.splash-status')).toBeNull()
act(() => {
progressCb?.({ kind: 'providers', providers: ['claude', 'codex'] })
progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' })
// A nonzero-total tick means the cache is genuinely cold: reveal at once.
progressCb?.({ kind: 'tick', provider: 'claude', done: 120, total: 480 })
})
const status = document.querySelector('.splash-status')
expect(status).toBeInTheDocument()
expect(status?.textContent).toContain('First run: indexing your usage history')
expect(status?.textContent).toContain('Ingesting Claude…')
expect(status?.textContent).toContain('120/480')
// Both detected providers render a row; claude is active, codex pending.
expect(document.querySelectorAll('.splash-prov').length).toBe(2)
expect(document.querySelector('.splash-prov.active')?.textContent).toContain('Claude')
act(() => { progressCb?.({ kind: 'provider', provider: 'claude', state: 'done' }) })
expect(document.querySelector('.splash-prov.done')?.textContent).toContain('Claude')
})
it('swaps instantly under reduced motion (no fade, no min-time)', () => {
mockReducedMotion(true)
const { rerender } = render(<Splash hasData={false} hasError={false} />)

View file

@ -2,14 +2,61 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { FlameMark } from './FlameMark'
import { ProviderLogo } from './ProviderLogo'
import { motionClass, motionEnabled, reducedMotion } from '../lib/motion'
import { codeburn } from '../lib/ipc'
import type { ScanProgressEvent } from '../lib/types'
import { version } from '../../package.json'
import loaderVideo from '../assets/splash-loader.webm'
const MIN_ON_SCREEN_MS = 600
const CROSSFADE_MS = 250
// If the first scan is still running this long after boot, reveal the per-provider
// indexing detail. Warm launches resolve well before this, so they never show it.
const REVEAL_FALLBACK_MS = 3500
type Phase = 'lit' | 'out' | 'done'
type ProvStatus = 'pending' | 'active' | 'done'
type Progress = {
/** Detected providers, in the order the CLI reports them. */
order: string[]
status: Record<string, ProvStatus>
claudeDone: number
claudeTotal: number
/** A tick with a nonzero total — the cache is genuinely cold and parsing. */
realWork: boolean
/** Any progress event at all has arrived (distinguishes a new CLI from old). */
seen: boolean
}
const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, realWork: false, seen: false }
function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
switch (event.kind) {
case 'providers': {
const status: Record<string, ProvStatus> = {}
for (const p of event.providers) status[p] = state.status[p] ?? 'pending'
return { ...state, order: event.providers, status, seen: true }
}
case 'provider': {
const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider]
const next: ProvStatus = event.state === 'done' ? 'done' : 'active'
return { ...state, order, status: { ...state.status, [event.provider]: next }, seen: true }
}
case 'tick':
return { ...state, claudeDone: event.done, claudeTotal: event.total, realWork: state.realWork || event.total > 0, seen: true }
case 'done': {
const status = { ...state.status }
for (const p of state.order) status[p] = 'done'
return { ...state, status }
}
}
}
function providerLabel(id: string): string {
return id.split(/[-\s]+/).filter(Boolean).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
}
/**
* Full-window branded startup loader -- the same scanning moment as the menubar
@ -20,11 +67,36 @@ type Phase = 'lit' | 'out' | 'done'
* never trapped behind branding; reduced motion swaps instantly with no fade.
* A `done` latch means later loading states -- polls, filter changes -- never
* bring it back.
*
* On a genuinely cold first run the overview warmup streams per-provider scan
* progress (main.ts forwards the CLI's stderr). Once real parse work is detected
* (or the scan simply outlasts REVEAL_FALLBACK_MS), the splash reveals a "first
* run: indexing" line and a per-provider ingest list. A warm launch resolves
* before that threshold and never shows it.
*/
export function Splash({ hasData, hasError }: { hasData: boolean; hasError: boolean }) {
const [phase, setPhase] = useState<Phase>('lit')
const [progress, setProgress] = useState<Progress>(EMPTY)
const [reveal, setReveal] = useState(false)
const shownAt = useRef(Date.now())
const done = useRef(false)
const seenRef = useRef(false)
// Subscribe once to cold-start progress. `codeburn` is undefined outside the
// Electron preload (e.g. unit tests); guard so the splash still renders.
useEffect(() => {
if (!codeburn || typeof codeburn.onProgress !== 'function') return
return codeburn.onProgress(event => setProgress(prev => reduceProgress(prev, event)))
}, [])
useEffect(() => { seenRef.current = progress.seen }, [progress.seen])
// Real parse work reveals the detail at once; otherwise a slow-scan fallback.
useEffect(() => { if (progress.realWork) setReveal(true) }, [progress.realWork])
useEffect(() => {
const timer = setTimeout(() => { if (seenRef.current) setReveal(true) }, REVEAL_FALLBACK_MS)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (done.current) return
@ -54,6 +126,7 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool
if (phase === 'done' || typeof document === 'undefined') return null
const base = phase === 'out' ? 'splash splash-out' : 'splash'
const showDetail = reveal && phase === 'lit'
return createPortal(
<div className={motionClass(base, 'splash-lit')} aria-hidden="true">
{motionEnabled() ? (
@ -67,6 +140,31 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool
)}
<div className="splash-word">CodeBurn</div>
<div className="splash-version">v{version}</div>
{showDetail && (
<div className="splash-status">
<div className="splash-status-line">
First run: indexing your usage history. This one-time scan can take a few minutes; future launches are instant.
</div>
{progress.order.length > 0 && (
<ul className="splash-providers">
{progress.order.map(id => {
const status = progress.status[id] ?? 'pending'
const count = id === 'claude' && status === 'active' && progress.claudeTotal > 0
? ` ${progress.claudeDone.toLocaleString('en-US')}/${progress.claudeTotal.toLocaleString('en-US')}`
: ''
const text = status === 'active' ? `Ingesting ${providerLabel(id)}${count}` : providerLabel(id)
return (
<li key={id} className={`splash-prov ${status}`}>
<ProviderLogo provider={id} size={16} />
<span className="splash-prov-name">{text}</span>
{status === 'done' && <span className="splash-prov-check"></span>}
</li>
)
})}
</ul>
)}
</div>
)}
</div>,
document.body,
)

View file

@ -37,6 +37,27 @@ describe('usePolled', () => {
expect(result.current.data).toBe('B-fresh')
})
it('does not fetch while disabled, then fires once enabled flips true', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
const { result, rerender } = renderHook(
({ enabled }: { enabled: boolean }) => usePolled(fetcher, ['x'], { enabled }),
{ initialProps: { enabled: false } },
)
// Gated: no spawn, still in the initial loading state (splash/skeleton stays).
expect(fetcher).not.toHaveBeenCalled()
expect(result.current.loading).toBe(true)
expect(result.current.data).toBeNull()
// Gate opens (first overview resolved): the fetch fires exactly once.
rerender({ enabled: true })
expect(fetcher).toHaveBeenCalledTimes(1)
await act(async () => { resolvers[0]!('ready') })
expect(result.current.data).toBe('ready')
})
it('keeps last-good data and exposes the error when a background reload fails', async () => {
const calls: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
const fetcher = vi.fn(() => new Promise<string>((resolve, reject) => { calls.push({ resolve, reject }) }))

View file

@ -17,8 +17,20 @@ export type Polled<T> = {
* Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then
* re-polls every `intervalMs`. Errors are normalized to the CliError shape so
* sections can branch on `error.kind`. Last-good data is retained on error.
*
* `enabled` (default true) gates fetching: while false the hook stays in its
* initial loading state and issues no CLI spawn. The app boot flow sets it false
* on every section poll until the first overview resolves, so the one-time cold
* cache hydration happens ONCE (via overview) instead of fanning out into a
* parallel full-history parse per section.
*/
export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs = 30_000): Polled<T> {
export function usePolled<T>(
fetcher: () => Promise<T>,
deps: unknown[],
opts: { intervalMs?: number; enabled?: boolean } = {},
): Polled<T> {
const intervalMs = opts.intervalMs ?? 30_000
const enabled = opts.enabled ?? true
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<CliError | null>(null)
const [loading, setLoading] = useState(true)
@ -30,6 +42,7 @@ export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], interva
const epochRef = useRef(0)
const load = useCallback(() => {
if (!enabled) return
const epoch = ++epochRef.current
setLoading(true)
// Clear any prior error at the start of each attempt so a fresh poll never
@ -50,9 +63,10 @@ export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], interva
if (epochRef.current !== epoch) return
setLoading(false)
})
// deps are intentionally the caller-provided dependency list.
// deps are intentionally the caller-provided dependency list; `enabled`
// is prepended so flipping the gate re-creates load and fires immediately.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}, [enabled, ...deps])
useEffect(() => {
load()

View file

@ -549,7 +549,16 @@ export type PriceRates = { input?: number; output?: number; cacheRead?: number;
// ————— IPC surface (preload contextBridge → window.codeburn) —————
/** Cold-start scan progress streamed from the CLI warmup (src/parser.ts). */
export type ScanProgressEvent =
| { kind: 'providers'; providers: string[] }
| { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number }
| { kind: 'tick'; provider: string; done: number; total: number }
| { kind: 'done' }
export interface CodeburnBridge {
/** Subscribe to cold-start scan progress; returns an unsubscribe fn. */
onProgress(cb: (event: ScanProgressEvent) => void): () => void
getQuota(force?: boolean): Promise<QuotaProvider[]>
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>

View file

@ -33,15 +33,18 @@ export function Compare({
provider,
range = null,
refreshToken = 0,
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
ready?: boolean
}) {
const models = usePolled<ModelStats[]>(
() => codeburn.getCompareModels(period, provider),
[period, provider, refreshToken],
{ enabled: ready },
)
const [modelA, setModelA] = useState<string | null>(null)
const [modelB, setModelB] = useState<string | null>(null)

View file

@ -36,12 +36,14 @@ export function Models({
range = null,
refreshToken = 0,
onNavigate,
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
onNavigate?: (section: Section, pane?: SettingsPane) => void
ready?: boolean
}) {
const [lens, setLens] = useState<ModelsLens>('model')
const onAddAlias = () => onNavigate?.('settings', 'aliases')
@ -57,7 +59,7 @@ export function Models({
)}
</div>
{lens === 'audit' ? (
<AuditLens period={period} provider={provider} range={range} refreshToken={refreshToken} />
<AuditLens period={period} provider={provider} range={range} refreshToken={refreshToken} ready={ready} />
) : (
<ModelsUsage
period={period}
@ -66,6 +68,7 @@ export function Models({
byTask={lens === 'task'}
refreshToken={refreshToken}
onAddAlias={onAddAlias}
ready={ready}
/>
)}
</>
@ -79,6 +82,7 @@ function ModelsUsage({
byTask,
refreshToken,
onAddAlias,
ready,
}: {
period: Period
provider: string
@ -86,10 +90,12 @@ function ModelsUsage({
byTask: boolean
refreshToken: number
onAddAlias: () => void
ready: boolean
}) {
const report = usePolled<ModelReportRow[]>(
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
[period, provider, byTask, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
if (!report.data) {
@ -124,15 +130,18 @@ function AuditLens({
provider,
range,
refreshToken,
ready,
}: {
period: Period
provider: string
range: DateRange | null
refreshToken: number
ready: boolean
}) {
const report = usePolled<AuditRow[]>(
() => range ? codeburn.getAudit(period, provider, range) : codeburn.getAudit(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
if (!report.data) {

View file

@ -27,20 +27,26 @@ export function OptimizeContent({
range = null,
overview,
refreshToken = 0,
ready = true,
}: {
period: Period
provider?: string
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
ready?: boolean
}) {
// Gate on app-level readiness so boot hydrates the cache once (default true
// keeps standalone renders/tests polling normally).
const optimizeReport = usePolled<OptimizeJsonReport>(
() => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
const yieldReport = usePolled<YieldJsonReport>(
() => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
const [tab, setTab] = useState<OptimizeTab>('waste')

View file

@ -563,15 +563,20 @@ export function OverviewContent({
range = null,
overview,
onNavigate,
ready = true,
}: {
period: Period
provider?: string
range?: DateRange | null
overview: Polled<MenubarPayload>
onNavigate?: (section: 'optimize' | 'sessions') => void
ready?: boolean
}) {
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [])
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider])
// Gate secondary spawns on the app-level readiness (first overview resolved),
// so the cold hydration runs once (via overview) rather than 3 parses at once
// on boot. Defaults true so standalone renders/tests poll normally.
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [], { enabled: ready })
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready })
const { data, error } = overview
const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map<string, string>(), [data])

View file

@ -62,7 +62,7 @@ function manualPlanSummaries(status: StatusJson): JsonPlanSummary[] {
return planSummaries(status).filter(plan => plan.provider !== 'claude' && plan.provider !== 'codex')
}
export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period; refreshToken?: number; onNavigate?: (section: Section, pane?: SettingsPane) => void }) {
export function Plans({ period, refreshToken = 0, onNavigate, ready = true }: { period: Period; refreshToken?: number; onNavigate?: (section: Section, pane?: SettingsPane) => void; ready?: boolean }) {
// Force a fresh fetch (bypassing QuotaService's 2-min cache, and its keychain
// guard) when the user hits ⌘R or clicks Refresh in the Connect affordance;
// the steady 30s poll keeps serving cached quota.
@ -75,7 +75,7 @@ export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period
return codeburn.getQuota(force)
}, [refreshToken, reconnectNonce])
const reconnect = () => setReconnectNonce(value => value + 1)
const budgetReport = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken])
const budgetReport = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken], { enabled: ready })
const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : []
return (

View file

@ -66,6 +66,7 @@ export function Sessions({
refreshToken = 0,
detectedProviders = [],
onProviderChange = () => {},
ready = true,
}: {
period: Period
provider: string
@ -73,6 +74,7 @@ export function Sessions({
refreshToken?: number
detectedProviders?: Array<{ id: string; label: string }>
onProviderChange?: (value: string) => void
ready?: boolean
}) {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState('')
@ -82,6 +84,7 @@ export function Sessions({
const report = usePolled<SessionRow[]>(
() => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
const rows = report.data ?? []
const q = query.trim().toLowerCase()

View file

@ -47,16 +47,21 @@ export function SpendContent({
range = null,
overview,
refreshToken = 0,
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
ready?: boolean
}) {
// Gate on app-level readiness so boot hydrates the cache once (default true
// keeps standalone renders/tests polling normally).
const flow = usePolled<SpendFlow>(
() => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready },
)
if (!overview.data) {

View file

@ -900,8 +900,35 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
to { opacity: 1; transform: none; }
}
/* First-run indexing detail: framing line + per-provider ingest list. Colors are
fixed to the dark splash canvas (like .splash-word/.splash-version), not the theme. */
.splash-status {
margin-top: 8px; display: flex; flex-direction: column; align-items: center; gap: 16px;
max-width: 420px; text-align: center;
animation: splash-word-in 400ms ease-out both;
}
.splash-status-line { font-size: var(--fs-label); line-height: 1.5; color: #b3a595; }
.splash-providers {
list-style: none; margin: 0; padding: 0; width: 100%;
display: flex; flex-direction: column; gap: 7px;
}
.splash-prov {
display: flex; align-items: center; gap: 9px;
font-size: var(--fs-label); color: #6f665c; padding: 0 6px;
}
.splash-prov .provider-logo { opacity: 0.4; flex: none; }
.splash-prov-name { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.splash-prov.active { color: #f4f1ec; }
.splash-prov.active .provider-logo { opacity: 1; animation: splash-prov-pulse 1.4s ease-in-out infinite; }
.splash-prov.done { color: #b3a595; }
.splash-prov.done .provider-logo { opacity: 0.85; }
.splash-prov-check { color: #7ec98f; font-size: 0.85em; flex: none; }
@keyframes splash-prov-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
@media (prefers-reduced-motion: reduce) {
.fm-flicker,
.splash-lit .splash-mark, .splash-lit .splash-mark::before, .splash-lit .splash-word { animation: none; }
.splash { transition: none; }
.splash-status { animation: none; }
.splash-prov.active .provider-logo { animation: none; }
}