perf(desktop): hydrate history progressively on first launch

This commit is contained in:
reviewer 2026-07-22 02:31:42 +02:00
parent 148d6116c1
commit ca28767f56
18 changed files with 455 additions and 49 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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<string, string> | 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<string, unknown> }> = []
const spawnCli = vi.fn(async (args: string[], opts?: Record<string, unknown>) => {
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<void>(resolve => { releaseStage = resolve })
let call = 0
const opts: Array<Record<string, unknown> | undefined> = []
const spawnCli = vi.fn(async (_args: string[], options?: Record<string, unknown>) => {
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<Record<string, unknown> | undefined> = []
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return { current: { cost: 1 } } })

View file

@ -71,12 +71,12 @@ export function createBeforeQuitHandler(deps: BeforeQuitDeps): (event: BeforeQui
// `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.
// 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<void> | 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)

View file

@ -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<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
@ -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(<App />)
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(<App />)

View file

@ -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<Set<string>>(() => 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<string | null>(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<Set<string>>(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<Set<string>>(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<unknown> }> = [
{
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}
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} refreshToken={refreshToken} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready && overview.data?.indexing !== true} refreshToken={refreshToken} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (
@ -612,7 +649,7 @@ function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPay
if (polled.data) {
return (
<>
{polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
{polled.data.indexing ? 'Indexing history' : polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
</>
)
}

View file

@ -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 => (
<span
key={opt.value}
className={opt.value === value ? 'on' : undefined}
className={[opt.value === value ? 'on' : '', opt.disabled ? 'disabled' : ''].filter(Boolean).join(' ') || undefined}
role="tab"
aria-selected={opt.value === value}
tabIndex={0}
onClick={() => 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 && <i className="seg-spinner" aria-hidden="true" />}
{opt.label}
</span>
))}

View file

@ -51,6 +51,10 @@ function reduceProgress(state: Progress, event: ScanProgressEvent): Progress {
for (const p of state.order) status[p] = 'done'
return { ...state, status }
}
case 'history-stage':
case 'period-ready':
case 'history-ready':
return state
}
}

View file

@ -34,6 +34,8 @@ export function TopBar({
claudeConfigs,
configSource,
onConfigSelect,
readyPeriods,
indexingPeriods = false,
}: {
title: ReactNode
scope?: ReactNode
@ -48,13 +50,21 @@ export function TopBar({
claudeConfigs?: ClaudeConfigSelector
configSource: string | null
onConfigSelect: (id: string) => void
readyPeriods?: ReadonlySet<string>
indexingPeriods?: boolean
}) {
const periodOptions = indexingPeriods
? PERIOD_OPTIONS.map(option => {
const ready = readyPeriods?.has(option.value) === true
return { ...option, disabled: !ready, loading: !ready }
})
: PERIOD_OPTIONS
return (
<div className="bar">
<div className="t">{title}</div>
{scope !== undefined && <span className="scope">{scope}</span>}
<div className="sp" />
<SegTabs options={PERIOD_OPTIONS} value={customRange ? '' : period} onChange={onPeriodChange} />
<SegTabs options={periodOptions} value={customRange ? '' : period} onChange={onPeriodChange} />
<CalendarPop value={customRange} onSelect={onRangeSelect} />
<ProviderPop value={provider} label={providerLabel} options={providerOptions} onSelect={onProviderSelect} />
{claudeConfigs && <ConfigPicker configs={claudeConfigs} value={configSource} onSelect={onConfigSelect} />}

View file

@ -118,6 +118,8 @@ export type ClaudeConfigSelector = {
export type MenubarPayload = {
generated: string
/** Cold first paint is ready while full history continues indexing. */
indexing?: boolean
current: {
label: string
cost: number
@ -618,6 +620,9 @@ export type ScanProgressEvent =
| { kind: 'provider'; provider: string; state: 'start' | 'done' | 'skipped'; files?: number }
| { kind: 'tick'; provider: string; done: number; total: number }
| { kind: 'done' }
| { kind: 'history-stage'; id: string; label: string; state: 'start' | 'ready' | 'error' }
| { kind: 'period-ready'; period: Period }
| { kind: 'history-ready' }
/** Update-availability status from the main process (app/electron/updates.ts). */
export type UpdateStatus = {

View file

@ -217,9 +217,12 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ni.on { background: var(--hover); color: var(--ink); box-shadow: none; }
.ni.on::before { background: var(--accent); }
.seg { background: var(--bg); border: 1px solid var(--line); }
.seg span { cursor: pointer; }
.seg span { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
.seg .on { background: var(--panel); box-shadow: inset 0 -2px 0 var(--accent); }
.seg span:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
.seg span.disabled { cursor: default; opacity: .52; }
.seg-spinner { width: 8px; height: 8px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: seg-spin .8s linear infinite; }
@keyframes seg-spin { to { transform: rotate(360deg); } }
.pop-wrap, .calendar-wrap { position: relative; }
.pop { background: var(--panel); color: var(--ink); cursor: pointer; user-select: none; }
.pop-menu {

View file

@ -1100,6 +1100,7 @@ program
optimize: opts.optimize !== false,
timeline: opts.timeline !== false,
claudeConfigSourceId: opts.claudeConfigSource,
fastStart: process.env['CODEBURN_FAST_START'] === '1',
})
if (opts.scope === 'combined') {
// Combined multi-device usage is best-effort enrichment on the menubar's

View file

@ -177,6 +177,9 @@ export type ClaudeConfigSelector = {
export type MenubarPayload = {
generated: string
/** True while a cold desktop install is serving an accurate range-limited
* first paint and lifetime history continues indexing in the background. */
indexing?: boolean
current: {
label: string
cost: number

View file

@ -3097,7 +3097,7 @@ const CACHE_TTL_MS = 180_000
const MAX_CACHE_ENTRIES = 10
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()
function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
function cacheKey(dateRange?: DateRange, providerFilter?: string, partialHydration = false): string {
const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none'
// Include the Claude config-dir env so a config change in a long-lived
// process (menubar / GNOME extension / test workers) does not return
@ -3105,7 +3105,7 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '')
// Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and
// then cached, so the key must change when that config changes.
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}`
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}:${partialHydration ? 'partial' : 'full'}`
}
export function clearSessionCache(): void {
@ -3521,8 +3521,72 @@ export function isSessionHydrationComplete(): boolean {
return sessionHydrationComplete
}
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise<ProjectSummary[]> {
const key = cacheKey(dateRange, providerFilter)
export type ParseSessionsOptions = {
/**
* Parse only sources that can contribute to `dateRange` and persist the work
* as an INCOMPLETE cache. Used by the desktop's first paint so a cold install
* does not wait for lifetime history. A later ordinary parse resumes from the
* partial cache and is the only path allowed to mark it complete.
*/
partialHydration?: boolean
}
function providerCoverageIncludes(cached: string, requested: string): boolean {
return cached === 'all' || cached === requested
}
/** Prepare an incomplete cache for a range-filtered parse without ever treating
* a narrow file snapshot as lifetime-complete. Claude cache files are retained
* because its scanner always captures the full transcript before slicing output;
* durable providers are retained because their cache is the historical source
* of truth. Other providers must reparse unchanged files when coverage expands. */
function prepareIncompleteCache(
cache: SessionCache,
dateRange: DateRange | undefined,
providerFilter: string | undefined,
partialHydration: boolean,
): void {
const previous = cache.partialRange
if (!partialHydration) {
if (!previous) return
for (const [provider, section] of Object.entries(cache.providers)) {
if (provider === 'claude' || section.durable || DURABLE_PROVIDER_NAMES.has(provider)) continue
delete cache.providers[provider]
}
delete cache.partialRange
;(cache as { _dirty?: boolean })._dirty = true
return
}
if (!dateRange) return
const requested = {
startMs: dateRange.start.getTime(),
endMs: dateRange.end.getTime(),
providerFilter: providerFilter ?? 'all',
}
const covered = previous
&& previous.startMs <= requested.startMs
&& previous.endMs >= requested.endMs
&& providerCoverageIncludes(previous.providerFilter, requested.providerFilter)
if (previous && !covered) {
for (const [provider, section] of Object.entries(cache.providers)) {
if (provider === 'claude' || section.durable || DURABLE_PROVIDER_NAMES.has(provider)) continue
delete cache.providers[provider]
}
}
if (!covered) {
cache.partialRange = requested
;(cache as { _dirty?: boolean })._dirty = true
}
}
export async function parseAllSessions(
dateRange?: DateRange,
providerFilter?: string,
parseOptions: ParseSessionsOptions = {},
): Promise<ProjectSummary[]> {
const partialHydration = parseOptions.partialHydration === true
const key = cacheKey(dateRange, providerFilter, partialHydration)
const cached = sessionCache.get(key)
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data
@ -3542,7 +3606,8 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
if (hydration.waited) diskCache = await loadCache()
const isCold = !isCacheComplete(diskCache)
try {
return await runParse(key, diskCache, dateRange, providerFilter, { isCold })
prepareIncompleteCache(diskCache, dateRange, providerFilter, partialHydration)
return await runParse(key, diskCache, dateRange, providerFilter, { isCold, partialHydration })
} finally {
await hydration.release()
}
@ -3580,6 +3645,7 @@ type RunParseOptions = {
isCold?: boolean
readOnly?: boolean
refreshLock?: RefreshLockHandle
partialHydration?: boolean
}
async function runParse(
@ -3589,7 +3655,7 @@ async function runParse(
providerFilter?: string,
options: RunParseOptions = {},
): Promise<ProjectSummary[]> {
const { isCold = false, readOnly = false, refreshLock } = options
const { isCold = false, readOnly = false, refreshLock, partialHydration = false } = options
const seenMsgIds = new Set<string>()
const seenKeys = new Set<string>()
const allSources = await discoverAllSessions(providerFilter)
@ -3684,8 +3750,14 @@ async function runParse(
// on is durable. A run killed before here never reaches this, so its throttled
// partial saves keep `complete: false` and the next launch resumes cold.
const wasComplete = isCacheComplete(diskCache)
if (!readOnly && !wasComplete) diskCache.complete = true
if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || !wasComplete)) {
// A range-limited first-paint parse is intentionally not proof that older
// sources were indexed. Persist its useful recent rows, but leave the cache
// incomplete so the background full hydration resumes and finalizes it.
if (!readOnly && !wasComplete && !partialHydration) {
diskCache.complete = true
if (diskCache.partialRange) delete diskCache.partialRange
}
if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || (!wasComplete && !partialHydration))) {
try {
const published = await saveCache(diskCache, refreshLock?.verifyStillOwner)
if (!published) throw new RefreshFenceLostError()
@ -3694,7 +3766,11 @@ async function runParse(
if (refreshLock) throw new RefreshPublicationUnavailableError()
}
}
sessionHydrationComplete = true
// Reaching the end of an ordinary parse is the same completion signal used
// before partial desktop hydration existed. Keep that behavior for normal
// and read-only refreshes; only the explicitly range-limited first-paint
// path must advertise that lifetime history is still incomplete.
sessionHydrationComplete = !partialHydration
// Merge across providers by normalised project path so the same repository
// is not double-counted when it was worked on with more than one tool

View file

@ -137,6 +137,15 @@ export type SessionCache = {
* can freeze a partial daily history. Absent on caches written before this
* field existed read as incomplete (one self-healing re-hydration). */
complete?: boolean
/** Coverage represented by range-filtered, non-durable provider entries while
* `complete !== true`. A wider partial request must invalidate those entries;
* otherwise an unchanged file would be reused as though its older turns had
* been parsed. Removed by the final ordinary full-history pass. */
partialRange?: {
startMs: number
endMs: number
providerFilter: string
}
}
// ── Constants ──────────────────────────────────────────────────────────
@ -401,6 +410,12 @@ function validateCache(raw: unknown): raw is SessionCache {
const o = raw as Record<string, unknown>
if (o['version'] !== CACHE_VERSION) return false
if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false
if (o['partialRange'] !== undefined) {
const range = o['partialRange']
if (!range || typeof range !== 'object' || Array.isArray(range)) return false
const r = range as Record<string, unknown>
if (!isNum(r['startMs']) || !isNum(r['endMs']) || typeof r['providerFilter'] !== 'string') return false
}
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
}

View file

@ -2,6 +2,7 @@ import { homedir } from 'node:os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, buildMenubarPayload } from './menubar-json.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js'
import { isCacheComplete as isSessionCacheComplete, loadCache as loadSessionCache } from './session-cache.js'
import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js'
@ -121,6 +122,9 @@ export type AggregateOpts = {
/// true. The desktop app never renders it, so it passes `--no-timeline` to
/// skip the buildGranularHistory pass on every menubar poll.
timeline?: boolean
/** Desktop-only cold-start mode. When the session cache is incomplete, serve
* the requested range first and let the app resume full indexing afterward. */
fastStart?: boolean
}
type ConfigOption = { id: string; label: string; path: string }
@ -287,7 +291,10 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
const rangeEndStr = toDateString(periodInfo.range.end)
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
const cache = await hydrateCache()
const cache = opts.fastStart ? emptyCache(getDailyCacheConfigHash()) : await hydrateCache()
const parse = opts.fastStart
? (range: DateRange, provider: string) => parseAllSessions(range, provider, { partialHydration: true })
: (range: DateRange, provider: string) => parseAllSessions(range, provider)
// Today's live data always comes from an all-provider parse so the union (and
// any per-provider slice of it) sees every provider's today. `todayAllDays` is
@ -298,25 +305,25 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
let scanRange: DateRange
if (pf === 'all') {
if (isTodayOnly) {
const raw = fp(await parseAllSessions(todayRange, 'all'))
const raw = fp(await parse(todayRange, 'all'))
liveProjects = raw
scanRange = todayRange
todayAllDays = aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
} else {
const raw = fp(await parseAllSessions(periodInfo.range, 'all'))
const raw = fp(await parse(periodInfo.range, 'all'))
liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw
scanRange = periodInfo.range
// A period that reaches today contains today's turns already, so derive the
// today slice from the same parse instead of scanning today again.
todayAllDays = rangeEndStr >= todayStr
? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
: aggregateProjectsIntoDays(fp(await parse(todayRange, 'all'))).filter(d => d.date === todayStr)
}
} else {
// Provider-filtered: today's all-provider parse feeds the union (sliced
// below); the provider-scoped parse feeds the detail/enrichment fields.
todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf))
todayAllDays = aggregateProjectsIntoDays(fp(await parse(todayRange, 'all'))).filter(d => d.date === todayStr)
const rawProv = fp(await parse(isTodayOnly ? todayRange : periodInfo.range, pf))
liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
@ -370,13 +377,20 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
const isAllProviders = pf === 'all'
// The Electron app requests fast start only for its first overview. Honor it
// solely for a genuinely incomplete session cache; warm launches keep the
// durable history path and return the exact same payload as before.
const fastStart = opts.fastStart === true && !isSessionCacheComplete(await loadSessionCache())
const parse = fastStart
? (range: DateRange, provider: string) => parseAllSessions(range, provider, { partialHydration: true })
: (range: DateRange, provider: string) => parseAllSessions(range, provider)
let todayAllProjects: ProjectSummary[] | null = null
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
const getTodayAllProjects = async (): Promise<ProjectSummary[]> => {
if (!todayAllProjects) {
todayAllProjects = fp(await parseAllSessions(todayRange, 'all'))
todayAllProjects = fp(await parse(todayRange, 'all'))
}
return todayAllProjects
}
@ -408,7 +422,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// A config source scopes Claude usage only, so scan just Claude (main.ts
// rejects a contradictory non-Claude --provider). This also avoids parsing
// every other provider's corpus on each scoped refresh.
const rawProjects = fp(await parseAllSessions(periodInfo.range, 'claude'))
const rawProjects = fp(await parse(periodInfo.range, 'claude'))
const fullProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects
claudeConfigs = await claudeConfigSelector(fullProjects, requestedClaudeConfigSourceId)
const selectedSourceId = claudeConfigs?.selectedId ?? null
@ -434,6 +448,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
project: opts.project,
exclude: opts.exclude,
daysSelection,
fastStart,
})
currentData = durable.data
scanProjects = durable.liveProjects
@ -510,7 +525,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
end: now,
}
const historyProjects = filterProjectsByClaudeConfigSource(
fp(await parseAllSessions(historyRange, 'claude')),
fp(await parse(historyRange, 'claude')),
claudeConfigs.selectedId,
)
dailyHistory = dailyEntriesToHistory(aggregateProjectsIntoDays(historyProjects))
@ -766,5 +781,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
const granularRange = opts.daysSelection?.range ?? scanRange
const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange)
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
const payload = buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
if (fastStart) payload.indexing = true
return payload
}

View file

@ -89,8 +89,13 @@ async function seedLiveTodaySession(): Promise<void> {
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const now = new Date()
const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString()
const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString()
// Keep the fixture inside both "today through now" and "all through end of
// today" even when the suite runs before noon. Fixed midday timestamps made
// the provider-parity assertion depend on the local time of the test run.
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const elapsed = Math.max(0, now.getTime() - todayStart)
const ts = new Date(todayStart + Math.floor(elapsed / 3)).toISOString()
const ts2 = new Date(todayStart + Math.floor(elapsed * 2 / 3)).toISOString()
const line = (id: string, t: string): string => JSON.stringify({
type: 'assistant',
timestamp: t,

View file

@ -10,7 +10,7 @@ import { mkdir, mkdtemp, rm, unlink, writeFile, readFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js'
import { sessionCachePath } from '../src/session-cache.js'
let tmpHome: string
@ -63,6 +63,52 @@ afterEach(async () => {
})
describe('parseAllSessions hydration lock', () => {
it('persists a range-limited first paint as partial, then an ordinary parse finalizes it', async () => {
await writeClaudeSession(50)
const range = { start: new Date('2026-05-15T00:00:00Z'), end: new Date('2026-05-15T23:59:59Z') }
expect(totalOutput(await parseAllSessions(range, 'claude', { partialHydration: true }))).toBe(50)
const partial = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
expect(partial.complete).not.toBe(true)
expect(partial.partialRange).toEqual({
startMs: range.start.getTime(),
endMs: range.end.getTime(),
providerFilter: 'claude',
})
expect(isSessionHydrationComplete()).toBe(false)
clearSessionCache()
expect(totalOutput(await parseAllSessions(range, 'claude'))).toBe(50)
const complete = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
expect(complete.complete).toBe(true)
expect(complete.partialRange).toBeUndefined()
expect(isSessionHydrationComplete()).toBe(true)
})
it('invalidates narrow non-durable provider snapshots when partial coverage expands', async () => {
await writeClaudeSession(50)
const narrow = { start: new Date('2026-05-15T00:00:00Z'), end: new Date('2026-05-15T23:59:59Z') }
await parseAllSessions(narrow, 'claude', { partialHydration: true })
const seeded = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
// A synthetic unchanged non-durable section represents the dangerous case:
// its files were parsed for only the narrow range and must not be reused as
// though they included older turns when the next stage grows backward.
seeded.providers['fixture-provider'] = { envFingerprint: 'fixture', files: {} }
await writeFile(sessionCachePath(), JSON.stringify(seeded))
clearSessionCache()
const expanded = { start: new Date('2026-05-01T00:00:00Z'), end: narrow.end }
await parseAllSessions(expanded, 'fixture-provider', { partialHydration: true })
const cache = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
expect(cache.providers['fixture-provider']).toBeUndefined()
// Claude is intentionally retained: its scanner always caches the full
// transcript before the result is range-sliced.
expect(cache.providers.claude).toBeDefined()
expect(cache.partialRange.startMs).toBe(expanded.start.getTime())
expect(cache.complete).not.toBe(true)
})
it('waits for a live foreign lock, then serves the warm cache instead of re-parsing', async () => {
// Warm the cache once from a real on-disk session (output 50), capture the
// exact cache structure, then tamper the cached output to a sentinel (999)