mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-20 14:04:25 +00:00
Merge pull request #919 from ozymandiashh/fix/918-windows-shortcuts
fix(desktop): make keyboard shortcuts work on Windows and Linux
This commit is contained in:
commit
5a18e44ba2
6 changed files with 174 additions and 37 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App'
|
||||
import { sanitizeProps } from '../electron/telemetry'
|
||||
|
|
@ -52,6 +52,16 @@ function setVisibility(state: 'visible' | 'hidden') {
|
|||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
|
||||
}
|
||||
|
||||
// The shortcut code (lib/platform.ts) reads `window.codeburn.platform` at call
|
||||
// time; stub it per test and always restore so no test leaks platform state.
|
||||
function setPlatform(platform: string): void {
|
||||
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
|
||||
}
|
||||
|
||||
function clearPlatform(): void {
|
||||
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
|
||||
}
|
||||
|
||||
function overviewPayload(): MenubarPayload {
|
||||
const now = new Date()
|
||||
return {
|
||||
|
|
@ -181,6 +191,11 @@ describe('App shortcuts', () => {
|
|||
// the app-wide default ('today'); tests that exercise the default set it.
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
setPlatform('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('applies the persisted theme on app boot before Settings mounts', async () => {
|
||||
|
|
@ -211,46 +226,59 @@ describe('App shortcuts', () => {
|
|||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
|
||||
it.each([
|
||||
['darwin', { metaKey: true }, '⌘'],
|
||||
['win32', { ctrlKey: true }, 'Ctrl+'],
|
||||
] as const)('keeps %s navigation, settings, and refresh shortcuts active without stale hints', async (platform, chord, mod) => {
|
||||
setPlatform(platform)
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘1-8')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('⌘R')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${mod}1-8`)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(`${mod},`).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(`${mod}R`)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Command')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Export view')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '2', ...chord })
|
||||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '3', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '3', ...chord })
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '4', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '4', ...chord })
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '5', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '5', ...chord })
|
||||
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '6', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '6', ...chord })
|
||||
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '7', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '7', ...chord })
|
||||
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '8', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '8', ...chord })
|
||||
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: ',', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: ',', ...chord })
|
||||
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Back')).not.toBeInTheDocument()
|
||||
|
||||
const overviewCalls = mocks.getOverview.mock.calls.length
|
||||
fireEvent.keyDown(document, { key: 'r', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: 'r', ...chord })
|
||||
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
|
||||
})
|
||||
|
||||
it('ignores Ctrl+2 on mac', async () => {
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('re-polls visible section data when period or provider changes', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
|
@ -481,6 +509,48 @@ describe('App shortcuts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('win32 shortcut chords', () => {
|
||||
beforeEach(() => {
|
||||
installDefaultMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
setPlatform('win32')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('navigates with Ctrl+2 and refreshes with Ctrl+R', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
|
||||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
|
||||
const overviewCalls = mocks.getOverview.mock.calls.length
|
||||
fireEvent.keyDown(document, { key: 'r', ctrlKey: true })
|
||||
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
|
||||
})
|
||||
|
||||
it('ignores Meta+2 on win32', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', metaKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores Ctrl+Alt+2 (the AltGr shape) on win32', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '2', ctrlKey: true, altKey: true })
|
||||
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider prefetch storm', () => {
|
||||
const PROVIDERS = [
|
||||
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
|
||||
|
|
@ -618,6 +688,11 @@ describe('currency correctness', () => {
|
|||
// independent of the app-wide default ('today').
|
||||
localStorage.setItem('codeburn.defaultPeriod', '30days')
|
||||
__resetPolledMemo()
|
||||
setPlatform('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearPlatform()
|
||||
})
|
||||
|
||||
it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { readDailyBudget } from './lib/budget'
|
|||
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
|
||||
import { motionClass } from './lib/motion'
|
||||
import { codeburn } from './lib/ipc'
|
||||
import { isModifierChord, shortcutLabel } from './lib/platform'
|
||||
import { localDateKey } from './lib/period'
|
||||
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
|
||||
import { OverviewContent } from './sections/Overview'
|
||||
|
|
@ -440,7 +441,7 @@ function AppMain() {
|
|||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
|
||||
if (!isModifierChord(event)) return
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === '1') navigate('overview')
|
||||
else if (key === '2') navigate('sessions')
|
||||
|
|
@ -577,9 +578,9 @@ function AppMain() {
|
|||
{section !== 'settings' && (
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘1-8', label: 'Navigate' },
|
||||
{ k: '⌘,', label: 'Settings' },
|
||||
{ k: '⌘R', label: 'Refresh' },
|
||||
{ k: shortcutLabel('1-8'), label: 'Navigate' },
|
||||
{ k: shortcutLabel(','), label: 'Settings' },
|
||||
{ k: shortcutLabel('R'), label: 'Refresh' },
|
||||
]}
|
||||
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
function setPlatform(platform: string): void {
|
||||
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders all nine nav items in the desktop order', () => {
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', '⌘'],
|
||||
['win32', 'Ctrl+'],
|
||||
] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => {
|
||||
setPlatform(platform)
|
||||
render(<Sidebar active="overview" onNavigate={() => {}} />)
|
||||
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, ''))
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, ''))
|
||||
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
|
||||
expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Pull requests.*⌘3/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Compare.*⌘7/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Plans.*⌘8/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: new RegExp(`Plans.*${esc(mod)}8`) })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onNavigate with the section id when a nav item is clicked', () => {
|
||||
|
|
|
|||
|
|
@ -1,37 +1,38 @@
|
|||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { shortcutLabel } from '../lib/platform'
|
||||
import { AboutModal, type SocialLink } from './AboutModal'
|
||||
import { FlameMark } from './FlameMark'
|
||||
|
||||
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
|
||||
{ id: 'overview', label: 'Overview', key: '⌘1', icon: (
|
||||
{ id: 'overview', label: 'Overview', key: '1', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1" /><rect x="14" y="3" width="7" height="5" rx="1" /><rect x="14" y="12" width="7" height="9" rx="1" /><rect x="3" y="16" width="7" height="5" rx="1" /></svg>
|
||||
) },
|
||||
{ id: 'sessions', label: 'Sessions', key: '⌘2', icon: (
|
||||
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
|
||||
) },
|
||||
{ id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: (
|
||||
{ id: 'pullRequests', label: 'Pull requests', key: '3', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
|
||||
) },
|
||||
{ id: 'spend', label: 'Spend', key: '⌘4', icon: (
|
||||
{ id: 'spend', label: 'Spend', key: '4', icon: (
|
||||
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
|
||||
) },
|
||||
{ id: 'optimize', label: 'Optimize', key: '⌘5', icon: (
|
||||
{ id: 'optimize', label: 'Optimize', key: '5', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
|
||||
) },
|
||||
{ id: 'models', label: 'Models', key: '⌘6', icon: (
|
||||
{ id: 'models', label: 'Models', key: '6', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
|
||||
) },
|
||||
{ id: 'compare', label: 'Compare', key: '⌘7', icon: (
|
||||
{ id: 'compare', label: 'Compare', key: '7', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
|
||||
) },
|
||||
{ id: 'plans', label: 'Plans', key: '⌘8', icon: (
|
||||
{ id: 'plans', label: 'Plans', key: '8', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
|
||||
) },
|
||||
{ id: 'settings', label: 'Settings', key: '⌘,', icon: (
|
||||
{ id: 'settings', label: 'Settings', key: ',', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||
) },
|
||||
]
|
||||
|
|
@ -75,7 +76,7 @@ export function Sidebar({
|
|||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
<span className="k">{item.key}</span>
|
||||
<span className="k">{shortcutLabel(item.key)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="push" />
|
||||
|
|
|
|||
46
app/renderer/lib/platform.ts
Normal file
46
app/renderer/lib/platform.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Single source of truth for platform-aware shortcut behaviour. The preload
|
||||
// exposes `window.codeburn.platform` (process.platform); when the bridge is
|
||||
// absent (unit tests, vite in a plain browser) fall back to the user agent.
|
||||
// All functions read platform state at call time, never at module load, so
|
||||
// the preload bridge may appear after this module is imported.
|
||||
|
||||
function bridgePlatform(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
return (window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform
|
||||
}
|
||||
|
||||
function userAgentPlatform(): string | undefined {
|
||||
if (typeof navigator === 'undefined') return undefined
|
||||
if (/mac/i.test(navigator.userAgent)) return 'darwin'
|
||||
const platform = navigator.platform
|
||||
if (typeof platform === 'string' && /mac/i.test(platform)) return 'darwin'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when the Electron preload reports darwin (or the UA matches a Mac). */
|
||||
export function isMacPlatform(): boolean {
|
||||
const platform = bridgePlatform()
|
||||
if (platform) return platform === 'darwin'
|
||||
return userAgentPlatform() === 'darwin'
|
||||
}
|
||||
|
||||
/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */
|
||||
export function modKeyLabel(): string {
|
||||
return isMacPlatform() ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
|
||||
/** A full shortcut label, e.g. '⌘R' on mac, 'Ctrl+R' on Windows. */
|
||||
export function shortcutLabel(key: string): string {
|
||||
return modKeyLabel() + key
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the event is the platform's modifier chord and no other modifier
|
||||
* is held. On mac: Meta (Cmd) without Ctrl. Elsewhere: Ctrl without Meta.
|
||||
* altKey stays rejected on every platform: AltGr on European layouts arrives
|
||||
* as Ctrl+Alt, and Ctrl+Alt+<key> must not hijack a typed character.
|
||||
*/
|
||||
export function isModifierChord(event: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean {
|
||||
if (event.altKey || event.shiftKey) return false
|
||||
return isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { version as appVersion } from '../../package.json'
|
|||
import { readDailyBudget } from '../lib/budget'
|
||||
import { formatConverted, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import { shortcutLabel } from '../lib/platform'
|
||||
import { motionClass } from '../lib/motion'
|
||||
import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
|
@ -123,7 +124,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
|
|||
{pane === 'privacy' && <PrivacyPane />}
|
||||
</main>
|
||||
</div>
|
||||
<Hint items={[{ k: '⌘1-7', label: 'Navigate' }, { k: '⌘R', label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
|
||||
<Hint items={[{ k: shortcutLabel('1-8'), label: 'Navigate' }, { k: shortcutLabel('R'), label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -203,7 +204,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
|
|||
</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-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on {shortcutLabel('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>}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue