diff --git a/app/electron/main.ts b/app/electron/main.ts index 3748a65..7f87013 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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, }, }) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index db04a93..01b8cca 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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() @@ -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() + // 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(() => {}) : Promise.resolve(usd)) + primePolledMemo(overviewMemoKey('claude', '30days', null, null), eur) + + render() + // 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() + 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) + }) +}) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 5ab2f22..94b1feb 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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' ? ( ) : section === 'settings' ? ( - + ) : ( <> {} } 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 { diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index e992ab7..6067e00 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -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( // 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(null) const load = useCallback(() => { if (!enabled) return @@ -148,7 +159,9 @@ export function usePolled( 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( 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++ diff --git a/app/renderer/lib/pageVisibility.test.ts b/app/renderer/lib/pageVisibility.test.ts new file mode 100644 index 0000000..be7b4ad --- /dev/null +++ b/app/renderer/lib/pageVisibility.test.ts @@ -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 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() + }) +}) diff --git a/app/renderer/lib/pageVisibility.ts b/app/renderer/lib/pageVisibility.ts new file mode 100644 index 0000000..02e0997 --- /dev/null +++ b/app/renderer/lib/pageVisibility.ts @@ -0,0 +1,15 @@ +/** + * Reflect document visibility onto 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) +} diff --git a/app/renderer/lib/refreshCadence.test.ts b/app/renderer/lib/refreshCadence.test.ts new file mode 100644 index 0000000..dc03408 --- /dev/null +++ b/app/renderer/lib/refreshCadence.test.ts @@ -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() +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) + }) +}) diff --git a/app/renderer/lib/refreshCadence.tsx b/app/renderer/lib/refreshCadence.tsx index 11b2ea7..48decdb 100644 --- a/app/renderer/lib/refreshCadence.tsx +++ b/app/renderer/lib/refreshCadence.tsx @@ -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 { diff --git a/app/renderer/main.tsx b/app/renderer/main.tsx index 9bff477..046b79f 100644 --- a/app/renderer/main.tsx +++ b/app/renderer/main.tsx @@ -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 = diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 9e19fa0..f3b2431 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -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(initialPane ?? 'general') return ( @@ -111,11 +111,11 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } - {pane === 'aliases' && } - {pane === 'pricing' && } - {pane === 'plans' && } + {pane === 'aliases' && } + {pane === 'pricing' && } + {pane === 'plans' && } {pane === 'devices' && } {pane === 'export' && } {pane === 'privacy' && } @@ -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(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) const [theme, setTheme] = useState(() => { @@ -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: } -function AliasesPane({ refreshToken }: { refreshToken: number }) { +function AliasesPane({ refreshToken, onConfigMutated }: { refreshToken: number; onConfigMutated?: () => void }) { const [actionNonce, setActionNonce] = useState(0) const aliases = usePolled(() => 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

Model aliases

Map an unrecognized model name to a priced model so its cost shows up.

@@ -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(() => 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 } -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) diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 20943ad..fa8ddb2 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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 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. ————— */