mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 11:55:33 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
Part 1 (src): expose two add-only, optional aggregations on the menubar payload's `current`, computed on the unscoped all-provider path from the surviving-session parse (carried history cannot contribute, as expected). - current.pullRequests: aggregateByPr rows (top 20 by cost) plus the multi-link-safe distinctCost/distinctSessions. Rows are by-reference, so they are never summed. - current.byBranch: new aggregateByBranch, per-branch spend (top 15 by cost) that carries each session's last-seen git branch forward across its turns. The cache stores a turn's branch only when it changes, so the parser now resolves the branch at reconstruction (before the date slice) and records SessionSummary.everHadBranch from the full transcript. That lets the report keep a branch-bearing session's pre-branch spend in an explicit null row even when the range clipped the anchor turn, while a provider that never captures a branch still contributes nothing. Part 2 (app): new "Pull requests" sidebar entry between Sessions and Spend (shortcuts reflow to number 8 for Plans). It renders the pullRequests rows as a refined table with the PR label linking out via openExternal, cost, sessions, calls and an active-date span, plus a footnote stating the distinct total and the by-reference attribution. A quiet explanatory line shows when no PR links exist, never a fake table. Tests: aggregateByBranch unit tests (carry-forward, null bucket, clipped anchor, by-reference session counts) and the Pull requests component tests (table, external link, footnote, both empty states). Nav-reflow assertions in Sidebar and App tests updated. Co-authored-by: reviewer <review@local>
616 lines
29 KiB
TypeScript
616 lines
29 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { EmptyNote } from './components/EmptyState'
|
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
|
import { Hint } from './components/Hint'
|
|
import { Onboarding } from './components/Onboarding'
|
|
import { Panel } from './components/Panel'
|
|
import { Sidebar, type Section } from './components/Sidebar'
|
|
import { Splash } from './components/Splash'
|
|
import { ToastHost } from './components/ToastHost'
|
|
import { UpdateBanner } from './components/UpdateBanner'
|
|
import { rangeLabel, TopBar } from './components/TopBar'
|
|
import { Window } from './components/Window'
|
|
import { clearPolledMemo, hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
|
|
import { readDailyBudget } from './lib/budget'
|
|
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
|
|
import { motionClass } from './lib/motion'
|
|
import { codeburn } from './lib/ipc'
|
|
import { localDateKey } from './lib/period'
|
|
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
|
|
import { OverviewContent } from './sections/Overview'
|
|
import { OptimizeContent } from './sections/Optimize'
|
|
import { Models } from './sections/Models'
|
|
import { Sessions } from './sections/Sessions'
|
|
import { PullRequestsContent } from './sections/PullRequests'
|
|
import { Compare } from './sections/Compare'
|
|
import { Plans } from './sections/Plans'
|
|
import { Settings, type SettingsPane } from './sections/Settings'
|
|
import { SpendContent } from './sections/Spend'
|
|
import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
|
|
|
|
// Bucket raw dollar amounts before they leave the machine: telemetry carries
|
|
// coarse ranges, never exact spend.
|
|
function costBucket(usd: number): string {
|
|
if (usd < 1) return '<1'
|
|
if (usd < 10) return '1-10'
|
|
if (usd < 50) return '10-50'
|
|
if (usd < 200) return '50-200'
|
|
if (usd < 1000) return '200-1k'
|
|
return '1k+'
|
|
}
|
|
|
|
// Bucket occurrence counts (MCP-server / skill invocations) the same way costBucket
|
|
// coarsens dollars: telemetry carries usage magnitude, never an exact tally.
|
|
function countBucket(n: number): string {
|
|
if (n < 10) return '1-10'
|
|
if (n < 100) return '10-100'
|
|
if (n < 1000) return '100-1k'
|
|
return '1k+'
|
|
}
|
|
|
|
/** Map each model to its dominant task category from the default models report.
|
|
* `topCategory` is computed only in that view (not `--by-task`). The overview's
|
|
* `topModels[].name` is the provider display name — for Claude that's exactly
|
|
* `modelDisplayName`, so we key on both it and the raw `model` id and take the
|
|
* highest-cost row per key (rows arrive cost-descending). */
|
|
export function topCategoryByModel(rows: ModelReportRow[]): Map<string, string> {
|
|
const map = new Map<string, string>()
|
|
for (const row of rows) {
|
|
if (!row.topCategory) continue
|
|
if (!map.has(row.modelDisplayName)) map.set(row.modelDisplayName, row.topCategory)
|
|
if (!map.has(row.model)) map.set(row.model, row.topCategory)
|
|
}
|
|
return map
|
|
}
|
|
|
|
/** The once-per-day anonymous aggregate (main process dedups by calendar day). */
|
|
export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Map<string, string>): Record<string, unknown> {
|
|
return {
|
|
period: payload.current.label,
|
|
providerCount: Object.keys(payload.current.providers).length,
|
|
costBucket: costBucket(payload.current.cost),
|
|
// Each top model with its coarse cost bucket, and — when the once-daily
|
|
// by-model report joins — its dominant task category (a single name string,
|
|
// never an array, so the sanitizer keeps it). This is the model x purpose cross.
|
|
models: (payload.current.topModels ?? []).slice(0, 8).map(model => {
|
|
const entry: Record<string, unknown> = { name: model.name, costBucket: costBucket(model.cost) }
|
|
const topCategory = modelCategories?.get(model.name)
|
|
if (topCategory) entry.topCategory = topCategory
|
|
return entry
|
|
}),
|
|
// Per-provider spend, same cost-bucketing as models. `providers` maps lowercased
|
|
// display name -> cost USD; sort by cost so the top spenders survive the cap.
|
|
providers: Object.entries(payload.current.providers ?? {})
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 8)
|
|
.map(([name, cost]) => ({ name, costBucket: costBucket(cost) })),
|
|
// Aggregate task categories (the "purpose" dimension across all models).
|
|
categories: (payload.current.topActivities ?? []).slice(0, 12).map(activity => ({
|
|
name: activity.name,
|
|
// Task-completion signal: share of turns resolved in one shot, 2dp.
|
|
oneShotRate: activity.oneShotRate == null ? -1 : Math.round(activity.oneShotRate * 100) / 100,
|
|
})),
|
|
// MCP servers and skills by name + bucketed usage. Names are config identifiers
|
|
// (like model names), never args/paths/descriptions. Skills are measured in turns.
|
|
mcpServers: (payload.current.mcpServers ?? []).slice(0, 12).map(server => ({
|
|
name: server.name,
|
|
callBucket: countBucket(server.calls),
|
|
})),
|
|
skills: (payload.current.skills ?? []).slice(0, 12).map(skill => ({
|
|
name: skill.name,
|
|
callBucket: countBucket(skill.turns),
|
|
})),
|
|
}
|
|
}
|
|
|
|
const SECTION_TITLES: Record<Section, string> = {
|
|
overview: 'Overview',
|
|
sessions: 'Sessions',
|
|
pullRequests: 'Pull requests',
|
|
spend: 'Spend',
|
|
optimize: 'Optimize',
|
|
models: 'Models',
|
|
compare: 'Compare',
|
|
plans: 'Plans',
|
|
settings: 'Settings',
|
|
}
|
|
|
|
const PERIOD_LABELS: Record<Period, string> = {
|
|
today: 'Today',
|
|
week: 'Last 7 days',
|
|
month: 'This month',
|
|
'30days': 'Last 30 days',
|
|
all: 'Last 6 months',
|
|
lifetime: 'Lifetime',
|
|
}
|
|
|
|
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime']
|
|
|
|
// 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. Exported so
|
|
// the prefetch-storm test can assert warmed keys survive between polls.
|
|
export 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
|
|
// A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set
|
|
// almost at once; pace it wide enough that each warm genuinely trails the last.
|
|
const PREFETCH_STAGGER_MS = 2000
|
|
// Base instant-switch memo keys live during overview polling besides the per-
|
|
// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`,
|
|
// plus one slot of headroom for section navigation. The memo cap is sized to
|
|
// (detected providers + this) so warmed entries — and the base overview key —
|
|
// never LRU-evict between polls (which would blank the overview and re-arm the
|
|
// prefetch every cycle).
|
|
const BASE_MEMO_KEYS = 4
|
|
|
|
function isPeriod(value: string): value is Period {
|
|
return (STANDARD_PERIODS as string[]).includes(value)
|
|
}
|
|
|
|
/** Boot period = the persisted "Default period" Settings writes, else today. */
|
|
function initialPeriod(): Period {
|
|
let saved: string | null = null
|
|
try { saved = globalThis.localStorage?.getItem('codeburn.defaultPeriod') ?? null } catch { /* storage can be unavailable */ }
|
|
return saved && isPeriod(saved) ? saved : 'today'
|
|
}
|
|
|
|
/** Persisted Claude config override (empty/absent = aggregate all configs). */
|
|
function initialConfigSource(): string | null {
|
|
try { return globalThis.localStorage?.getItem('codeburn.claudeConfigSource') || null } catch { return null }
|
|
}
|
|
|
|
function persistConfigSource(id: string | null): void {
|
|
try {
|
|
if (id) globalThis.localStorage?.setItem('codeburn.claudeConfigSource', id)
|
|
else globalThis.localStorage?.removeItem('codeburn.claudeConfigSource')
|
|
} catch { /* storage can be unavailable */ }
|
|
}
|
|
|
|
function providerName(provider: string): string {
|
|
if (provider === 'all') return 'All providers'
|
|
return provider
|
|
.split(/[-\s]+/)
|
|
.filter(Boolean)
|
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: number): string {
|
|
if (loading && lastSuccessAt === null) return 'refreshing…'
|
|
if (lastSuccessAt === null) return 'not refreshed yet'
|
|
const seconds = Math.max(0, Math.floor((now - lastSuccessAt) / 1000))
|
|
if (seconds < 1) return 'refreshed just now'
|
|
if (seconds < 60) return `refreshed ${seconds}s ago`
|
|
const minutes = Math.floor(seconds / 60)
|
|
return `refreshed ${minutes}m ago`
|
|
}
|
|
|
|
/** Provides the app-wide refresh cadence (read persisted at boot, applied live)
|
|
* so every usePolled below reads it as its default interval. */
|
|
export function App() {
|
|
const [refreshValue, setRefreshValue] = useState(readRefreshValue)
|
|
const setValue = useCallback((value: string) => {
|
|
setRefreshValue(value)
|
|
persistRefreshValue(value)
|
|
}, [])
|
|
const cadence = useMemo<RefreshCadence>(
|
|
() => ({ value: refreshValue, intervalMs: refreshValueToMs(refreshValue), setValue }),
|
|
[refreshValue, setValue],
|
|
)
|
|
return (
|
|
<RefreshCadenceContext.Provider value={cadence}>
|
|
<AppMain />
|
|
</RefreshCadenceContext.Provider>
|
|
)
|
|
}
|
|
|
|
function AppMain() {
|
|
const [section, setSection] = useState<Section>('overview')
|
|
const [settingsPane, setSettingsPane] = useState<SettingsPane>('general')
|
|
const [period, setPeriod] = useState<Period>(initialPeriod)
|
|
const [provider, setProvider] = useState<string>('all')
|
|
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
|
|
const [customRange, setCustomRange] = useState<DateRange | null>(null)
|
|
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
|
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
const [now, setNow] = useState(() => Date.now())
|
|
const [, setCurrencyTick] = useState(0)
|
|
|
|
// 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.
|
|
const overview = usePolled<MenubarPayload>(
|
|
() => claudeConfigSource
|
|
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
|
|
: customRange
|
|
? codeburn.getOverview(period, provider, customRange)
|
|
: codeburn.getOverview(period, provider),
|
|
[period, provider, 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 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
|
|
// preload (or the test bridge mock) degrades to "no onboarding, no tracking".
|
|
const [onboardingStatus, setOnboardingStatus] = useState<TelemetryStatus | null>(null)
|
|
useEffect(() => {
|
|
if (typeof codeburn.telemetryStatus !== 'function') return
|
|
codeburn.telemetryStatus()
|
|
.then(status => { if (status && !status.onboarded) setOnboardingStatus(status) })
|
|
.catch(() => { /* telemetry unavailable — skip onboarding */ })
|
|
}, [])
|
|
const finishOnboarding = useCallback((enabled: boolean) => {
|
|
setOnboardingStatus(null)
|
|
if (typeof codeburn.completeOnboarding === 'function') void codeburn.completeOnboarding(enabled).catch(() => {})
|
|
}, [])
|
|
|
|
const trackEvent = useCallback((name: string, props?: Record<string, unknown>) => {
|
|
if (typeof codeburn.telemetryTrack === 'function') void codeburn.telemetryTrack(name, props).catch(() => {})
|
|
}, [])
|
|
|
|
// Once-per-day anonymous usage aggregate, only from the canonical view
|
|
// (all providers, standard period, no config scope) so buckets are stable.
|
|
// Gated to the first qualifying render per calendar day so the extra by-model
|
|
// report fetch runs at most once/day, not on every poll (main also dedups the
|
|
// event). The fetch enriches each model with its dominant task category; if it
|
|
// 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
|
|
const today = localDateKey(new Date())
|
|
if (snapshotDayRef.current === today) return
|
|
snapshotDayRef.current = today
|
|
const payload = overview.data
|
|
void (async () => {
|
|
let modelCategories: Map<string, string> | undefined
|
|
try {
|
|
modelCategories = topCategoryByModel(await codeburn.getModels(period, 'all', false))
|
|
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
|
|
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
|
|
})()
|
|
}, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
|
|
|
|
useEffect(() => {
|
|
let saved: string | null = null
|
|
try { saved = globalThis.localStorage?.getItem('codeburn.theme') ?? null } catch { /* storage can be unavailable */ }
|
|
if (saved === 'light' || saved === 'dark') document.documentElement.setAttribute('data-theme', saved)
|
|
else document.documentElement.removeAttribute('data-theme')
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!overview.data) return
|
|
const details = overview.data.current.providerDetails
|
|
// Prefer providerDetails (internal id + display label); fall back to the
|
|
// providers map keys (lowercased display names) for older CLIs. The CLI
|
|
// only emits detected providers, so keep every entry (including ones with
|
|
// no spend this period) and sort by cost so zero-cost ones sit at the
|
|
// bottom of the picker.
|
|
const found = details
|
|
? [...details]
|
|
.sort((a, b) => b.cost - a.cost)
|
|
.map(entry => ({ id: entry.id, label: entry.label }))
|
|
: Object.entries(overview.data.current.providers)
|
|
// Fallback map keys are lowercased display names; ones with spaces
|
|
// ("grok build") cannot round-trip as --provider, so exclude them
|
|
// rather than offer a filter that is guaranteed to error.
|
|
.filter(([key]) => /^[a-z0-9-]+$/.test(key))
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([key]) => ({ id: key, label: providerName(key) }))
|
|
setDetectedProviders(current => {
|
|
const next = [...current]
|
|
for (const item of found) if (!next.some(entry => entry.id === item.id)) next.push(item)
|
|
return next.length === current.length ? current : next
|
|
})
|
|
}, [overview.data])
|
|
|
|
useEffect(() => {
|
|
const currency = overview.data?.currency
|
|
if (!currency) return
|
|
// While `switching`, `data` is a memo-served payload from a previous key that
|
|
// may carry a STALE currency (cached before a Settings currency change): never
|
|
// let it regress the display. Apply currency only from a freshly-resolved
|
|
// fetch; the fresh result (switching false) re-runs this and applies the real
|
|
// one. clearPolledMemo() on a currency mutation also purges those stale entries.
|
|
if (overview.switching) return
|
|
setActiveCurrency(currency)
|
|
setCurrencyTick(tick => tick + 1)
|
|
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol, overview.switching])
|
|
|
|
// Size the instant-switch memo to hold every prefetched provider overview plus
|
|
// the base keys, so warmed entries survive between polls instead of evicting.
|
|
useEffect(() => {
|
|
setPolledMemoMax(detectedProviders.length + BASE_MEMO_KEYS)
|
|
}, [detectedProviders.length])
|
|
|
|
// 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).
|
|
//
|
|
// `warmedKeys` is a session-lifetime once-per-key guard: each (provider,period)
|
|
// memo key is marked BEFORE its spawn, so an effect re-run — e.g. an overview
|
|
// poll that momentarily blanked `overview.data` — can never re-spawn a provider
|
|
// already warmed. New keys (a new provider id, or a period switch) still warm
|
|
// exactly once. Without this the prefetch re-fired every poll: 12 redundant
|
|
// full-history CLI parses every 30s, forever.
|
|
// Mirror the visible overview's fetch state into a ref so the prefetch can hold
|
|
// for a user-triggered fetch without re-arming the whole loop on each toggle.
|
|
const overviewBusyRef = useRef(false)
|
|
overviewBusyRef.current = overview.loading
|
|
const warmedKeys = useRef<Set<string>>(new Set())
|
|
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 (let i = 0; i < targets.length && !cancelled; ) {
|
|
const key = overviewMemoKey(targets[i]!, period, null, null)
|
|
if (warmedKeys.current.has(key) || hasPolledMemo(key)) { i++; continue }
|
|
// Only warm while the visible overview is idle: a user fetch in flight
|
|
// takes priority (background-classed CLI spawns yield their slot to it),
|
|
// so hold and retry this provider after the stagger rather than race it.
|
|
if (overviewBusyRef.current) {
|
|
await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS))
|
|
continue
|
|
}
|
|
warmedKeys.current.add(key)
|
|
try {
|
|
// background priority (5th arg) so this never delays an interactive
|
|
// poll; ignored by an older preload, degrading to current behavior.
|
|
const value = await codeburn.getOverview(period, targets[i]!, undefined, undefined, true)
|
|
if (!cancelled) primePolledMemo(key, value)
|
|
} catch { /* best-effort warm; a real switch will fetch and surface any error */ }
|
|
i++
|
|
if (!cancelled && i < targets.length) 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)
|
|
}, [])
|
|
|
|
const refreshVisible = useCallback(() => {
|
|
refreshOverview()
|
|
setRefreshToken(token => token + 1)
|
|
}, [refreshOverview])
|
|
|
|
// A Settings action changed config that alters computed costs/currency
|
|
// (currency/alias/plan/price-override). The electron read-cache is flushed CLI-
|
|
// side, but the renderer's instant-switch memo still holds payloads computed
|
|
// under the OLD config — a later provider switch would repaint the stale currency.
|
|
// Purge the memo, then force-refresh the active view so the new values land in a
|
|
// couple seconds (quick like the menubar) instead of at the next poll.
|
|
const onConfigMutated = useCallback(() => {
|
|
clearPolledMemo()
|
|
refreshVisible()
|
|
}, [refreshVisible])
|
|
|
|
const navigate = useCallback((next: Section, pane: SettingsPane = 'general') => {
|
|
setSettingsPane(pane)
|
|
setSection(next)
|
|
trackEvent('section_view', { section: next })
|
|
}, [trackEvent])
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
|
|
const key = event.key.toLowerCase()
|
|
if (key === '1') navigate('overview')
|
|
else if (key === '2') navigate('sessions')
|
|
else if (key === '3') navigate('pullRequests')
|
|
else if (key === '4') navigate('spend')
|
|
else if (key === '5') navigate('optimize')
|
|
else if (key === '6') navigate('models')
|
|
else if (key === '7') navigate('compare')
|
|
else if (key === '8') navigate('plans')
|
|
else if (key === ',') navigate('settings')
|
|
else if (key === 'r') refreshVisible()
|
|
else return
|
|
event.preventDefault()
|
|
}
|
|
|
|
document.addEventListener('keydown', onKeyDown)
|
|
return () => document.removeEventListener('keydown', onKeyDown)
|
|
}, [refreshVisible, navigate])
|
|
|
|
const onPeriodChange = (value: string) => {
|
|
if (isPeriod(value)) {
|
|
setCustomRange(null)
|
|
setPeriod(value)
|
|
}
|
|
}
|
|
|
|
// A Claude config scopes Claude usage only, so a non-Claude provider filter
|
|
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
|
|
// filter is already compatible and is left alone).
|
|
const onConfigSelect = (id: string) => {
|
|
const next = id || null
|
|
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
|
|
setClaudeConfigSource(next)
|
|
persistConfigSource(next)
|
|
}
|
|
|
|
// Symmetric direction: picking a non-Claude provider while a config is
|
|
// scoped would hit the same CLI rejection, so drop the config scope.
|
|
const onProviderSelect = (value: string) => {
|
|
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
|
|
setClaudeConfigSource(null)
|
|
persistConfigSource(null)
|
|
}
|
|
setProvider(value)
|
|
}
|
|
|
|
const claudeConfigs = overview.data?.claudeConfigs
|
|
const providerOptions = [
|
|
{ value: 'all', label: 'All providers' },
|
|
...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })),
|
|
]
|
|
const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider)
|
|
const activeConfigLabel = claudeConfigSource
|
|
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
|
|
: null
|
|
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
|
|
|
|
return (
|
|
<Window>
|
|
<Sidebar active={section} onNavigate={navigate} status={<StatusLine polled={overview} />} />
|
|
<ToastHost />
|
|
<Splash hasData={overview.data != null} hasError={overview.error != null} />
|
|
{onboardingStatus && <Onboarding defaultEnabled={onboardingStatus.defaultEnabled} onDone={finishOnboarding} />}
|
|
<div className="ct">
|
|
<div className={overview.switching ? 'switch-line on' : 'switch-line'} aria-hidden="true" />
|
|
<UpdateBanner />
|
|
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
|
|
<ErrorBoundary key={section}>
|
|
{section === 'plans' ? (
|
|
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
|
|
) : section === 'settings' ? (
|
|
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
|
|
) : (
|
|
<>
|
|
<TopBar
|
|
title={SECTION_TITLES[section]}
|
|
scope={scope}
|
|
period={period}
|
|
onPeriodChange={onPeriodChange}
|
|
customRange={customRange}
|
|
onRangeSelect={setCustomRange}
|
|
provider={provider}
|
|
providerLabel={providerLabel}
|
|
providerOptions={providerOptions}
|
|
onProviderSelect={onProviderSelect}
|
|
claudeConfigs={claudeConfigs}
|
|
configSource={claudeConfigSource}
|
|
onConfigSelect={onConfigSelect}
|
|
/>
|
|
<div className={motionClass('body', 'section-fade')}>
|
|
{section === 'overview' ? (
|
|
<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} ready={ready} />
|
|
) : section === 'pullRequests' ? (
|
|
<PullRequestsContent overview={overview} />
|
|
) : section === 'spend' ? (
|
|
<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} ready={ready} />
|
|
) : section === 'models' ? (
|
|
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
|
|
) : section === 'compare' ? (
|
|
<Compare period={period} provider={provider} range={customRange} refreshToken={refreshToken} ready={ready} />
|
|
) : (
|
|
<SectionPlaceholder title={SECTION_TITLES[section]} />
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</ErrorBoundary>
|
|
{section !== 'settings' && (
|
|
<Hint
|
|
items={[
|
|
{ k: '⌘1-8', label: 'Navigate' },
|
|
{ k: '⌘,', label: 'Settings' },
|
|
{ k: '⌘R', label: 'Refresh' },
|
|
]}
|
|
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Window>
|
|
)
|
|
}
|
|
|
|
function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
|
|
if (polled.data) {
|
|
return (
|
|
<>
|
|
{polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
|
|
</>
|
|
)
|
|
}
|
|
if (polled.error?.kind === 'not-found') return <>CLI not found</>
|
|
if (polled.loading) return <>scanning…</>
|
|
return <>—</>
|
|
}
|
|
|
|
function SectionPlaceholder({ title }: { title: string }) {
|
|
return (
|
|
<Panel title={title}>
|
|
<EmptyNote>{title} lands in a later task. The shell, data bridge, and design system are in place.</EmptyNote>
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
/** App-wide daily-budget alert: reads today's usage from the overview payload and
|
|
* warns at >=80% / alerts at >=100% of the configured cap. Dismissible per day. */
|
|
function DailyBudgetBanner({ payload, provider }: { payload: MenubarPayload | null; provider: string }) {
|
|
const [, bumpDismiss] = useState(0)
|
|
const budget = readDailyBudget()
|
|
if (!budget || !payload) return null
|
|
|
|
// Token totals in history.daily are zeroed under a specific-provider filter
|
|
// (only cost is per-provider), so a token cap can only be evaluated honestly on
|
|
// the all-providers view; otherwise we'd compare usage against a false zero.
|
|
if (budget.kind === 'tokens' && provider !== 'all') return null
|
|
|
|
const todayKey = localDateKey(new Date())
|
|
let dismissed: string | null = null
|
|
try { dismissed = globalThis.localStorage?.getItem('codeburn.dailyBudget.dismissed') ?? null } catch { /* storage can be unavailable */ }
|
|
if (dismissed === todayKey) return null
|
|
|
|
// Today's entry may be absent when there has been no activity yet: that's 0 used.
|
|
const entry = payload.history.daily.find(day => day.date === todayKey)
|
|
const used = budget.kind === 'usd'
|
|
? entry?.cost ?? 0
|
|
: entry ? entry.inputTokens + entry.outputTokens : 0
|
|
const percent = (used / budget.value) * 100
|
|
if (percent < 80) return null
|
|
|
|
const exceeded = percent >= 100
|
|
const spent = budget.kind === 'usd' ? formatUsd(used) : formatCompact(used)
|
|
const cap = budget.kind === 'usd' ? formatUsd(budget.value) : formatCompact(budget.value)
|
|
const text = exceeded
|
|
? `Daily budget exceeded: ${spent} of ${cap}`
|
|
: `Today's spend is at ${Math.floor(percent)}% of your daily budget`
|
|
|
|
const dismiss = () => {
|
|
try { globalThis.localStorage?.setItem('codeburn.dailyBudget.dismissed', todayKey) } catch { /* storage can be unavailable */ }
|
|
bumpDismiss(tick => tick + 1)
|
|
}
|
|
|
|
return (
|
|
<div role="status" className={exceeded ? 'budget-banner exceeded' : 'budget-banner'}>
|
|
<span>{text}</span>
|
|
<button type="button" className="set-text-button" onClick={dismiss}>Dismiss</button>
|
|
</div>
|
|
)
|
|
}
|