diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 30dfe17e..208bd865 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = { 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'] }, + // Combined scope emits --scope combined; an explicit local scope is identical + // to the default (no flag). The CLI rejects --scope with --provider, so a + // provider passed alongside combined is dropped (the renderer forces 'all'). + { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] }, { 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'] }, @@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => { { name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] }, { name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] }, { name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] }, + { name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] }, ] it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => { diff --git a/app/electron/main.ts b/app/electron/main.ts index 82a95c0d..5eddbdca 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null { if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source') return source } +function vScope(scope: string | undefined): 'local' | 'combined' { + if (scope === 'combined') return 'combined' + if (scope === undefined || scope === 'local') return 'local' + throw new CliError('bad-args', 'invalid scope') +} function vOutPath(outPath: string): string { if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute') return outPath @@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re // 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)), - ] + // + // Combined scope aggregates paired-device usage: the CLI rejects --scope + // combined alongside --provider/--project/--exclude (paired devices report + // unfiltered usage), so the provider filter is dropped in that mode. The + // caller (renderer) forces provider='all' when combined, so nothing is lost. + const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => { + const vScopeValue = vScope(scope) + return [ + 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', + ...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))), + ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), + ] + } // `background` (renderer prefetch only) drops this fetch to background priority // so it yields the CLI's run slots to any interactive poll or click. Optional // and defaulting to interactive, so an older preload that omits it is unchanged. - const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => { + const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => { coldStartBegan ??= Date.now() const priority: SpawnPriority | undefined = background ? 'background' : undefined try { - const args = buildOverviewArgs(period, provider, range, configSource) + const args = buildOverviewArgs(period, provider, range, configSource, scope) if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) } const value = await deps.spawnCli(args, { timeoutMs: WARMUP_TIMEOUT_MS, diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 39426ebd..1c4bc3e9 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -20,7 +20,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // renderer-side where `window.codeburn` is declared as CodeburnBridge. const bridge = { getQuota: (force?: boolean) => invoke('codeburn:getQuota', force), - getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background), + getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope), getPlans: (period: string) => invoke('codeburn:getPlans', period), getActReport: () => invoke('codeburn:getActReport'), getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 81c7e090..8548917a 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', { }) const mocks = vi.hoisted(() => ({ - getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), @@ -273,6 +273,25 @@ describe('App shortcuts', () => { }) }) + it('drives combined-scope overview fetches and persists the Scope setting', async () => { + render() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all')) + + fireEvent.keyDown(document, { key: ',', metaKey: true }) + fireEvent.click(await screen.findByLabelText('Scope')) + fireEvent.click(await screen.findByRole('option', { name: 'Combined' })) + + // Combined scope forces provider='all' and passes --scope combined (6th arg). + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined')) + expect(localStorage.getItem('codeburn.scope')).toBe('combined') + }) + + it('boots in combined scope from the persisted Scope setting', async () => { + localStorage.setItem('codeburn.scope', 'combined') + render() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined')) + }) + it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => { // grok's display name is "Grok Build"; the picker must show the label but // send the internal id `grok` as --provider (which assertProvider accepts). diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 1540bd31..a3110a21 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -27,7 +27,7 @@ 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' +import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types' // Bucket raw dollar amounts before they leave the machine: telemetry carries // coarse ranges, never exact spend. @@ -130,8 +130,8 @@ 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. 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 ?? ''}` +export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string { + return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}` } // Prefetch pacing: wait a short idle after the first paint, then warm one @@ -172,6 +172,15 @@ function persistConfigSource(id: string | null): void { } catch { /* storage can be unavailable */ } } +/** Boot scope = the persisted dashboard Scope setting, else local. */ +function initialScope(): Scope { + try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' } +} + +function persistScope(scope: Scope): void { + try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ } +} + function providerName(provider: string): string { if (provider === 'all') return 'All providers' return provider @@ -218,20 +227,27 @@ function AppMain() { const [detectedProviders, setDetectedProviders] = useState>([]) const [customRange, setCustomRange] = useState(null) const [claudeConfigSource, setClaudeConfigSource] = useState(initialConfigSource) + const [scope, setScopeState] = useState(initialScope) 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. + // Combined scope aggregates paired-device usage; the CLI rejects it alongside + // a provider/config filter, so onScopeChange forces provider='all' and clears + // the config scope before this poll runs. Passing scope='local' produces the + // same flag-free argv as before, so local users are unaffected. const overview = usePolled( - () => claudeConfigSource + () => scope === 'combined' + ? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined') + : 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) }, + [period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope], + { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) }, ) const refreshOverview = overview.refresh @@ -273,7 +289,7 @@ function AppMain() { // fails we still emit the snapshot, just without the model x category cross. const snapshotDayRef = useRef(null) useEffect(() => { - if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return + if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return const today = localDateKey(new Date()) if (snapshotDayRef.current === today) return snapshotDayRef.current = today @@ -285,7 +301,7 @@ function AppMain() { } catch { /* degrade: emit the snapshot without per-model topCategory */ } trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories)) })() - }, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent]) + }, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent]) useEffect(() => { let saved: string | null = null @@ -360,7 +376,9 @@ function AppMain() { overviewBusyRef.current = overview.loading const warmedKeys = useRef>(new Set()) useEffect(() => { - if (!ready || overview.data == null || customRange || claudeConfigSource) return + // Combined scope has no provider picker to warm — it always shows unfiltered + // all-device usage — so the per-provider prefetch is local-scope only. + if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider) if (targets.length === 0) return let cancelled = false @@ -391,7 +409,7 @@ function AppMain() { // `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]) + }, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null]) useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) @@ -451,17 +469,22 @@ function AppMain() { // 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). + // filter is already compatible and is left alone). Picking a config also + // implies a device-specific view, so drop combined scope back to local. const onConfigSelect = (id: string) => { const next = id || null if (next && provider !== 'all' && provider !== 'claude') setProvider('all') + if (next && scope === 'combined') { setScopeState('local'); persistScope('local') } 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. + // scoped would hit the same CLI rejection, so drop the config scope. A + // specific provider filter is a device-specific view, so it also drops + // combined scope back to local (combined reports unfiltered usage). const onProviderSelect = (value: string) => { + if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') } if (claudeConfigSource && value !== 'all' && value !== 'claude') { setClaudeConfigSource(null) persistConfigSource(null) @@ -469,6 +492,19 @@ function AppMain() { setProvider(value) } + // Combined scope reports unfiltered, all-provider usage across paired devices, + // so switching to it resets the provider filter and Claude-config scope (which + // the CLI would otherwise reject), mirroring the menubar's setMenubarScope. + const onScopeChange = (value: string) => { + const next: Scope = value === 'combined' ? 'combined' : 'local' + if (next === 'combined') { + if (provider !== 'all') setProvider('all') + if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) } + } + setScopeState(next) + persistScope(next) + } + const claudeConfigs = overview.data?.claudeConfigs const providerOptions = [ { value: 'all', label: 'All providers' }, @@ -478,7 +514,11 @@ function AppMain() { const activeConfigLabel = claudeConfigSource ? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null : null - const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}` + // Combined scope reports unfiltered all-device usage, so the caption reads + // "Combined" in place of the (forced-'all') provider label. + const scopeCaption = scope === 'combined' + ? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined` + : `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}` return ( @@ -494,12 +534,12 @@ function AppMain() { {section === 'plans' ? ( ) : section === 'settings' ? ( - + ) : ( <>
{section === 'overview' ? ( - + ) : section === 'sessions' ? ( ) : section === 'pullRequests' ? ( diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 14e2d80f..faf00a92 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -6,6 +6,10 @@ export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime' +// Dashboard usage scope: this device only ('local') or the aggregate across +// every paired device ('combined'). Mirrors the macOS menubar's Scope setting. +export type Scope = 'local' | 'combined' + export type DateRange = { from: string; to: string } export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args' @@ -637,7 +641,9 @@ export interface CodeburnBridge { getQuota(force?: boolean): Promise // `background` (prefetch only) requests background CLI-spawn priority; optional // so an older preload that ignores it degrades to interactive priority. - getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise + // `scope` selects local-device usage ('local', default) or paired-device + // aggregate ('combined'); optional so an older preload degrades to local. + getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 3138792e..1093eebe 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -520,6 +520,49 @@ describe('Overview', () => { expect(screen.queryByText('Saved to date')).not.toBeInTheDocument() }) + it('shows paired-device aggregate totals in the hero under combined scope', async () => { + const now = new Date() + const payload = makePayload(now) + // Local device: $312.40 / 4200 calls / 88 sessions (from makePayload). + // Combined swaps the hero to the cross-device aggregate and lists devices. + payload.combined = { + perDevice: [ + { id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 }, + { id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 }, + ], + combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 }, + } + + const { container } = render() + + const kpis = container.querySelector('.ov-hero-main') as HTMLElement + // Hero cost is the combined $500, not the local $312.40. + expect(within(kpis).getByText('$500.00')).toBeInTheDocument() + expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument() + expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument() + expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument() + expect(within(kpis).getByText('workstation')).toBeInTheDocument() + expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument() + // Combined mode hides the local savings lines (they are device-specific). + expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument() + }) + + it('keeps local hero totals when scope is local even if a combined payload is present', async () => { + const now = new Date() + const payload = makePayload(now) + payload.combined = { + perDevice: [], + combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 }, + } + + const { container } = render() + + const kpis = container.querySelector('.ov-hero-main') as HTMLElement + expect(within(kpis).getByText('$312.40')).toBeInTheDocument() + expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument() + expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument() + }) + it('shows a stale banner when last-good data is present but the latest poll failed', async () => { const now = new Date() const overview: Polled = { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 06cb9c66..b2a9dd20 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc' import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period' import type { ActReportJson, + CombinedUsage, DailyHistoryEntry, DateRange, MenubarPayload, Period, + Scope, YieldJsonReport, } from '../lib/types' @@ -650,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin return } +/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/ + * total device count, mirroring the menubar's combined view. An unreachable + * device (powered off, off-network) shows its error in place of a cost. */ +function CombinedDevices({ usage }: { usage: CombinedUsage }) { + return ( +
+
{usage.combined.reachableCount} of {usage.combined.deviceCount} devices
+ {usage.perDevice.map(device => ( +
+ {device.local ? `${device.name} · this device` : device.name} + {device.error ?? formatUsd(device.cost)} +
+ ))} +
+ ) +} + export function OverviewContent({ period, provider = 'all', @@ -657,6 +676,7 @@ export function OverviewContent({ overview, onNavigate, ready = true, + scope = 'local', }: { period: Period provider?: string @@ -664,6 +684,7 @@ export function OverviewContent({ overview: Polled onNavigate?: (section: 'optimize' | 'sessions') => void ready?: boolean + scope?: Scope }) { // 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 @@ -680,7 +701,14 @@ export function OverviewContent({ const now = new Date() const rangeActive = !!range - const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}` + // Combined scope shows the paired-device aggregate in the hero KPIs, mirroring + // the menubar. Only the hero totals are aggregated; the detailed panels below + // (daily chart, models) stay local — the combined payload carries totals only. + const combined = scope === 'combined' ? data.combined : undefined + const heroCost = combined ? combined.combined.cost : data.current.cost + const heroCalls = combined ? combined.combined.calls : data.current.calls + const heroSessions = combined ? combined.combined.sessions : data.current.sessions + const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}` const stats = deriveStats(data, now) const periodDaily = sliceDailyToPeriod(data.history.daily, period, now) // Daily chart: contiguous zero-filled calendar window. A custom range spans @@ -715,15 +743,21 @@ export function OverviewContent({ {error && }
-
{data.current.label}{streakDays(data.history.daily, now)}-day streak
- -
{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions
- {saved > 0 && ( -
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
- )} - {localSaved > 0 && ( -
Saved via local models{formatUsd(localSaved)}local-model routing
- )} +
{combined ? `Combined · ${data.current.label}` : data.current.label}{streakDays(data.history.daily, now)}-day streak
+ +
{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions
+ {combined + ? + : ( + <> + {saved > 0 && ( +
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ )} + {localSaved > 0 && ( +
Saved via local models{formatUsd(localSaved)}local-model routing
+ )} + + )}
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index c2d12827..58aa987e 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -145,6 +145,17 @@ describe('Settings', () => { expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy() }) + it('reflects the current scope and reports a change through onScopeChange', async () => { + const user = userEvent.setup() + const onScopeChange = vi.fn() + render() + const scope = screen.getByLabelText('Scope') + expect(scope).toHaveTextContent('Local') + await user.click(scope) + await user.click(screen.getByRole('option', { name: 'Combined' })) + expect(onScopeChange).toHaveBeenCalledWith('combined') + }) + it('lists providers from the real overview payload', async () => { const user = userEvent.setup() render() diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 17666f20..f3af6fed 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -18,7 +18,7 @@ import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence' import { showToast } from '../lib/toast' import { ToastHost } from '../components/ToastHost' import { rateLimitedNote } from './Plans' -import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types' +import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types' export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy' type Pane = SettingsPane @@ -97,7 +97,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st ) } -export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) { +export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) { const [pane, setPane] = useState(initialPane ?? 'general') return ( @@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } {pane === 'aliases' && } {pane === 'pricing' && } @@ -128,7 +128,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ) } -function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) { +function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) { const [currencyNonce, setCurrencyNonce] = useState(0) const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) const [theme, setTheme] = useState(() => { @@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
onScopeChange?.(value)} width={110} />
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError &&

{budgetError}

} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 64b45c5c..f82172b9 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -512,6 +512,12 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-saved-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 7px; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10.5px; } .ov-saved-line strong { color: var(--ok); font-family: var(--mono); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } .ov-saved-line small { color: var(--mut2); font-size: 10px; } +.ov-combined-devices { width: 100%; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); display: flex; flex-direction: column; gap: 3px; } +.ov-combined-head { color: var(--mut2); font-size: 10.5px; font-weight: 560; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 2px; } +.ov-combined-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--mut); } +.ov-combined-row .ov-combined-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ov-combined-row .ov-combined-val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); } +.ov-combined-row.err .ov-combined-val { color: var(--warn); font-family: inherit; } .ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; } .ov-activity-head { display: flex; align-items: baseline; gap: 8px; } .ov-hero-sub .neutral { color: var(--mut); font-weight: 560; } diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 32c9cdd1..3f09fdcf 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -271,6 +271,49 @@ final class AppStore { cache[menubarStatusKey]?.payload } + private var menubarCombinedKey: PayloadCacheKey { + PayloadCacheKey(scope: .combined, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId) + } + + /// Cross-device totals for the menubar badge's period, used so the badge + /// figure matches the popover hero under combined scope. `nil` under local + /// scope, or when no combined payload for the badge period is cached yet + /// (cold start, or the peer is unreachable) — the badge then falls back to + /// the local figure, exactly like the popover. + var menubarBadgeCombined: CombinedUsageTotals? { + guard effectiveSelectedScope == .combined else { return nil } + return cache[menubarCombinedKey]?.payload.combined?.combined + } + + /// `(reachable, total)` only when combined scope is active and fewer paired + /// devices reported than are paired — i.e. the badge total is degraded to + /// the reachable subset (a peer is asleep/off-network this cycle). The badge + /// shows this so a momentary drop to the local figure reads as "peer + /// unreachable", not a glitch. `nil` when every paired device reported (or + /// there is only one), and under local scope. + var menubarBadgeDeviceShortfall: (reachable: Int, total: Int)? { + guard let totals = menubarBadgeCombined, totals.reachableCount < totals.deviceCount else { return nil } + return (totals.reachableCount, totals.deviceCount) + } + + /// Refresh the payloads the badge renders for `period`: always the local + /// figure, plus the combined cross-device total when combined scope is + /// active. Combined is best-effort — a slow or unreachable peer degrades to + /// the local figure — so the local fetch alone determines success. + @discardableResult + func refreshMenubarBadge(period: Period, force: Bool = false, qualityOfService: QualityOfService = .userInitiated) async -> Bool { + async let local = refreshQuietly(period: period, force: force, qualityOfService: qualityOfService) + guard effectiveSelectedScope == .combined else { return await local } + async let combined = refreshQuietly( + key: PayloadCacheKey(scope: .combined, period: period, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId), + includeOptimize: false, + force: force, + qualityOfService: qualityOfService + ) + let (localSucceeded, _) = await (local, combined) + return localSucceeded + } + /// All-provider payload for the selected period. Used by the tab strip to show /// per-provider costs that match the active period, not just today. var periodAllPayload: MenubarPayload? { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 1188eeaf..758dca11 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -467,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // is refreshed by refreshPayloadForPopoverOpen the moment it opens, // so a closed-popover tick never pays for it (#647). if !(popover?.isShown ?? false) { - async let menubar = store.refreshQuietly( + async let menubar = store.refreshMenubarBadge( period: menubarPeriod, force: force, qualityOfService: qualityOfService @@ -489,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM qualityOfService: qualityOfService ) async let menubar = needsMenubarPayload - ? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService) + ? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService) : true async let today = needsTodayPayload ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) @@ -870,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM _ = self.store.payload _ = self.store.menubarPeriod _ = self.store.menubarPayload + // Combined-scope badge total: re-render the badge when the cross-device + // aggregate for the menubar period lands (or a peer goes reachable). + _ = self.store.menubarBadgeCombined // Track currency so the menubar title catches up immediately on // currency switch instead of waiting for the next 30s payload tick. _ = self.store.currency @@ -1023,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM if store.displayMetric != .iconOnly { let suffix = menubarPeriod.menubarSuffix(compact: compact) + // Under combined scope the badge shows the cross-device aggregate, so + // it matches the popover hero instead of trailing it with the local + // figure. Falls back to local when no combined payload is available + // (local scope, cold cache, or an unreachable peer). Credits have no + // combined total, so that metric always reflects the local device. + let badgeCombined = store.menubarBadgeCombined + let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost + let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens + let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens let valueText: String - if store.displayMetric == .tokens, let p = menubarPayload?.current { - let out = formatTokensMenubar(Double(p.outputTokens)) - let inp = formatTokensMenubar(Double(p.inputTokens)) - valueText = compact ? "↑\(out)↓\(inp)\(suffix)" : " ↑\(out) ↓\(inp)\(suffix)" - } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current { - let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens)) + if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens { + let outText = formatTokensMenubar(Double(out)) + let inpText = formatTokensMenubar(Double(inp)) + valueText = compact ? "↑\(outText)↓\(inpText)\(suffix)" : " ↑\(outText) ↓\(inpText)\(suffix)" + } else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens { + let total = formatTokensMenubar(Double(inp + out)) valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)" } else if store.displayMetric == .credits, let p = menubarPayload?.current { let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)" } else { let fallback = compact ? "$-" : "$—" - let formatted = menubarPayload?.current.cost valueText = compact - ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix - : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix + ? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix + : " " + (cost?.asCompactCurrency() ?? fallback) + suffix } var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0] @@ -1047,10 +1058,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM textAttrs[.foregroundColor] = NSColor.secondaryLabelColor } composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) + + // Combined scope, but a paired device didn't report this cycle: append + // a dimmed "reachable/total" so the reduced total reads as "peer + // unreachable" rather than a glitch (mirrors the popover's device list). + if let shortfall = store.menubarBadgeDeviceShortfall { + let marker = " · \(shortfall.reachable)/\(shortfall.total)" + let markerAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .baselineOffset: -1.0, + .foregroundColor: NSColor.secondaryLabelColor, + ] + composed.append(NSAttributedString(string: marker, attributes: markerAttrs)) + } } button.attributedTitle = composed - button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + if let shortfall = store.menubarBadgeDeviceShortfall { + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting" + } else { + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + } persistBadgeStatusFile() } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 74d0ec8d..c021b456 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -202,6 +202,113 @@ struct AppStoreRefreshRecoveryTests { #expect(store.payload.combined == nil) } + @Test("menubar badge shows the combined total under combined scope") + func menubarBadgeShowsCombinedTotal() { + let store = AppStore() + store.suppressRefreshesForTesting() + let period = store.menubarPeriod + // Local badge figure and a higher cross-device combined total, both for + // the badge's period. + store.setCachedPayloadForTesting( + menubarPayload(cost: 30), + scope: .local, + period: period, + provider: .all, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 30, combined: combinedUsage(cost: 75)), + scope: .combined, + period: period, + provider: .all, + fetchedAt: Date() + ) + + // Local scope: no combined total, so the badge renders the local figure. + store.selectedScope = .local + #expect(store.menubarBadgeCombined == nil) + + // Combined scope: the badge total is the cross-device aggregate ($75), + // not the local $30 — this is the fix for the badge trailing the popover. + store.selectedScope = .combined + #expect(store.menubarBadgeCombined?.cost == 75) + } + + @Test("badge reports a device shortfall when a paired peer is unreachable") + func menubarBadgeReportsDeviceShortfall() { + let store = AppStore() + store.suppressRefreshesForTesting() + let period = store.menubarPeriod + // Combined payload where only 1 of 2 paired devices reported this cycle + // (the peer is asleep/off-network), so the aggregate is degraded to local. + let degraded = CombinedUsage( + perDevice: [], + combined: CombinedUsageTotals( + cost: 30, + calls: 3, + sessions: 2, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + deviceCount: 2, + reachableCount: 1 + ) + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 30, combined: degraded), + scope: .combined, + period: period, + provider: .all, + fetchedAt: Date() + ) + store.selectedScope = .combined + + let shortfall = store.menubarBadgeDeviceShortfall + #expect(shortfall?.reachable == 1) + #expect(shortfall?.total == 2) + } + + @Test("badge reports no shortfall when every paired device reports") + func menubarBadgeNoShortfallWhenAllReachable() { + let store = AppStore() + store.suppressRefreshesForTesting() + let period = store.menubarPeriod + // combinedUsage() carries deviceCount == reachableCount == 1. + store.setCachedPayloadForTesting( + menubarPayload(cost: 30, combined: combinedUsage(cost: 30)), + scope: .combined, + period: period, + provider: .all, + fetchedAt: Date() + ) + store.selectedScope = .combined + #expect(store.menubarBadgeDeviceShortfall == nil) + + // Local scope never reports a shortfall. + store.selectedScope = .local + #expect(store.menubarBadgeDeviceShortfall == nil) + } + + @Test("menubar badge falls back to local when no combined payload is cached") + func menubarBadgeFallsBackWhenCombinedMissing() { + let store = AppStore() + store.suppressRefreshesForTesting() + let period = store.menubarPeriod + store.setCachedPayloadForTesting( + menubarPayload(cost: 30), + scope: .local, + period: period, + provider: .all, + fetchedAt: Date() + ) + // Combined scope selected but no combined payload cached yet (cold cache + // or an unreachable peer): the badge must fall back to the local figure. + store.selectedScope = .combined + #expect(store.menubarBadgeCombined == nil) + } + @Test("switching to combined resets selected provider to all") func switchingToCombinedResetsSelectedProviderToAll() { let store = AppStore()