mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 19:05:30 +00:00
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:
parent
f8b2894b4e
commit
55fdbfd761
11 changed files with 223 additions and 16 deletions
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue