diff --git a/app/electron/cli.ts b/app/electron/cli.ts index 1069665..ccbc461 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -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 { +function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: (chunk: string) => void): Promise { return new Promise((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 { 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 { +export function spawnCli( + args: string[], + opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv } = {}, +): Promise { 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) diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 77ae693..74e592c 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -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 | undefined> = [] + const spawnCli = vi.fn(async (_args: string[], o?: Record) => { 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 | 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 }) + }) +}) diff --git a/app/electron/main.ts b/app/electron/main.ts index d8980fb..cf3596f 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -8,6 +8,41 @@ import { getQuota, sanitizeError } from './quota' // `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. +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 + spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv }) => Promise spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise 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 @@ -99,7 +136,13 @@ type Handler = (...args: any[]) => Promise * 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 { +export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress }): Record { + 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) => [ diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 8f651df..106c7f7 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -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, } diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 425fd2d..c60da34 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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() { {section === 'plans' ? ( - + ) : section === 'settings' ? ( ) : ( @@ -257,17 +264,17 @@ export function App() { />
{section === 'overview' ? ( - + ) : section === 'sessions' ? ( - + ) : section === 'spend' ? ( - + ) : section === 'optimize' ? ( - + ) : section === 'models' ? ( - + ) : section === 'compare' ? ( - + ) : ( )} diff --git a/app/renderer/components/Splash.test.tsx b/app/renderer/components/Splash.test.tsx index 95207d0..31b2310 100644 --- a/app/renderer/components/Splash.test.tsx +++ b/app/renderer/components/Splash.test.tsx @@ -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() + 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() diff --git a/app/renderer/components/Splash.tsx b/app/renderer/components/Splash.tsx index fb82333..a27dfb9 100644 --- a/app/renderer/components/Splash.tsx +++ b/app/renderer/components/Splash.tsx @@ -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 + 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 = {} + 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('lit') + const [progress, setProgress] = useState(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( , document.body, ) diff --git a/app/renderer/hooks/usePolled.test.ts b/app/renderer/hooks/usePolled.test.ts index 3e89de5..ee5a930 100644 --- a/app/renderer/hooks/usePolled.test.ts +++ b/app/renderer/hooks/usePolled.test.ts @@ -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(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((resolve, reject) => { calls.push({ resolve, reject }) })) diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index a29a85c..c6f4252 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -17,8 +17,20 @@ export type Polled = { * 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(fetcher: () => Promise, deps: unknown[], intervalMs = 30_000): Polled { +export function usePolled( + fetcher: () => Promise, + deps: unknown[], + opts: { intervalMs?: number; enabled?: boolean } = {}, +): Polled { + const intervalMs = opts.intervalMs ?? 30_000 + const enabled = opts.enabled ?? true const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -30,6 +42,7 @@ export function usePolled(fetcher: () => Promise, 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(fetcher: () => Promise, 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() diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 03508bf..654af0f 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise getPlans(period: Period): Promise diff --git a/app/renderer/sections/Compare.tsx b/app/renderer/sections/Compare.tsx index fa0b5b0..f897243 100644 --- a/app/renderer/sections/Compare.tsx +++ b/app/renderer/sections/Compare.tsx @@ -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( () => codeburn.getCompareModels(period, provider), [period, provider, refreshToken], + { enabled: ready }, ) const [modelA, setModelA] = useState(null) const [modelB, setModelB] = useState(null) diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index 6731102..d789d2d 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -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('model') const onAddAlias = () => onNavigate?.('settings', 'aliases') @@ -57,7 +59,7 @@ export function Models({ )}
{lens === 'audit' ? ( - + ) : ( )} @@ -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( () => 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( () => range ? codeburn.getAudit(period, provider, range) : codeburn.getAudit(period, provider), [period, provider, range?.from, range?.to, refreshToken], + { enabled: ready }, ) if (!report.data) { diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 466b892..edd8625 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -27,20 +27,26 @@ export function OptimizeContent({ range = null, overview, refreshToken = 0, + ready = true, }: { period: Period provider?: string range?: DateRange | null overview: Polled 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( () => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider), [period, provider, range?.from, range?.to, refreshToken], + { enabled: ready }, ) const yieldReport = usePolled( () => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider), [period, provider, range?.from, range?.to, refreshToken], + { enabled: ready }, ) const [tab, setTab] = useState('waste') diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index a3aac58..ac3eb20 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -563,15 +563,20 @@ export function OverviewContent({ range = null, overview, onNavigate, + ready = true, }: { period: Period provider?: string range?: DateRange | null overview: Polled onNavigate?: (section: 'optimize' | 'sessions') => void + ready?: boolean }) { - const actReport = usePolled(() => codeburn.getActReport(), []) - const yieldReport = usePolled(() => 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(() => codeburn.getActReport(), [], { enabled: ready }) + const yieldReport = usePolled(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready }) const { data, error } = overview const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map(), [data]) diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index b6a2c5f..312404b 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -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(() => codeburn.getPlans(period), [period, refreshToken]) + const budgetReport = usePolled(() => codeburn.getPlans(period), [period, refreshToken], { enabled: ready }) const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : [] return ( diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 1558879..077299c 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -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(null) const [query, setQuery] = useState('') @@ -82,6 +84,7 @@ export function Sessions({ const report = usePolled( () => 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() diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index a9dc3b3..9c811c5 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -47,16 +47,21 @@ export function SpendContent({ range = null, overview, refreshToken = 0, + ready = true, }: { period: Period provider: string range?: DateRange | null overview: Polled 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( () => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider), [period, provider, range?.from, range?.to, refreshToken], + { enabled: ready }, ) if (!overview.data) { diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 53822b8..6f58111 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; } }