fix(app): zero background work when hidden; currency applies instantly

Energy (field report: ~3x Chrome drain):
- polls skip entirely while the window is hidden/minimized, with one
  catch-up refresh on return when data is stale; visible-but-unfocused
  keeps polling (second-monitor case)
- all looping animations pause under html.page-hidden (the sidebar
  flame flicker was a perpetual compositor drain)
- default cadence 60s; an explicit stored choice is always honored

Currency (field report: set USD, still saw EUR):
- memo-served payloads re-applied their embedded stale currency; config
  mutations now purge the renderer memo and force-refresh, and a
  switching payload can never overwrite the applied currency
- USD default verified end to end

Measured: hidden window = 0 new CLI spawns over 5 cadences (was: full
polling forever). App 340/340, build + package green.
This commit is contained in:
iamtoruk 2026-07-16 18:41:28 -07:00
parent b597449bed
commit e4bbcaaba7
12 changed files with 441 additions and 70 deletions

View file

@ -389,6 +389,12 @@ function createWindow(): BrowserWindow {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// Chromium's default (kept explicit): when the window is minimized or fully
// occluded the renderer's document.visibilityState flips to 'hidden' and a
// visibilitychange fires. usePolled and the flame animation gate on that to
// stop background CLI polls and compositor wakeups while hidden. A merely
// unfocused-but-visible window stays 'visible' and keeps polling.
backgroundThrottling: true,
},
})

View file

@ -3,7 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { App, overviewMemoKey } from './App'
import { __resetPolledMemo, hasPolledMemo } from './hooks/usePolled'
import { __resetPolledMemo, hasPolledMemo, primePolledMemo } from './hooks/usePolled'
import { setActiveCurrency } from './lib/format'
import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types'
const stored = new Map<string, string>()
@ -30,6 +31,10 @@ const mocks = vi.hoisted(() => ({
getDevicesScan: vi.fn(),
getIdentity: vi.fn(),
cliStatus: vi.fn(),
getPriceOverrides: vi.fn(),
getAliases: vi.fn(),
setCurrency: vi.fn(),
resetCurrency: vi.fn(),
}))
vi.mock('./lib/ipc', async orig => {
@ -41,6 +46,11 @@ function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}
function overviewPayload(): MenubarPayload {
const now = new Date()
return {
@ -106,57 +116,65 @@ function withConfigs(payload: MenubarPayload): MenubarPayload {
}
}
function installDefaultMocks() {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(overviewPayload())
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
mocks.getOptimizeReport.mockResolvedValue({
period: { label: 'Last 30 days', start: null, end: null },
summary: {
healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
potentialSavingsPercent: 0, costRateUSD: 0,
},
findings: [],
})
mocks.getModels.mockResolvedValue([])
mocks.getSessions.mockResolvedValue([])
mocks.getCompareModels.mockResolvedValue([])
mocks.getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
])
mocks.getPlans.mockResolvedValue({})
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
mocks.getIdentity.mockResolvedValue({ name: 'CodeBurn Mac', fingerprint: 'AA:BB:CC' })
mocks.getDevicesScan.mockResolvedValue({ found: [] })
mocks.getDevices.mockResolvedValue({
perDevice: [],
combined: {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
deviceCount: 1,
reachableCount: 1,
},
})
mocks.getPriceOverrides.mockResolvedValue({ overrides: [] })
mocks.getAliases.mockResolvedValue([])
mocks.setCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
}
describe('App shortcuts', () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(overviewPayload())
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
mocks.getOptimizeReport.mockResolvedValue({
period: { label: 'Last 30 days', start: null, end: null },
summary: {
healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
potentialSavingsPercent: 0, costRateUSD: 0,
},
findings: [],
})
mocks.getModels.mockResolvedValue([])
mocks.getSessions.mockResolvedValue([])
mocks.getCompareModels.mockResolvedValue([])
mocks.getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
])
mocks.getPlans.mockResolvedValue({})
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
mocks.getIdentity.mockResolvedValue({ name: 'CodeBurn Mac', fingerprint: 'AA:BB:CC' })
mocks.getDevicesScan.mockResolvedValue({ found: [] })
mocks.getDevices.mockResolvedValue({
perDevice: [],
combined: {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
deviceCount: 1,
reachableCount: 1,
},
})
installDefaultMocks()
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
})
@ -460,6 +478,9 @@ describe('provider prefetch storm', () => {
details: [],
})
localStorage.clear()
// Pin the cadence to 30s so the fake-timer soak math below is independent of
// the app-wide default (bumped to 60s for energy).
localStorage.setItem('codeburn.refreshInterval', '30s')
__resetPolledMemo()
})
@ -501,3 +522,105 @@ describe('provider prefetch storm', () => {
}
})
})
describe('energy: hidden-window polling', () => {
beforeEach(() => {
installDefaultMocks()
localStorage.clear()
localStorage.setItem('codeburn.refreshInterval', '30s') // pin cadence for the soak math
__resetPolledMemo()
})
// The whole app's data flows through usePolled, which is the ONLY driver of CLI
// spawns (each codeburn.getX → IPC → spawnCli). This measures that a hidden
// window issues ZERO new interval spawns, and that visibility resumes them —
// the unit-level stand-in for the packaged visible-vs-hidden sample.
it('issues zero new interval spawns while hidden and resumes when visible', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
render(<App />)
// Boot + three visible 30s cadences: the overview section's yield poll (a
// pure usePolled interval, never prefetched) fires each cadence.
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3) })
const visibleYield = mocks.getYield.mock.calls.length
expect(visibleYield).toBeGreaterThan(1) // polling while visible
// Hidden for five cadences: not a single new spawn on any poller.
setVisibility('hidden')
const atHideYield = mocks.getYield.mock.calls.length
const atHideOverview = mocks.getOverview.mock.calls.length
await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 5) })
expect(mocks.getYield.mock.calls.length).toBe(atHideYield)
expect(mocks.getOverview.mock.calls.length).toBe(atHideOverview)
// Back to visible: the stale-by-a-cadence polls catch up immediately.
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(mocks.getYield.mock.calls.length).toBeGreaterThan(atHideYield)
expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(atHideOverview)
} finally {
setVisibility('visible')
vi.useRealTimers()
localStorage.clear()
}
})
})
describe('currency correctness', () => {
const USD = { code: 'USD', symbol: '$', rate: 1 }
const EUR = { code: 'EUR', symbol: '€', rate: 0.9 }
beforeEach(() => {
installDefaultMocks()
// Reset the module-level display currency so a prior test never bleeds in.
setActiveCurrency(USD)
localStorage.clear()
__resetPolledMemo()
})
it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {
const usd = { ...overviewPayload(), currency: USD }
// A stale EUR payload cached for `claude`, as if warmed before a currency
// change. The claude fetch is left pending so `switching` stays true and the
// memo-served EUR payload is what's on screen during the assertion window.
const eur = { ...overviewPayload(), currency: EUR }
mocks.getOverview.mockImplementation((_period: string, provider: string) =>
provider === 'claude' ? new Promise<MenubarPayload>(() => {}) : Promise.resolve(usd))
primePolledMemo(overviewMemoKey('claude', '30days', null, null), eur)
render(<App />)
// Boot on the USD ('all') view.
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/€/)).not.toBeInTheDocument()
// Switch to claude: usePolled paints the memoized EUR payload (switching) while
// its fresh fetch hangs. The currency effect must NOT apply that stale EUR.
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
expect(screen.queryByText(/€/)).not.toBeInTheDocument()
})
it('clears the instant-switch memo and force-refreshes when currency is reset', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
// A warmed entry (as the prefetcher would leave one) that must be purged so a
// later switch can't repaint a payload computed under the old currency.
primePolledMemo('sentinel-warmed-key', { stale: true })
expect(hasPolledMemo('sentinel-warmed-key')).toBe(true)
fireEvent.keyDown(document, { key: ',', metaKey: true })
const overviewCalls = mocks.getOverview.mock.calls.length
fireEvent.click(await screen.findByRole('button', { name: 'Reset to USD' }))
await waitFor(() => expect(mocks.resetCurrency).toHaveBeenCalled())
// Memo purged and the active view force-refreshed so the new currency lands fast.
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
expect(hasPolledMemo('sentinel-warmed-key')).toBe(false)
})
})

