mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 05:41:29 +00:00
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:
parent
ded4e4e844
commit
55bc53e6be
18 changed files with 407 additions and 30 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }) }))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue