Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,17 @@ the same machine (no quarantine attribute is applied to files that were never
downloaded) — they only apply to a build distributed to someone else, e.g.
via a GitHub Release.

### Folder-access prompts re-appear on every update

CodeBurn requests access to folders like Documents, Desktop, and Downloads
(via `mac.extendInfo` in `app/package.json`) to read local AI coding tool
session logs. Because each ad-hoc/unsigned build has no stable Developer ID,
macOS TCC treats every rebuild as a new app identity, so users get
re-prompted for folder access after each update even though nothing else
changed. Signing with a stable Developer ID certificate (see "Upgrade path"
below) fixes this — TCC grants persist across updates once the app's
identity is stable.

## Upgrade path: paid account + notarization

When a paid Apple Developer Program membership is available, the same
Expand Down
7 changes: 6 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@
"icon": "build/icon.png",
"identity": "-",
"hardenedRuntime": false,
"gatekeeperAssess": false
"gatekeeperAssess": false,
"extendInfo": {
"NSDocumentsFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine.",
"NSDesktopFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine.",
"NSDownloadsFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine."
}
},
"dmg": {},
"win": {
Expand Down
58 changes: 53 additions & 5 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Splash } from './components/Splash'
import { ToastHost } from './components/ToastHost'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { hasPolledMemo, primePolledMemo, usePolled } from './hooks/usePolled'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
Expand Down Expand Up @@ -77,6 +77,18 @@ const PERIOD_LABELS: Record<Period, string> = {

const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']

// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync.
function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
}

// Prefetch pacing: wait a short idle after the first paint, then warm one
// provider at a time at low priority so the background scan never competes with
// the interaction the user is actually having.
const PREFETCH_START_DELAY_MS = 1500
const PREFETCH_STAGGER_MS = 400

function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
}
Expand Down Expand Up @@ -159,16 +171,20 @@ function AppMain() {
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
{ memoKey: `overview|${provider}|${period}|${customRange?.from ?? ''}-${customRange?.to ?? ''}|${claudeConfigSource ?? ''}` },
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
)
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
// full-history parse per section. Flips true the moment overview first has data
// OR a (resolved) error; LATCHED, so a later uncached switch (which clears
// overview.data to paint a skeleton) can never re-gate the sections.
const [ready, setReady] = useState(false)
useEffect(() => {
if (overview.data != null || overview.error != null) setReady(true)
}, [overview.data, overview.error])

// First-launch onboarding: shown until the telemetry consent screen has been
// completed once. All telemetry bridge calls are typeof-guarded so an older
Expand Down Expand Up @@ -236,6 +252,38 @@ function AppMain() {
setCurrencyTick(tick => tick + 1)
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol])

// Prefetch for millisecond switches: once the first overview has resolved,
// quietly warm the instant-switch memo for every OTHER detected provider at the
// current period, so a picker switch to one paints from memory instead of
// waiting on a fresh 2-3s CLI spawn. One provider at a time, lowest priority,
// and only for the plain view (no custom range / no config scope) the picker
// actually toggles between. The CLI's own read-cache + in-flight coalescing keep
// this from double-spawning against a live user fetch; hasPolledMemo skips any
// provider already warm (including one warmed by a real visit).
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
if (targets.length === 0) return
let cancelled = false
const warm = async () => {
for (const id of targets) {
if (cancelled) return
const key = overviewMemoKey(id, period, null, null)
if (hasPolledMemo(key)) continue
try {
const value = await codeburn.getOverview(period, id)
if (!cancelled) primePolledMemo(key, value)
} catch { /* best-effort warm; a real switch will fetch and surface any error */ }
if (!cancelled) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS))
}
}
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])

useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
Expand Down
2 changes: 2 additions & 0 deletions app/renderer/components/AboutModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, type MouseEvent, type ReactNode } from 'react'

import { version } from '../../package.json'
import { FlameMark } from './FlameMark'
import { BUILD_STAMP } from '../lib/build'
import { codeburn } from '../lib/ipc'

export type SocialLink = {
Expand Down Expand Up @@ -42,6 +43,7 @@ export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClos
<span className="about-modal-logo" aria-hidden="true"><FlameMark size={52} /></span>
<div className="about-modal-name" id="about-modal-title">CodeBurn</div>
<div className="about-modal-version">v{version}</div>
<div className="about-modal-build">{BUILD_STAMP}</div>
<div className="about-modal-tagline">Know where every token goes, across every AI coding tool.</div>
</div>
<div className="about-modal-side">
Expand Down
22 changes: 20 additions & 2 deletions app/renderer/components/Splash.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ describe('Splash', () => {
expect(document.querySelector('.splash-status')).toBeNull()

act(() => {
progressCb?.({ kind: 'providers', providers: ['claude', 'codex'] })
// The leading `providers` event carries cold:true only on a genuine full
// hydration — that, and only that, reveals the indexing detail.
progressCb?.({ kind: 'providers', cold: true, 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 })
})

Expand All @@ -108,6 +109,23 @@ describe('Splash', () => {
expect(document.querySelector('.splash-status-line')?.textContent).toBe('Indexing your usage history…')
})

it('never reveals the indexing status on a warm launch (providers/ticks without cold)', () => {
render(<Splash hasData={false} hasError={false} />)
expect(splashEl()).toBeInTheDocument()

act(() => {
// A warm launch's incremental re-parse streams the same providers/provider/
// tick events, but the providers event has NO cold flag. The strip must stay
// hidden — this is the "indexing on every launch" bug fix.
progressCb?.({ kind: 'providers', cold: false, providers: ['claude', 'codex'] })
progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' })
progressCb?.({ kind: 'tick', provider: 'claude', done: 40, total: 60 })
progressCb?.({ kind: 'provider', provider: 'claude', state: 'done' })
})

expect(document.querySelector('.splash-status')).toBeNull()
})

it('swaps instantly under reduced motion (no fade, no min-time)', () => {
mockReducedMotion(true)
const { rerender } = render(<Splash hasData={false} hasError={false} />)
Expand Down
43 changes: 19 additions & 24 deletions app/renderer/components/Splash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ import { ProviderLogo } from './ProviderLogo'
import { motionClass, motionEnabled, reducedMotion } from '../lib/motion'
import { codeburn } from '../lib/ipc'
import type { ScanProgressEvent } from '../lib/types'
import { BUILD_STAMP } from '../lib/build'
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'
Expand All @@ -24,28 +22,29 @@ type Progress = {
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
/** True only for a genuine cold hydration (the CLI's `providers` event carries
* `cold: true`). A warm launch's incremental re-parse of a few changed files
* emits the same providers/tick stream WITHOUT this, so it never reveals the
* indexing detail. */
cold: boolean
}

const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, realWork: false, seen: false }
const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, cold: 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 }
return { ...state, order: event.providers, status, cold: state.cold || event.cold === 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 }
return { ...state, order, status: { ...state.status, [event.provider]: next } }
}
case 'tick':
return { ...state, claudeDone: event.done, claudeTotal: event.total, realWork: state.realWork || event.total > 0, seen: true }
return { ...state, claudeDone: event.done, claudeTotal: event.total }
case 'done': {
const status = { ...state.status }
for (const p of state.order) status[p] = 'done'
Expand Down Expand Up @@ -98,18 +97,18 @@ function SplashStatus({ progress }: { progress: Progress }) {
* 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 compact
* status block (see SplashStatus). A warm launch resolves before that threshold
* and never shows it.
* progress (main.ts forwards the CLI's stderr) and the leading `providers` event
* carries `cold: true`; that — and only that — reveals the compact indexing
* status block (see SplashStatus). A warm launch's incremental re-parse emits the
* same provider/tick stream without the cold flag, so the splash stays a plain
* flame and dismisses the moment data arrives.
*/
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.
Expand All @@ -118,14 +117,9 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool
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)
}, [])
// Only a genuine cold hydration reveals the indexing detail. Warm launches
// never set `cold`, so the strip stays hidden and the flame dismisses fast.
useEffect(() => { if (progress.cold) setReveal(true) }, [progress.cold])

useEffect(() => {
if (done.current) return
Expand Down Expand Up @@ -169,6 +163,7 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool
)}
<div className="splash-word">CodeBurn</div>
<div className="splash-version">v{version}</div>
<div className="splash-build">{BUILD_STAMP}</div>
{showDetail && <SplashStatus progress={progress} />}
</div>,
document.body,
Expand Down
28 changes: 28 additions & 0 deletions app/renderer/hooks/usePolled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ describe('usePolled', () => {
expect(result.current.switching).toBe(false)
})

it('clears stale data on a switch to an unmemoized key (skeleton, never the prior filter)', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))

const { result, rerender } = renderHook(
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }),
{ initialProps: { k: 'miss-A' } },
)
await act(async () => { resolvers[0]!('A0') })
expect(result.current.data).toBe('A0')

// Switch to a brand-new key with nothing memoized: data must drop to null so
// the section paints its skeleton, NOT the previous filter's numbers. This is
// the "old numbers for 2-3s on switch" fix — no cache hit, no stale hold.
rerender({ k: 'miss-B' })
expect(result.current.data).toBeNull()
expect(result.current.switching).toBe(false)
expect(result.current.loading).toBe(true)

await act(async () => { resolvers[1]!('B0') })
expect(result.current.data).toBe('B0')

// A background re-poll on the SAME key (its last result is memoized) must keep
// showing data — the clear-on-miss must never blank a plain refresh.
act(() => { result.current.refresh() })
expect(result.current.data).toBe('B0')
})

it('manual cadence (null interval) polls only on mount + refresh, never on a timer', async () => {
vi.useFakeTimers()
try {
Expand Down
20 changes: 19 additions & 1 deletion app/renderer/hooks/usePolled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ export function __resetPolledMemo(): void {
memoStore.clear()
}

/** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the
* overview result for every detected provider so a picker switch to one paints
* from memory in the same frame instead of waiting on a fresh CLI spawn. Keyed
* identically to the corresponding usePolled `memoKey`. */
export function primePolledMemo(key: string, value: unknown): void {
memoSet(key, value)
}

/** Whether a live result is already memoized for `key` (does not affect recency).
* Lets the prefetcher skip providers it has already warmed. */
export function hasPolledMemo(key: string): boolean {
return memoStore.has(key)
}

/**
* Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then
* re-polls every `intervalMs`. Errors are normalized to the CliError shape so
Expand Down Expand Up @@ -92,11 +106,15 @@ export function usePolled<T>(
const epoch = ++epochRef.current
// Instant paint: on a deps/key change, if a last-good result for the new key
// is cached, show it immediately and flag `switching` while the fresh fetch
// runs. Otherwise fall back to the normal loading state.
// runs. If there is NO cached result for the new key, clear stale data so the
// section paints its loading/skeleton state — never the previous filter's
// numbers. (An interval re-poll keeps the same key, whose last result is
// always cached, so a background refresh never blanks.)
let servedCached = false
if (memoKey) {
const cached = memoGet<T>(memoKey)
if (cached !== undefined) { setData(cached); servedCached = true }
else setData(null)
}
setLoading(true)
setSwitching(servedCached)
Expand Down
11 changes: 11 additions & 0 deletions app/renderer/lib/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Build identity, injected at package time by vite `define` (see vite.config.ts)
// from the git sha + build date. Falls back to 'dev' under the dev server and
// unit tests, where `define` is absent. Shown in the splash footer and the About
// modal so a field report is never ambiguous about which build is running.
declare const __BUILD_SHA__: string
declare const __BUILD_DATE__: string

export const BUILD_SHA = typeof __BUILD_SHA__ !== 'undefined' ? __BUILD_SHA__ : 'dev'
export const BUILD_DATE = typeof __BUILD_DATE__ !== 'undefined' ? __BUILD_DATE__ : ''
/** e.g. "a1b2c3d · 2026-07-16", or just the sha when no date is available. */
export const BUILD_STAMP = BUILD_DATE ? `${BUILD_SHA} · ${BUILD_DATE}` : BUILD_SHA
6 changes: 4 additions & 2 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,11 @@ export type TelemetryStatus = {
onboarded: boolean
}

/** Cold-start scan progress streamed from the CLI warmup (src/parser.ts). */
/** Cold-start scan progress streamed from the CLI warmup (src/parser.ts).
* `cold` (on the `providers` event) is true only for a genuine full hydration;
* a warm launch's incremental re-parse emits the same events without it. */
export type ScanProgressEvent =
| { kind: 'providers'; providers: string[] }
| { kind: 'providers'; providers: string[]; cold?: boolean }
| { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number }
| { kind: 'tick'; provider: string; done: number; total: number }
| { kind: 'done' }
Expand Down
Loading