Merge pull request #722 from getagentseal/feat/update-check

Desktop: update-available notifications
This commit is contained in:
Resham Joshi 2026-07-17 04:58:48 -07:00 committed by GitHub
commit ca46845e2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 486 additions and 2 deletions

View file

@ -64,6 +64,7 @@ const CHANNELS = [
'codeburn:telemetrySetEnabled',
'codeburn:telemetryOnboarded',
'codeburn:telemetryTrack',
'codeburn:getUpdateStatus',
] as const
const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [

View file

@ -4,9 +4,12 @@ import path from 'node:path'
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli'
import { getQuota, sanitizeError } from './quota'
import { Telemetry } from './telemetry'
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
// Initialized in bootstrap() once Electron paths exist; stays null under tests.
let telemetryInstance: Telemetry | null = null
// The once-per-launch + 24h update-availability checker. Null under tests.
let updateChecker: UpdateChecker | null = null
/** The slice of Telemetry the bridge handlers use — injectable for tests. */
export type TelemetryBridge = Pick<Telemetry, 'status' | 'setEnabled' | 'completeOnboarding' | 'track'>
@ -26,6 +29,8 @@ const WARMUP_TIMEOUT_MS = 10 * 60_000
const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS '
// IPC channel carrying cold-start scan-progress events to the splash.
export const PROGRESS_CHANNEL = 'codeburn:progress'
// IPC channel pushing update-availability status to open windows (launch + 24h).
export const UPDATE_CHANNEL = 'codeburn:update'
/** Line-buffer a spawn's stderr and forward each parsed scan-progress event. */
export function makeProgressReader(emit: (event: unknown) => void): (chunk: string) => void {
@ -50,6 +55,14 @@ function broadcastProgress(event: unknown): void {
}
}
function broadcastUpdateStatus(status: UpdateStatus): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(UPDATE_CHANNEL, status)
}
}
const NO_UPDATE_STATUS: UpdateStatus = { currentVersion: '', latestVersion: null, updateAvailable: false, tag: null }
function providerArgs(provider: string | undefined): string[] {
return provider && provider !== 'all' ? ['--provider', provider] : []
}
@ -136,6 +149,8 @@ type Deps = {
emitProgress?: (event: unknown) => void
/** Consent-gated anonymous telemetry; absent under tests unless injected. */
telemetry?: TelemetryBridge | null
/** Cached update-availability status; absent under tests unless injected. */
getUpdateStatus?: () => Promise<UpdateStatus>
}
type Handler = (...args: any[]) => Promise<Envelope>
@ -145,7 +160,7 @@ type Handler = (...args: any[]) => Promise<Envelope>
* shell) and returns a result envelope. Pure + injectable so the wiring is
* unit-testable without launching Electron.
*/
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance }): Record<string, Handler> {
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance, getUpdateStatus: () => updateChecker ? updateChecker.getStatus() : Promise.resolve(NO_UPDATE_STATUS) }): Record<string, Handler> {
const emitProgress = deps.emitProgress ?? (() => {})
const telemetry = deps.telemetry ?? null
// Flips true after the first overview fetch succeeds. Until then, every
@ -277,6 +292,9 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
telemetry?.track(String(name ?? ''), props)
return { ok: true, value: true }
},
// One-shot read of the cached update-availability status. The check itself
// runs in the background (launch + 24h); this returns whatever is known.
'codeburn:getUpdateStatus': async () => ({ ok: true, value: deps.getUpdateStatus ? await deps.getUpdateStatus() : NO_UPDATE_STATUS }),
}
}
@ -475,6 +493,14 @@ function bootstrap(): void {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// Update availability: check once at launch, then every 24h, pushing each
// result to any open window. Never downloads/installs (unsigned builds);
// errors are swallowed inside the checker as a silent no-op.
updateChecker = createUpdateChecker({ currentVersion: app.getVersion() })
const runUpdateCheck = () => { void updateChecker?.check().then(broadcastUpdateStatus) }
runUpdateCheck()
setInterval(runUpdateCheck, 24 * 60 * 60 * 1000)
})
app.on('window-all-closed', () => {

View file

@ -61,6 +61,13 @@ const bridge = {
ipcRenderer.on('codeburn:progress', listener)
return () => { ipcRenderer.removeListener('codeburn:progress', listener) }
},
// Update availability: a one-shot read plus a push for the launch + 24h checks.
getUpdateStatus: () => invoke('codeburn:getUpdateStatus'),
onUpdateStatus: (cb: (status: unknown) => void) => {
const listener = (_e: unknown, status: unknown) => cb(status)
ipcRenderer.on('codeburn:update', listener)
return () => { ipcRenderer.removeListener('codeburn:update', listener) }
},
platform: process.platform,
}

View file

@ -0,0 +1,129 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { compareSemver, createUpdateChecker, fetchReleases, pickLatestDesktopVersion } from './updates'
const CURRENT = '0.9.16'
function release(tag: string) {
return { tag_name: tag }
}
describe('compareSemver', () => {
it.each([
['0.9.17', '0.9.16', 1],
['0.10.0', '0.9.16', 1],
['1.0.0', '0.9.16', 1],
['0.9.16', '0.9.16', 0],
['0.9.15', '0.9.16', -1],
['0.9.9', '0.9.16', -1], // string sort would rank "9" > "16"; numeric must not
])('compareSemver(%s, %s) === %d', (a, b, expected) => {
expect(Math.sign(compareSemver(a, b))).toBe(expected)
})
})
describe('pickLatestDesktopVersion', () => {
it('picks the newest desktop-v tag by semver, ignoring non-desktop tags', () => {
const picked = pickLatestDesktopVersion([
release('v0.9.20'), // CLI release — ignored
release('mac-v0.9.18'), // menubar release — ignored
release('desktop-v0.9.15'),
release('desktop-v0.9.17'),
release('desktop-v0.9.16'),
])
expect(picked).toEqual({ version: '0.9.17', tag: 'desktop-v0.9.17' })
})
it('returns null when no desktop-v release is present', () => {
expect(pickLatestDesktopVersion([release('v1.2.3'), release('mac-v0.9.9')])).toBeNull()
})
it('rejects malformed / prerelease-shaped desktop tags', () => {
expect(pickLatestDesktopVersion([release('desktop-v0.9'), release('desktop-v1.0.0-rc1')])).toBeNull()
})
})
describe('createUpdateChecker', () => {
const checker = (releases: unknown[]) =>
createUpdateChecker({ currentVersion: CURRENT, fetchReleasesImpl: async () => releases as never })
it('flags an update when a newer desktop release exists', async () => {
const status = await checker([release('desktop-v0.9.17')]).getStatus()
expect(status).toEqual({ currentVersion: '0.9.16', latestVersion: '0.9.17', updateAvailable: true, tag: 'desktop-v0.9.17' })
})
it.each(['0.10.0', '1.0.0'])('flags an update for a %s release', async version => {
const status = await checker([release(`desktop-v${version}`)]).getStatus()
expect(status).toMatchObject({ updateAvailable: true, latestVersion: version })
})
it('reports no update for the same version (tag suppressed)', async () => {
const status = await checker([release('desktop-v0.9.16')]).getStatus()
expect(status).toEqual({ currentVersion: '0.9.16', latestVersion: '0.9.16', updateAvailable: false, tag: null })
})
it('reports no update for an older release', async () => {
const status = await checker([release('desktop-v0.9.15')]).getStatus()
expect(status).toMatchObject({ updateAvailable: false, latestVersion: '0.9.15' })
})
it('is a silent no-op on a fetch error and retries on the next cycle', async () => {
let calls = 0
const check = createUpdateChecker({
currentVersion: CURRENT,
fetchReleasesImpl: async () => {
calls += 1
if (calls === 1) throw new Error('offline')
return [release('desktop-v0.9.17')] as never
},
})
const first = await check.getStatus()
expect(first).toMatchObject({ updateAvailable: false, latestVersion: null }) // error: no crash, no update
const second = await check.getStatus() // retries because the error did not stamp lastCheckedAt
expect(second).toMatchObject({ updateAvailable: true, latestVersion: '0.9.17' })
expect(calls).toBe(2)
})
it('serves the cached status within the 24h interval, then re-checks after it', async () => {
let clock = 1_000
let calls = 0
const check = createUpdateChecker({
currentVersion: CURRENT,
now: () => clock,
fetchReleasesImpl: async () => {
calls += 1
return [release('desktop-v0.9.17')] as never
},
})
await check.getStatus()
expect(calls).toBe(1)
clock += 60_000 // <24h later
await check.getStatus()
expect(calls).toBe(1) // served from cache
clock += 24 * 60 * 60 * 1000 // past the interval
await check.getStatus()
expect(calls).toBe(2)
})
})
describe('fetchReleases', () => {
it('reads the releases feed with no auth or app-identifying headers', async () => {
const fakeFetch = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify([{ tag_name: 'desktop-v0.9.17' }]), { status: 200 }))
const result = await fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch)
expect(result).toEqual([{ tag_name: 'desktop-v0.9.17' }])
const [url, init] = fakeFetch.mock.calls[0]!
expect(String(url)).toContain('api.github.com/repos/getagentseal/codeburn/releases')
// No custom headers at all → no Authorization, no app-identifying UA override.
expect(init?.headers).toBeUndefined()
})
it('throws on a non-2xx response so the checker treats it as a no-op', async () => {
const fakeFetch = vi.fn(async () => new Response('rate limited', { status: 403 }))
await expect(fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch)).rejects.toThrow(/403/)
})
it('tolerates a non-array body', async () => {
const fakeFetch = vi.fn(async () => new Response(JSON.stringify({ message: 'oops' }), { status: 200 }))
expect(await fetchReleases(new AbortController().signal, fakeFetch as unknown as typeof fetch)).toEqual([])
})
})

