mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 22:01:25 +00:00
Merge pull request #866 from marcreynolds/feat/desktop-combined-scope
fix(dashboard): aggregate paired-device usage in the desktop Dashboard and menubar badge
This commit is contained in:
commit
bb506e0a3f
14 changed files with 324 additions and 50 deletions
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|||
// 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),
|
||||
|
|
|
|||
|
|
@ -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<MenubarPayload>>(),
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
|
||||
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
|
||||
getModels: vi.fn(),
|
||||
|
|
@ -273,6 +273,25 @@ describe('App shortcuts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
|
||||
render(<App />)
|
||||
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(<App />)
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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<Array<{ id: string; label: string }>>([])
|
||||
const [customRange, setCustomRange] = useState<DateRange | null>(null)
|
||||
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
|
||||
const [scope, setScopeState] = useState<Scope>(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<MenubarPayload>(
|
||||
() => 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<string | null>(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<Set<string>>(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 (
|
||||
<Window>
|
||||
|
|
@ -494,12 +534,12 @@ function AppMain() {
|
|||
{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} />
|
||||
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
|
||||
) : (
|
||||
<>
|
||||
<TopBar
|
||||
title={SECTION_TITLES[section]}
|
||||
scope={scope}
|
||||
scope={scopeCaption}
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
customRange={customRange}
|
||||
|
|
@ -514,7 +554,7 @@ function AppMain() {
|
|||
/>
|
||||
<div className={motionClass('body', 'section-fade')}>
|
||||
{section === 'overview' ? (
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
|
||||
) : section === 'sessions' ? (
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
|
||||
) : section === 'pullRequests' ? (
|
||||
|
|
|
|||
|
|
@ -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<QuotaProvider[]>
|
||||
// `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<MenubarPayload>
|
||||
// `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<MenubarPayload>
|
||||
getPlans(period: Period): Promise<StatusJson>
|
||||
getActReport(): Promise<ActReportJson>
|
||||
readonly platform: string
|
||||
|
|
|
|||
|
|
@ -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(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="combined" />)
|
||||
|
||||
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(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="local" />)
|
||||
|
||||
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<MenubarPayload> = {
|
||||
|
|
|
|||
|
|
@ -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 <OverviewContent period={period} provider={provider} overview={overview} />
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="ov-combined-devices">
|
||||
<div className="ov-combined-head">{usage.combined.reachableCount} of {usage.combined.deviceCount} devices</div>
|
||||
{usage.perDevice.map(device => (
|
||||
<div className={device.error ? 'ov-combined-row err' : 'ov-combined-row'} key={device.id}>
|
||||
<span className="ov-combined-name">{device.local ? `${device.name} · this device` : device.name}</span>
|
||||
<span className="ov-combined-val">{device.error ?? formatUsd(device.cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<MenubarPayload>
|
||||
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 && <StaleBanner error={error} />}
|
||||
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
|
||||
<div className="ov-hero-main">
|
||||
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
|
||||
<CountUp value={data.current.cost} animateKey={animateKey} />
|
||||
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
|
||||
{saved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
|
||||
)}
|
||||
{localSaved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
|
||||
)}
|
||||
<div className="ov-hero-top"><span className="ov-label">{combined ? `Combined · ${data.current.label}` : data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
|
||||
<CountUp value={heroCost} animateKey={animateKey} />
|
||||
<div className="ov-hero-sub">{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions</div>
|
||||
{combined
|
||||
? <CombinedDevices usage={combined} />
|
||||
: (
|
||||
<>
|
||||
{saved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
|
||||
)}
|
||||
{localSaved > 0 && (
|
||||
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ActivityHeatmap daily={data.history.daily} bare />
|
||||
<EfficiencyScorecard current={data.current} bare />
|
||||
|
|
|
|||
|
|
@ -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(<Settings period="month" scope="local" onScopeChange={onScopeChange} />)
|
||||
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(<Settings period="week" />)
|
||||
|
|
|
|||
|
|
@ -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<Pane>(initialPane ?? 'general')
|
||||
|
||||
return (
|
||||
|
|
@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
|
|||
))}
|
||||
</nav>
|
||||
<main className="set-pane">
|
||||
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
|
||||
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />}
|
||||
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
|
||||
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
|
||||
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
|
||||
|
|
@ -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<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
|
|
@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
|
|||
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
|
||||
</span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on ⌘R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
|
||||
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -271,6 +271,38 @@ 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
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -202,6 +202,56 @@ 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("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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue