feat(app): Claude config switcher (multi-account overview scope)

- TopBar picker (shown only when >1 Claude config dir exists) threads
  --claude-config-source through getOverview; synthetic 'All Claude
  configs' entry returns to the aggregate default
- config id validation accepts the real <kind>:<16hex> id shape while
  still rejecting flag-shaped input (leading char anchored)
- both incompatibility directions handled: selecting a config resets a
  non-Claude provider filter, and picking a non-Claude provider clears
  the config scope, so the CLI rejection is unreachable
- choice persisted (localStorage); active config shown in the scope
  line and read-only in Settings/General

Verified live against 2 real config dirs. 205/205, build green.
This commit is contained in:
iamtoruk 2026-07-16 03:06:05 -07:00
parent f8b2894b4e
commit 55fdbfd761
11 changed files with 223 additions and 16 deletions

View file

@ -74,6 +74,8 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ channel: 'codeburn: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'] },
@ -185,6 +187,8 @@ describe('createBridgeHandlers (IPC input validation)', () => {
{ name: 'device name that looks like a flag', channel: 'codeburn:removeDevice', args: ['-rf'] },
{ name: 'relative export path', channel: 'codeburn:exportData', args: ['json', 'all', 'relative/out'] },
{ name: 'compare model that looks like a flag', channel: 'codeburn:getCompare', args: ['month', 'all', '-a', 'model-b'] },
{ 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'] },
]
it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {

View file

@ -18,6 +18,10 @@ function rangeArgs(range: DateRange | undefined): string[] {
return range ? ['--from', range.from, '--to', range.to] : []
}
function configSourceArgs(source: string | null): string[] {
return source ? ['--claude-config-source', source] : []
}
// Renderer-supplied strings become argv, so reject anything that could smuggle a
// flag or shell metacharacter before it reaches the CLI. Thrown from the argv
// builders, these surface through the same error envelope as any CliError.
@ -45,6 +49,14 @@ function vToken(value: string): string {
if (value.startsWith('-')) throw new CliError('bad-args', 'argument must not start with "-"')
return value
}
// Claude config source ids are `<kind>:<hex>` (src/providers/claude.ts) — the
// colon is part of the real value, so the token class allows it while anchoring
// the first char to alphanumeric so a leading "-" can never smuggle a flag.
function vConfigSource(source: string | null | undefined): string | null {
if (source == null) return null
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
@ -91,8 +103,8 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
try { return { ok: true, value: await deps.getQuota({ force: Boolean(force) }) } }
catch (error) { return { ok: false, error: { kind: 'nonzero', message: sanitizeError(error) } } }
},
'codeburn:getOverview': run((period: string, provider: string, range?: DateRange) => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
'codeburn:getOverview': run((period: string, provider: string, range?: DateRange, configSource?: string | null) => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]),
'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getActReport': run(() => ['act', 'report', '--json']),

View file

@ -19,7 +19,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) => invoke('codeburn:getOverview', period, provider, range),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null) => invoke('codeburn:getOverview', period, provider, range, configSource),
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),

View file

@ -9,11 +9,12 @@ const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
removeItem: (key: string) => stored.delete(key),
clear: () => stored.clear(),
})
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<MenubarPayload>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null) => 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(),
@ -88,6 +89,22 @@ function overviewPayload(): MenubarPayload {
}
}
const CONFIG_A = 'claude-config:aaaa000011112222'
const CONFIG_B = 'claude-desktop:bbbb000011112222'
function withConfigs(payload: MenubarPayload): MenubarPayload {
return {
...payload,
claudeConfigs: {
selectedId: null,
options: [
{ id: CONFIG_A, label: 'Default Claude', path: '/Users/x/.claude' },
{ id: CONFIG_B, label: 'Claude Desktop', path: '/Users/x/Library/Application Support/Claude' },
],
},
}
}
describe('App shortcuts', () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset()
@ -244,6 +261,77 @@ describe('App shortcuts', () => {
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
})
it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Claude config source' })).not.toBeInTheDocument()
})
it('shows the config picker, re-fetches overview with the flag, and persists the choice', async () => {
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
const trigger = await screen.findByRole('button', { name: 'Claude config source' })
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBe(CONFIG_A)
})
it('resets a non-Claude provider filter to all when a config is selected', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
// The Claude-incompatible provider filter must never reach the CLI with the flag.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
})
it('clears the config scope when a non-Claude provider is picked afterwards', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
// The incompatible combination must never reach the CLI.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('boots with the persisted config source and clears it via All Claude configs', async () => {
localStorage.setItem('codeburn.claudeConfigSource', CONFIG_A)
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(await screen.findByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'All Claude configs' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)

View file

@ -52,6 +52,18 @@ function initialPeriod(): Period {
return saved && isPeriod(saved) ? saved : '30days'
}
/** Persisted Claude config override (empty/absent = aggregate all configs). */
function initialConfigSource(): string | null {
try { return globalThis.localStorage?.getItem('codeburn.claudeConfigSource') || null } catch { return null }
}
function persistConfigSource(id: string | null): void {
try {
if (id) globalThis.localStorage?.setItem('codeburn.claudeConfigSource', id)
else globalThis.localStorage?.removeItem('codeburn.claudeConfigSource')
} catch { /* storage can be unavailable */ }
}
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
@ -78,15 +90,20 @@ export function App() {
const [provider, setProvider] = useState<string>('all')
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
// stays flag-free; only add --claude-config-source once a config is picked.
const overview = usePolled<MenubarPayload>(
() => customRange
() => claudeConfigSource
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
: customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to],
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
)
const refreshOverview = overview.refresh
@ -162,12 +179,36 @@ export function App() {
}
}
// A Claude config scopes Claude usage only, so a non-Claude provider filter
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
// filter is already compatible and is left alone).
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
setClaudeConfigSource(next)
persistConfigSource(next)
}
// Symmetric direction: picking a non-Claude provider while a config is
// scoped would hit the same CLI rejection, so drop the config scope.
const onProviderSelect = (value: string) => {
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
}
setProvider(value)
}
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })),
]
const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider)
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}`
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
return (
<Window>
@ -177,7 +218,7 @@ export function App() {
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} />
) : (
<>
<TopBar
@ -190,13 +231,16 @@ export function App() {
provider={provider}
providerLabel={providerLabel}
providerOptions={providerOptions}
onProviderSelect={setProvider}
onProviderSelect={onProviderSelect}
claudeConfigs={claudeConfigs}
configSource={claudeConfigSource}
onConfigSelect={onConfigSelect}
/>
<div className="body">
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={setProvider} />
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
) : section === 'optimize' ? (

View file

@ -10,6 +10,7 @@ export function Dropdown({
id,
width,
renderIcon,
footer,
}: {
value: string
options: DropdownOption[]
@ -19,6 +20,8 @@ export function Dropdown({
width?: React.CSSProperties['width']
/** Optional leading glyph (e.g. a provider logo) shown in the trigger and each option. */
renderIcon?: (value: string) => ReactNode
/** Optional non-interactive note pinned below the options in the open menu. */
footer?: ReactNode
}) {
const [open, setOpen] = useState(false)
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value))
@ -126,6 +129,7 @@ export function Dropdown({
{option.label}
</button>
))}
{footer && <div className="dropdown-foot">{footer}</div>}
</div>
)}
</div>

View file

@ -1,10 +1,14 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { DateRange } from '../lib/types'
import type { ClaudeConfigSelector, DateRange } from '../lib/types'
import { Dropdown } from './Dropdown'
import { ProviderPop, type ProviderOption } from './ProviderPop'
import { RangeCalendar } from './RangeCalendar'
import { SegTabs, type SegOption } from './SegTabs'
/** Sentinel option value: no --claude-config-source flag (aggregate all configs). */
const ALL_CONFIGS = ''
/** The real CLI period vocabulary (`codeburn ... --period`). */
export const PERIOD_OPTIONS: SegOption[] = [
{ value: 'today', label: 'Today' },
@ -26,6 +30,9 @@ export function TopBar({
providerLabel,
providerOptions,
onProviderSelect,
claudeConfigs,
configSource,
onConfigSelect,
}: {
title: ReactNode
scope?: ReactNode
@ -37,6 +44,9 @@ export function TopBar({
providerLabel: string
providerOptions: ProviderOption[]
onProviderSelect: (value: string) => void
claudeConfigs?: ClaudeConfigSelector
configSource: string | null
onConfigSelect: (id: string) => void
}) {
return (
<div className="bar">
@ -46,10 +56,31 @@ export function TopBar({
<SegTabs options={PERIOD_OPTIONS} value={customRange ? '' : period} onChange={onPeriodChange} />
<CalendarPop value={customRange} onSelect={onRangeSelect} />
<ProviderPop value={provider} label={providerLabel} options={providerOptions} onSelect={onProviderSelect} />
{claudeConfigs && <ConfigPicker configs={claudeConfigs} value={configSource} onSelect={onConfigSelect} />}
</div>
)
}
/** Claude config source switcher. Only getOverview honors the selection, so the
* footer names the limit; the active label is also echoed in the scope line. */
function ConfigPicker({ configs, value, onSelect }: { configs: ClaudeConfigSelector; value: string | null; onSelect: (id: string) => void }) {
const options = [
{ value: ALL_CONFIGS, label: 'All Claude configs' },
...configs.options.map(option => ({ value: option.id, label: option.label })),
]
return (
<Dropdown
id="claude-config-select"
ariaLabel="Claude config source"
value={value ?? ALL_CONFIGS}
options={options}
onChange={onSelect}
width={168}
footer="Applies to the overview data. Manage config folders with the codeburn CLI."
/>
)
}
function formatRange(range: DateRange): string {
const from = new Date(`${range.from}T12:00:00`)
const to = new Date(`${range.to}T12:00:00`)

View file

@ -490,7 +490,7 @@ export type CompareJsonReport = {
export interface CodeburnBridge {
getQuota(force?: boolean): Promise<QuotaProvider[]>
getOverview(period: Period, provider: string, range?: DateRange): Promise<MenubarPayload>
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string

View file

@ -101,6 +101,19 @@ describe('Settings', () => {
expect(document.documentElement).not.toHaveAttribute('data-theme')
})
it('shows the active Claude config as a read-only line in General when multiple configs exist', async () => {
render(<Settings period="month" claudeConfigs={{ selectedId: null, options: [{ id: 'claude-config:aaaa', label: 'Default Claude', path: '/x' }, { id: 'claude-desktop:bbbb', label: 'Claude Desktop', path: '/y' }] }} claudeConfigSource="claude-desktop:bbbb" />)
expect(await screen.findByText('Claude config')).toBeInTheDocument()
expect(screen.getByText('Claude Desktop')).toBeInTheDocument()
expect(screen.getByText('Applies to the overview data. Manage config folders with the codeburn CLI.')).toBeInTheDocument()
})
it('omits the Claude config line when no multi-config selector is present', async () => {
render(<Settings period="month" />)
expect(await screen.findByRole('heading', { name: 'General' })).toBeInTheDocument()
expect(screen.queryByText('Claude config')).not.toBeInTheDocument()
})
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render(<Settings period="week" />)

View file

@ -9,7 +9,7 @@ import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { ActionResult, AliasRow, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
@ -83,7 +83,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
)
}
export function Settings({ period, refreshToken = 0, onNavigate, initialPane }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane }) {
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
return (
@ -98,7 +98,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane }:
))}
</nav>
<main className="set-pane">
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} />}
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} />}
{pane === 'plans' && <PlansPane period={period} refreshToken={refreshToken} onNavigate={onNavigate} />}
@ -112,7 +112,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane }:
)
}
function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: number }) {
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
@ -137,6 +137,10 @@ function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: n
}
const currencies = [...CURRENCIES]
if (plans.data?.currency && !currencies.includes(plans.data.currency)) currencies.push(plans.data.currency)
const hasConfigs = Boolean(claudeConfigs && claudeConfigs.options.length > 0)
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? claudeConfigSource
: 'All Claude configs'
return (
<section className="set-p on">
@ -148,6 +152,12 @@ function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: n
{(['system', 'light', 'dark'] as Theme[]).map(value => <button key={value} className={theme === value ? 'on' : undefined} aria-pressed={theme === value} onClick={() => chooseTheme(value)}>{value[0]!.toUpperCase() + value.slice(1)}</button>)}
</span></span></div>
</div>
{hasConfigs && (
<div className="about-sec">
<div className="about-sec-h">Claude config</div>
<div className="about-row"><span className="tx">Active config<small>Applies to the overview data. Manage config folders with the codeburn CLI.</small></span><span className="r"><span className="set-cap">{activeConfigLabel}</span></span></div>
</div>
)}
<div className="about-sec set-last-sec">
<div className="about-sec-h">Display</div>
<div className="about-row"><label className="tx" htmlFor="settings-currency">Currency</label><span className="r">

View file

@ -264,6 +264,7 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.dropdown-menu { max-height: 240px; overflow-y: auto; }
.dropdown-menu .pop-item { width: 100%; border: 0; background: transparent; font: inherit; text-align: left; }
.dropdown-menu .pop-item:hover, .dropdown-menu .pop-item:focus-visible { background: var(--hover); }
.dropdown-foot { max-width: 220px; margin-top: 4px; padding: 7px 11px 2px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10px; line-height: 1.35; white-space: normal; }
.calendar-trigger {
display: inline-flex; align-items: center; gap: 6px; height: 25px; padding: 3px 7px;
border: 1px solid var(--hair2); border-radius: 7px; background: var(--panel);