133
app/electron/updates.ts Normal file
View file

@ -0,0 +1,133 @@
// "Update available" check for CodeBurn Desktop, mirroring the Swift menubar's
// UpdateChecker (mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift): it reads
// the public GitHub releases feed once per launch and every 24h, finds the newest
// `desktop-v<semver>` release, and semver-compares it to the running version.
//
// It NEVER downloads or installs. The desktop builds are unsigned, so an in-app
// auto-update can't run yet — that arrives with Developer ID signing. Offline or
// any error is a silent no-op that retries on the next cycle.
//
// Privacy: this is a plain, unauthenticated GitHub read that carries no
// identifiers. We deliberately send NO app-identifying headers — only the
// runtime's default User-Agent (Node/Electron's "node") goes out, and no auth
// token — so the request reveals nothing about the user or install. GitHub only
// requires *some* User-Agent, which the default satisfies. See fetchReleases.
const RELEASES_URL = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=15'
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const FETCH_TIMEOUT_MS = 15_000
// Desktop releases are tagged `desktop-v<major>.<minor>.<patch>` (app/DISTRIBUTION.md).
const DESKTOP_TAG_RE = /^desktop-v(\d+\.\d+\.\d+)$/
export type UpdateStatus = {
currentVersion: string
/** Newest published desktop version, or null when unknown (offline / no match). */
latestVersion: string | null
updateAvailable: boolean
/** The release tag to link when an update is available (else null). */
tag: string | null
}
type GitHubRelease = { tag_name?: string }
function baselineStatus(currentVersion: string): UpdateStatus {
return { currentVersion, latestVersion: null, updateAvailable: false, tag: null }
}
/** Numeric compare of two `major.minor.patch` strings: -1 / 0 / 1. Missing or
* non-numeric parts count as 0. */
export function compareSemver(a: string, b: string): number {
const pa = a.split('.')
const pb = b.split('.')
for (let i = 0; i < 3; i++) {
const x = Number(pa[i] ?? 0) || 0
const y = Number(pb[i] ?? 0) || 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
/** The newest `desktop-v<semver>` release among the feed, or null if none match.
* Picks by semver rather than trusting feed order. */
export function pickLatestDesktopVersion(releases: GitHubRelease[]): { version: string; tag: string } | null {
let best: { version: string; tag: string } | null = null
for (const release of releases) {
const tag = typeof release?.tag_name === 'string' ? release.tag_name : ''
const match = DESKTOP_TAG_RE.exec(tag)
if (!match) continue
const version = match[1]!
if (!best || compareSemver(version, best.version) > 0) best = { version, tag }
}
return best
}
/** Fetch + parse the releases feed. No auth, no app-identifying headers (see the
* file header). Aborts after 15s. Throws on a non-2xx response. */
export async function fetchReleases(signal: AbortSignal, fetchImpl: typeof fetch = globalThis.fetch): Promise<GitHubRelease[]> {
const response = await fetchImpl(RELEASES_URL, { signal })
if (!response.ok) throw new Error(`GitHub HTTP ${response.status}`)
const data = await response.json()
return Array.isArray(data) ? (data as GitHubRelease[]) : []
}
export type UpdateChecker = {
/** Cached status, refreshed only when older than the 24h interval. */
getStatus(): Promise<UpdateStatus>
/** Force a fresh check now (launch + the 24h timer call this). */
check(): Promise<UpdateStatus>
}
export function createUpdateChecker(opts: {
currentVersion: string
/** Injected in tests; defaults to the real GitHub read. */
fetchReleasesImpl?: (signal: AbortSignal) => Promise<GitHubRelease[]>
now?: () => number
intervalMs?: number
}): UpdateChecker {
const now = opts.now ?? (() => Date.now())
const intervalMs = opts.intervalMs ?? CHECK_INTERVAL_MS
const fetchReleasesImpl = opts.fetchReleasesImpl ?? ((signal: AbortSignal) => fetchReleases(signal))
let cached = baselineStatus(opts.currentVersion)
let lastCheckedAt = 0
let inflight: Promise<UpdateStatus> | null = null
const check = (): Promise<UpdateStatus> => {
if (inflight) return inflight
inflight = (async () => {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const releases = await fetchReleasesImpl(controller.signal)
const latest = pickLatestDesktopVersion(releases)
lastCheckedAt = now()
if (!latest) {
cached = baselineStatus(opts.currentVersion)
} else {
const updateAvailable = compareSemver(latest.version, opts.currentVersion) > 0
cached = {
currentVersion: opts.currentVersion,
latestVersion: latest.version,
updateAvailable,
tag: updateAvailable ? latest.tag : null,
}
}
} catch {
// Offline / GitHub error / timeout: silent no-op. Keep the last known
// status and leave lastCheckedAt so the next cycle retries.
} finally {
clearTimeout(timer)
inflight = null
}
return cached
})()
return inflight
}
const getStatus = (): Promise<UpdateStatus> => {
if (lastCheckedAt !== 0 && now() - lastCheckedAt < intervalMs) return Promise.resolve(cached)
return check()
}
return { getStatus, check }
}

View file

@ -8,6 +8,7 @@ import { Panel } from './components/Panel'
import { Sidebar, type Section } from './components/Sidebar'
import { Splash } from './components/Splash'
import { ToastHost } from './components/ToastHost'
import { UpdateBanner } from './components/UpdateBanner'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { clearPolledMemo, hasPolledMemo, primePolledMemo, setPolledMemoMax, usePolled } from './hooks/usePolled'
@ -407,6 +408,7 @@ function AppMain() {
{onboardingStatus && <Onboarding defaultEnabled={onboardingStatus.defaultEnabled} onDone={finishOnboarding} />}
<div className="ct">
<div className={overview.switching ? 'switch-line on' : 'switch-line'} aria-hidden="true" />
<UpdateBanner />
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
{section === 'plans' ? (

View file

@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { UpdateBanner } from './UpdateBanner'
import type { UpdateStatus } from '../lib/types'
const mocks = vi.hoisted(() => ({
getUpdateStatus: vi.fn<() => Promise<UpdateStatus>>(),
onUpdateStatus: vi.fn<(cb: (s: UpdateStatus) => void) => () => void>(() => () => {}),
openExternal: vi.fn<(url: string) => Promise<void>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: mocks }
})
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 NEWER: UpdateStatus = { currentVersion: '0.9.16', latestVersion: '0.9.17', updateAvailable: true, tag: 'desktop-v0.9.17' }
const SAME: UpdateStatus = { currentVersion: '0.9.16', latestVersion: '0.9.16', updateAvailable: false, tag: null }
describe('UpdateBanner', () => {
beforeEach(() => {
stored.clear()
mocks.getUpdateStatus.mockReset()
mocks.openExternal.mockReset().mockResolvedValue(undefined)
mocks.onUpdateStatus.mockReset().mockReturnValue(() => {})
})
it('renders on a newer version, and Download opens the release page', async () => {
mocks.getUpdateStatus.mockResolvedValue(NEWER)
render(<UpdateBanner />)
const banner = await screen.findByRole('status')
expect(banner).toHaveTextContent('Update available: CodeBurn 0.9.17')
fireEvent.click(screen.getByRole('button', { name: 'Download' }))
expect(mocks.openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/releases/tag/desktop-v0.9.17')
})
it('does not render when the running version is current', async () => {
mocks.getUpdateStatus.mockResolvedValue(SAME)
render(<UpdateBanner />)
await waitFor(() => expect(mocks.getUpdateStatus).toHaveBeenCalled())
expect(screen.queryByRole('status')).toBeNull()
})
it('does not render when the check errored (offline)', async () => {
mocks.getUpdateStatus.mockRejectedValue(new Error('offline'))
render(<UpdateBanner />)
await waitFor(() => expect(mocks.getUpdateStatus).toHaveBeenCalled())
expect(screen.queryByRole('status')).toBeNull()
})
it('dismiss persists per version and does not nag again for the same tag', async () => {
mocks.getUpdateStatus.mockResolvedValue(NEWER)
render(<UpdateBanner />)
await screen.findByRole('status')
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).toBeNull()
expect(stored.get('codeburn.updateDismissed')).toBe('desktop-v0.9.17')
// A fresh mount with the same tag stays hidden.
cleanup()
render(<UpdateBanner />)
await waitFor(() => expect(mocks.getUpdateStatus).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('status')).toBeNull()
})
it('shows again for a newer release even after an older one was dismissed', async () => {
stored.set('codeburn.updateDismissed', 'desktop-v0.9.17')
mocks.getUpdateStatus.mockResolvedValue({ currentVersion: '0.9.16', latestVersion: '0.9.18', updateAvailable: true, tag: 'desktop-v0.9.18' })
render(<UpdateBanner />)
const banner = await screen.findByRole('status')
expect(banner).toHaveTextContent('Update available: CodeBurn 0.9.18')
})
})

View file

@ -0,0 +1,40 @@
import { useState } from 'react'
import { releasePageUrl, useUpdateStatus } from '../hooks/useUpdateStatus'
import { codeburn } from '../lib/ipc'
const DISMISS_KEY = 'codeburn.updateDismissed'
function readDismissed(): string | null {
try { return globalThis.localStorage?.getItem(DISMISS_KEY) ?? null } catch { return null }
}
/**
* Subtle, dismissible "update available" nudge, in the budget-banner visual
* language. Dismiss persists per release tag (codeburn.updateDismissed), so the
* same version never nags twice but the next release shows fresh. Download opens
* the release page via the https-only openExternal bridge. Never auto-installs.
*/
export function UpdateBanner() {
const status = useUpdateStatus()
const [dismissedTag, setDismissedTag] = useState<string | null>(readDismissed)
if (!status || !status.updateAvailable || !status.tag) return null
if (dismissedTag === status.tag) return null
const tag = status.tag
const dismiss = () => {
try { globalThis.localStorage?.setItem(DISMISS_KEY, tag) } catch { /* storage can be unavailable */ }
setDismissedTag(tag)
}
return (
<div role="status" className="update-banner">
<span>
Update available: CodeBurn {status.latestVersion} ·{' '}
<button type="button" className="set-text-button" onClick={() => { void codeburn.openExternal(releasePageUrl(tag)) }}>Download</button>
</span>
<button type="button" className="set-text-button" onClick={dismiss}>Dismiss</button>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import { codeburn } from '../lib/ipc'
import type { UpdateStatus } from '../lib/types'
/**
* Reads the main-process update-availability status once on mount and stays live
* via the push event. Returns null until the first read resolves, or when the
* bridge predates the feature (an older preload, or a test mock) both degrade
* to "no update", so callers can treat null as "nothing to show".
*/
export function useUpdateStatus(): UpdateStatus | null {
const [status, setStatus] = useState<UpdateStatus | null>(null)
useEffect(() => {
if (typeof codeburn.getUpdateStatus !== 'function') return
let active = true
codeburn.getUpdateStatus().then(next => { if (active) setStatus(next) }).catch(() => { /* offline — no nudge */ })
const unsubscribe = typeof codeburn.onUpdateStatus === 'function'
? codeburn.onUpdateStatus(next => { if (active) setStatus(next) })
: undefined
return () => { active = false; unsubscribe?.() }
}, [])
return status
}
/** GitHub release page for a desktop tag the Download target (no site #get
* anchor exists). https-only, so it passes the openExternal allowlist. */
export function releasePageUrl(tag: string): string {
return `https://github.com/getagentseal/codeburn/releases/tag/${tag}`
}

View file

@ -570,9 +570,21 @@ export type ScanProgressEvent =
| { kind: 'tick'; provider: string; done: number; total: number }
| { kind: 'done' }
/** Update-availability status from the main process (app/electron/updates.ts). */
export type UpdateStatus = {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
tag: string | null
}
export interface CodeburnBridge {
/** Subscribe to cold-start scan progress; returns an unsubscribe fn. */
onProgress(cb: (event: ScanProgressEvent) => void): () => void
/** Read the cached update-availability status (launch + 24h background check). */
getUpdateStatus(): Promise<UpdateStatus>
/** Subscribe to pushed update-availability status; returns an unsubscribe fn. */
onUpdateStatus(cb: (status: UpdateStatus) => void): () => void
getQuota(force?: boolean): Promise<QuotaProvider[]>
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>

View file

@ -8,6 +8,8 @@ import { Panel } from '../components/Panel'
import { ProviderLogo } from '../components/ProviderLogo'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { releasePageUrl, useUpdateStatus } from '../hooks/useUpdateStatus'
import { version as appVersion } from '../../package.json'
import { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
@ -138,6 +140,13 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
const [budgetKind, setBudgetKind] = useState<'off' | 'usd' | 'tokens'>(() => readDailyBudget()?.kind ?? 'off')
const [budgetInput, setBudgetInput] = useState(() => { const budget = readDailyBudget(); return budget ? String(budget.value) : '' })
const [budgetError, setBudgetError] = useState('')
const update = useUpdateStatus()
const version = update?.currentVersion || appVersion
const updateNote = update?.updateAvailable && update.latestVersion
? `Update available: ${update.latestVersion}`
: update?.latestVersion
? 'Up to date'
: ''
useEffect(() => {
if (theme === 'system') document.documentElement.removeAttribute('data-theme')
@ -186,7 +195,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<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">
<div className="about-sec-h">Display</div>
<div className="about-row"><label className="tx" htmlFor="settings-currency">Currency</label><span className="r">
{plans.data ? <Dropdown id="settings-currency" ariaLabel="Currency" value={plans.data.currency} options={currencies.map(code => ({ value: code, label: code }))} onChange={value => void codeburn.setCurrency(value).then(finishCurrency)} width={92} /> : plans.error ? <SettingsErrorText error={plans.error} /> : <span className="set-cap">Loading</span>}
@ -197,6 +206,10 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<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>
<div className="about-sec set-last-sec">
<div className="about-sec-h">About</div>
<div className="about-row"><span className="tx">Version {version}{updateNote && <small>{updateNote}</small>}</span><span className="r">{update?.updateAvailable && update.tag ? <button className="set-text-button" onClick={() => { void codeburn.openExternal(releasePageUrl(update.tag!)) }}>Download</button> : null}</span></div>
</div>
</div>
</section>
)

View file

@ -305,6 +305,9 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.budget-banner.exceeded { color: var(--bad); border-left-color: var(--bad); }
.budget-banner > span { flex: 1; }
.budget-banner .set-text-button { color: var(--mut); }
.update-banner { display: flex; align-items: center; gap: 10px; padding: 6px 16px; font-size: 12px; font-weight: 550; color: var(--ok); border-left: 3px solid var(--ok); border-bottom: 1px solid var(--line); line-height: 1.4; }
.update-banner > span { flex: 1; }
.update-banner > .set-text-button { color: var(--mut); }
/* Settings */
.body.set-body { align-self: stretch; align-items: stretch; justify-content: flex-start; width: auto; height: 100%; min-height: 0; max-width: none; margin: 0; padding: 0; overflow: hidden; flex-direction: row; gap: 0; }