View file

@ -10,7 +10,7 @@ import { Splash } from './components/Splash'
import { ToastHost } from './components/ToastHost'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
import { clearPolledMemo, hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
@ -256,9 +256,15 @@ function AppMain() {
useEffect(() => {
const currency = overview.data?.currency
if (!currency) return
// While `switching`, `data` is a memo-served payload from a previous key that
// may carry a STALE currency (cached before a Settings currency change): never
// let it regress the display. Apply currency only from a freshly-resolved
// fetch; the fresh result (switching false) re-runs this and applies the real
// one. clearPolledMemo() on a currency mutation also purges those stale entries.
if (overview.switching) return
setActiveCurrency(currency)
setCurrencyTick(tick => tick + 1)
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol])
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol, overview.switching])
// Size the instant-switch memo to hold every prefetched provider overview plus
// the base keys, so warmed entries survive between polls instead of evicting.
@ -317,6 +323,17 @@ function AppMain() {
setRefreshToken(token => token + 1)
}, [refreshOverview])
// A Settings action changed config that alters computed costs/currency
// (currency/alias/plan/price-override). The electron read-cache is flushed CLI-
// side, but the renderer's instant-switch memo still holds payloads computed
// under the OLD config — a later provider switch would repaint the stale currency.
// Purge the memo, then force-refresh the active view so the new values land in a
// couple seconds (quick like the menubar) instead of at the next poll.
const onConfigMutated = useCallback(() => {
clearPolledMemo()
refreshVisible()
}, [refreshVisible])
const navigate = useCallback((next: Section, pane: SettingsPane = 'general') => {
setSettingsPane(pane)
setSection(next)
@ -395,7 +412,7 @@ 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} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
) : (
<>
<TopBar

View file

@ -1,16 +1,27 @@
// @vitest-environment jsdom
import { createElement, type ReactNode } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { RefreshCadenceContext, type RefreshCadence } from '../lib/refreshCadence'
import { usePolled } from './usePolled'
import { clearPolledMemo, hasPolledMemo, primePolledMemo, usePolled } from './usePolled'
function cadenceWrapper(intervalMs: number | null) {
const value: RefreshCadence = { value: 'x', intervalMs, setValue: () => {} }
return ({ children }: { children: ReactNode }) => createElement(RefreshCadenceContext.Provider, { value }, children)
}
/** Override document visibility for the hidden-polling tests. jsdom defaults to
* 'visible'; the own-property override is torn down in afterEach. */
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}
afterEach(() => {
delete (document as unknown as { visibilityState?: unknown }).visibilityState
delete (document as unknown as { hidden?: unknown }).hidden
})
describe('usePolled', () => {
it('discards a stale in-flight fetch that resolves after a newer one (epoch guard)', async () => {
// A fetcher we resolve by hand, one deferred per call, so we can force a
@ -158,6 +169,62 @@ describe('usePolled', () => {
}
})
it('skips interval polls while the document is hidden, then catches up on return to visible', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
const fetcher = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(fetcher, [], { intervalMs: 1000 }))
expect(fetcher).toHaveBeenCalledTimes(1) // mount fetch
// Let the mount fetch resolve so lastSuccess is recorded.
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
// Hidden (minimized/occluded): five interval ticks must spawn NOTHING.
setVisibility('hidden')
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(fetcher).toHaveBeenCalledTimes(1)
// Back to visible with the last success now older than a full cadence:
// exactly one immediate catch-up fetch, not a wait for the next tick.
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(fetcher).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('does not fire a catch-up when returning to visible within one cadence', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
const fetcher = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(fetcher, [], { intervalMs: 10_000 }))
expect(fetcher).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
// Hidden only briefly (well under the 10s cadence), then visible again:
// the last success is still fresh, so no catch-up fetch.
setVisibility('hidden')
await act(async () => { await vi.advanceTimersByTimeAsync(500) })
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(fetcher).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('clearPolledMemo empties the instant-switch memo', () => {
primePolledMemo('k1', 'v1')
primePolledMemo('k2', 'v2')
expect(hasPolledMemo('k1')).toBe(true)
expect(hasPolledMemo('k2')).toBe(true)
clearPolledMemo()
expect(hasPolledMemo('k1')).toBe(false)
expect(hasPolledMemo('k2')).toBe(false)
})
it('defaults the interval to the RefreshCadence context, and Manual disables the timer', async () => {
vi.useFakeTimers()
try {

View file

@ -72,6 +72,14 @@ export function __resetPolledMemo(): void {
memoMax = DEFAULT_MEMO_MAX
}
/** Empty the instant-switch memo. Called when a Settings action mutates config
* that changes computed costs or currency (currency/alias/plan/price-override):
* a later provider/period switch must never paint a payload cached under the OLD
* config, which is what stuck the display on the previous currency. */
export function clearPolledMemo(): void {
memoStore.clear()
}
/** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the
* overview result for every detected provider so a picker switch to one paints
* from memory in the same frame instead of waiting on a fresh CLI spawn. Keyed
@ -122,6 +130,9 @@ export function usePolled<T>(
// still current. This is what keeps a slow fetch from an older deps/period
// from clobbering a newer one that already resolved.
const epochRef = useRef(0)
// Wall-clock of the last successful fetch, mirrored out of state so the
// visibilitychange catch-up can read it without re-subscribing on every poll.
const lastSuccessRef = useRef<number | null>(null)
const load = useCallback(() => {
if (!enabled) return
@ -148,7 +159,9 @@ export function usePolled<T>(
if (epochRef.current !== epoch) return
setData(result)
setError(null)
setLastSuccessAt(Date.now())
const at = Date.now()
setLastSuccessAt(at)
lastSuccessRef.current = at
if (memoKey) memoSet(memoKey, result)
})
.catch(err => {
@ -168,10 +181,30 @@ export function usePolled<T>(
useEffect(() => {
load()
// Skip interval ticks while the window is hidden/minimized/occluded: a
// backgrounded dashboard polling the CLI is pure energy waste. A visible-
// but-unfocused window (e.g. a second monitor) reports 'visible' and keeps
// polling. Read visibility live per tick so pausing holds even if a
// visibilitychange event was missed.
const tick = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
load()
}
// Manual cadence (intervalMs == null) skips the interval entirely.
const id = intervalMs != null ? setInterval(() => load(), intervalMs) : null
const id = intervalMs != null ? setInterval(tick, intervalMs) : null
// On return to visible, if the last success is older than a full cadence,
// refresh once immediately instead of waiting up to intervalMs for the next
// tick. Manual cadence has no catch-up (the user drives refresh).
const onVisible = () => {
if (intervalMs == null) return
if (typeof document === 'undefined' || document.visibilityState !== 'visible') return
const last = lastSuccessRef.current
if (last == null || Date.now() - last >= intervalMs) load()
}
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
return () => {
if (id != null) clearInterval(id)
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
// Retire this generation so an in-flight fetch can't resolve into state
// after unmount or a deps change.
epochRef.current++

View file

@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { afterEach, describe, it, expect } from 'vitest'
import { installPageHiddenClass } from './pageVisibility'
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
}
afterEach(() => {
document.documentElement.classList.remove('page-hidden')
delete (document as unknown as { visibilityState?: unknown }).visibilityState
})
describe('installPageHiddenClass', () => {
it('toggles the page-hidden class on <html> as visibility changes, and stops after dispose', () => {
setVisibility('visible')
const dispose = installPageHiddenClass()
expect(document.documentElement.classList.contains('page-hidden')).toBe(false)
setVisibility('hidden')
document.dispatchEvent(new Event('visibilitychange'))
expect(document.documentElement.classList.contains('page-hidden')).toBe(true)
setVisibility('visible')
document.dispatchEvent(new Event('visibilitychange'))
expect(document.documentElement.classList.contains('page-hidden')).toBe(false)
dispose()
setVisibility('hidden')
document.dispatchEvent(new Event('visibilitychange'))
// Disposed: the class is no longer maintained.
expect(document.documentElement.classList.contains('page-hidden')).toBe(false)
})
it('reflects an already-hidden window immediately on install', () => {
setVisibility('hidden')
const dispose = installPageHiddenClass()
expect(document.documentElement.classList.contains('page-hidden')).toBe(true)
dispose()
})
})

View file

@ -0,0 +1,15 @@
/**
* Reflect document visibility onto <html> as the `page-hidden` class so CSS can
* pause looping animations (the sidebar flame flicker, shimmers) while the window
* is minimized/occluded see plain.css. Applies the current state immediately and
* returns a disposer. Safe when `document` is absent (SSR/tests without jsdom).
*/
export function installPageHiddenClass(): () => void {
if (typeof document === 'undefined') return () => {}
const sync = () => {
document.documentElement.classList.toggle('page-hidden', document.visibilityState === 'hidden')
}
sync()
document.addEventListener('visibilitychange', sync)
return () => document.removeEventListener('visibilitychange', sync)
}

View file

@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { DEFAULT_REFRESH_VALUE, readRefreshValue, refreshValueToMs } from './refreshCadence'
const STORAGE_KEY = 'codeburn.refreshInterval'
// The project's jsdom does not expose a working localStorage (see App.test.tsx),
// so back it with a Map for these persistence tests.
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(),
})
beforeEach(() => { stored.clear() })
describe('refresh cadence default migration', () => {
it('defaults to 60s when a cadence was never chosen (silent migration off 30s)', () => {
expect(DEFAULT_REFRESH_VALUE).toBe('1m')
expect(readRefreshValue()).toBe('1m')
expect(refreshValueToMs(readRefreshValue())).toBe(60_000)
})
it('honors an explicit stored 30s choice over the new default', () => {
globalThis.localStorage.setItem(STORAGE_KEY, '30s')
expect(readRefreshValue()).toBe('30s')
expect(refreshValueToMs('30s')).toBe(30_000)
})
it('honors any other explicit stored choice', () => {
globalThis.localStorage.setItem(STORAGE_KEY, '5m')
expect(readRefreshValue()).toBe('5m')
expect(refreshValueToMs('5m')).toBe(300_000)
})
it('falls back to the default for an unrecognized stored value', () => {
globalThis.localStorage.setItem(STORAGE_KEY, 'bogus')
expect(readRefreshValue()).toBe(DEFAULT_REFRESH_VALUE)
})
it('still offers 30s as a selectable cadence', () => {
expect(refreshValueToMs('30s')).toBe(30_000)
})
})

View file

@ -13,8 +13,13 @@ export const REFRESH_OPTIONS: ReadonlyArray<{ value: string; label: string; ms:
{ value: '10m', label: '10 minutes', ms: 600_000 },
]
export const DEFAULT_REFRESH_VALUE = '30s'
const DEFAULT_MS = 30_000
// 60s is the default cadence: it halves idle CLI spawns versus the old 30s while
// staying fresh enough for a usage dashboard. 30s is still offered for anyone who
// wants it. Only an explicit choice is persisted (persistRefreshValue runs solely
// from Settings), so bumping this default silently migrates users who never chose,
// while any stored value below keeps overriding it.
export const DEFAULT_REFRESH_VALUE = '1m'
const DEFAULT_MS = 60_000
const STORAGE_KEY = 'codeburn.refreshInterval'
export function refreshValueToMs(value: string): number | null {

View file

@ -2,12 +2,16 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { installPageHiddenClass } from './lib/pageVisibility'
import './styles/indigo.css'
import './styles/plain.css'
const root = document.getElementById('root')
if (!root) throw new Error('#root not found')
// Pause looping CSS animations while the window is hidden/minimized (energy).
installPageHiddenClass()
// Tag the platform so CSS can adapt native chrome (macOS hiddenInset insets +
// drag regions); harmless when the bridge is absent (tests/jsdom).
document.documentElement.dataset.platform =

View file

@ -95,7 +95,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
)
}
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 }) {
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 }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
return (
@ -111,11 +111,11 @@ 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} />}
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} />}
{pane === 'plans' && <PlansPane period={period} refreshToken={refreshToken} onNavigate={onNavigate} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'plans' && <PlansPane period={period} refreshToken={refreshToken} onNavigate={onNavigate} onConfigMutated={onConfigMutated} />}
{pane === 'devices' && <DevicesPane period={period} refreshToken={refreshToken} />}
{pane === 'export' && <ExportPane period={period} refreshToken={refreshToken} />}
{pane === 'privacy' && <PrivacyPane />}
@ -126,7 +126,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
)
}
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null }) {
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
@ -161,7 +161,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
}
const finishCurrency = (result: ActionResult) => {
showToast(result.ok ? 'Updated' : result.stderr || 'Unable to update currency', result.ok ? 'ok' : 'error')
if (result.ok) setCurrencyNonce(value => value + 1)
if (result.ok) { setCurrencyNonce(value => value + 1); onConfigMutated?.() }
}
const currencies = [...CURRENCIES]
if (plans.data?.currency && !currencies.includes(plans.data.currency)) currencies.push(plans.data.currency)
@ -217,7 +217,7 @@ function ProvidersPane({ period, refreshToken }: { period: Period; refreshToken:
</section>
}
function AliasesPane({ refreshToken }: { refreshToken: number }) {
function AliasesPane({ refreshToken, onConfigMutated }: { refreshToken: number; onConfigMutated?: () => void }) {
const [actionNonce, setActionNonce] = useState(0)
const aliases = usePolled<AliasRow[]>(() => codeburn.getAliases(), [refreshToken, actionNonce])
const [from, setFrom] = useState('')
@ -228,6 +228,7 @@ function AliasesPane({ refreshToken }: { refreshToken: number }) {
setError('')
if (added) { setFrom(''); setTo('') }
setActionNonce(value => value + 1)
onConfigMutated?.()
}
return <section className="set-p on">
<div><h3 className="set-h">Model aliases</h3><p className="set-sub">Map an unrecognized model name to a priced model so its cost shows up.</p></div>
@ -256,7 +257,7 @@ function parseRate(raw: string): number | undefined | 'invalid' {
return value
}
function PricingPane({ refreshToken }: { refreshToken: number }) {
function PricingPane({ refreshToken, onConfigMutated }: { refreshToken: number; onConfigMutated?: () => void }) {
const [actionNonce, setActionNonce] = useState(0)
const overrides = usePolled<PriceOverrideList>(() => codeburn.getPriceOverrides(), [refreshToken, actionNonce])
const [model, setModel] = useState('')
@ -271,6 +272,7 @@ function PricingPane({ refreshToken }: { refreshToken: number }) {
setError('')
if (added) { setModel(''); setInput(''); setOutput(''); setCacheRead(''); setCacheCreation('') }
setActionNonce(value => value + 1)
onConfigMutated?.()
}
const add = () => {
@ -323,7 +325,7 @@ function DetectedRow({ quota, onReconnect }: { quota: QuotaProvider; onReconnect
</div>
}
function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void }) {
function PlansPane({ period, refreshToken, onNavigate, onConfigMutated }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void; onConfigMutated?: () => void }) {
const [nonce, setNonce] = useState(0)
// Steady poll serves cached quota (force=false); the Connect affordance's
// Refresh forces a keychain-allowed fetch via the same path as Plans.tsx.
@ -341,7 +343,7 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre
const finish = (result: ActionResult) => {
showToast(result.ok ? (result.stdout.trim() || 'Plan updated') : (result.stderr || 'Plan action failed'), result.ok ? 'ok' : 'error')
if (result.ok) setNonce(value => value + 1)
if (result.ok) { setNonce(value => value + 1); onConfigMutated?.() }
}
const remove = (plan: JsonPlanSummary) => {
void codeburn.resetPlan(plan.provider).then(finish)

View file

@ -842,6 +842,16 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.panel:hover, .ov-card:hover, .btnp:active, .btn:active, .ov-coach-cta:active { transform: none; }
}
/* Energy: pause every looping animation while the window is hidden/minimized so
the compositor stops waking. `page-hidden` is toggled on <html> from document
visibility (renderer/lib/pageVisibility.ts). !important beats the `animation`
shorthands (e.g. .fm-flicker) that would otherwise reset play-state to running. */
html.page-hidden *,
html.page-hidden *::before,
html.page-hidden *::after {
animation-play-state: paused !important;
}
/* Flame mark + launch splash (Batch G4). Every animated class below is
added only through motionClass()/motionEnabled(); the media query is a
belt-and-suspenders escape hatch. */