mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 11:25:08 +00:00
feat(app): window chrome, sidebar nav, and Overview smoke view
Ports the wireframe window shell into React with exact markup/classes:
- Shared components: Window, Sidebar (traffic lights, gradient mark, six
nav items with ⌘ keycaps + active `on` rail, status line), TopBar
(title, scope, period SegTabs Today/7D/30D/Month/6M/Custom, ProviderPop),
Panel, Stat, SegTabs, ProviderPop, Hint.
- usePolled(fetcher, deps): generic 30s poll + loading/error state,
errors normalized to CliError.
- App.tsx: local section state switches the content outlet; the Overview
placeholder round-trips getOverview('30days','all') and renders the raw
current.cost, with an honest first-run "locate CLI" state when the
codeburn binary isn't found (never a crash).
- Sidebar.test.tsx: six nav items, click routes onNavigate, active `on`.
This commit is contained in:
parent
e22e5511ba
commit
6e3729cfa9
13 changed files with 497 additions and 0 deletions
153
app/renderer/App.tsx
Normal file
153
app/renderer/App.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { Hint } from './components/Hint'
|
||||
import { Panel } from './components/Panel'
|
||||
import { Sidebar, type Section } from './components/Sidebar'
|
||||
import { Stat } from './components/Stat'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { Window } from './components/Window'
|
||||
import { usePolled } from './hooks/usePolled'
|
||||
import { codeburn } from './lib/ipc'
|
||||
import type { MenubarPayload, Period } from './lib/types'
|
||||
|
||||
const SECTION_TITLES: Record<Section, string> = {
|
||||
overview: 'Overview',
|
||||
spend: 'Spend',
|
||||
optimize: 'Optimize',
|
||||
models: 'Models',
|
||||
plans: 'Plans',
|
||||
settings: 'Settings',
|
||||
}
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today',
|
||||
week: 'Last 7 days',
|
||||
month: 'This month',
|
||||
'30days': 'Last 30 days',
|
||||
all: 'All time',
|
||||
}
|
||||
|
||||
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
|
||||
function isPeriod(value: string): value is Period {
|
||||
return (STANDARD_PERIODS as string[]).includes(value)
|
||||
}
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [section, setSection] = useState<Section>('overview')
|
||||
const [period, setPeriod] = useState<Period>('30days')
|
||||
const provider = 'all'
|
||||
|
||||
// Polled at the app level: proves the spawn→IPC→render path and feeds the
|
||||
// sidebar status line. Section-specific fetching lands in T2–T7.
|
||||
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
|
||||
|
||||
const onPeriodChange = (value: string) => {
|
||||
// 6M / Custom (date ranges) are not wired until T8; ignore for now so the
|
||||
// highlight never lies about what was fetched.
|
||||
if (isPeriod(value)) setPeriod(value)
|
||||
}
|
||||
|
||||
const scope = `${PERIOD_LABELS[period]} · All providers`
|
||||
|
||||
return (
|
||||
<Window>
|
||||
<Sidebar active={section} onNavigate={setSection} status={<StatusLine polled={overview} />} />
|
||||
<div className="ct">
|
||||
<TopBar
|
||||
title={SECTION_TITLES[section]}
|
||||
scope={section === 'settings' ? undefined : scope}
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
providerLabel="All providers"
|
||||
/>
|
||||
<div className="body">
|
||||
{section === 'overview' ? (
|
||||
<OverviewPlaceholder polled={overview} />
|
||||
) : (
|
||||
<SectionPlaceholder title={SECTION_TITLES[section]} />
|
||||
)}
|
||||
</div>
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘K', label: 'Command' },
|
||||
{ k: '⌘E', label: 'Export view' },
|
||||
]}
|
||||
right={overview.loading ? 'refreshing…' : 'auto-refresh 30s'}
|
||||
/>
|
||||
</div>
|
||||
</Window>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
|
||||
if (polled.data) {
|
||||
return (
|
||||
<>
|
||||
{polled.data.current.label} <b>{fmtUsd(polled.data.current.cost)}</b>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (polled.error?.kind === 'not-found') return <>CLI not found</>
|
||||
if (polled.loading) return <>scanning…</>
|
||||
return <>—</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 0 smoke view: renders the raw `current.cost` from getOverview to prove
|
||||
* the end-to-end path. The real Overview (stat cards, capsule chart, sessions)
|
||||
* is Task 2. CLI-missing shows an honest first-run state, never a crash.
|
||||
*/
|
||||
function OverviewPlaceholder({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
|
||||
if (polled.error?.kind === 'not-found') {
|
||||
return (
|
||||
<Panel title="Locate the codeburn CLI">
|
||||
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
|
||||
CodeBurn Desktop reads your usage by running the <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command,
|
||||
but it isn't on your PATH yet.
|
||||
</p>
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
|
||||
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>, then reopen this window.
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (polled.error) {
|
||||
return (
|
||||
<Panel title="Couldn't read usage">
|
||||
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{polled.error.message}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (!polled.data) {
|
||||
return (
|
||||
<Panel title="Overview">
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Scanning sessions…</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const current = polled.data.current
|
||||
return (
|
||||
<div className="stats" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
<Stat label={current.label} value={fmtUsd(current.cost)} delta={`${current.sessions} sessions`} tone="info" />
|
||||
<Stat label="Calls" value={current.calls.toLocaleString('en-US')} delta="this period" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionPlaceholder({ title }: { title: string }) {
|
||||
return (
|
||||
<Panel title={title}>
|
||||
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>
|
||||
{title} lands in a later task. The shell, data bridge, and design system are in place.
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
18
app/renderer/components/Hint.tsx
Normal file
18
app/renderer/components/Hint.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export type HintItem = { k?: string; label: ReactNode }
|
||||
|
||||
/** The `.hint` footer strip: keycap hints on the left, optional right-aligned note. */
|
||||
export function Hint({ items, right }: { items: HintItem[]; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="hint">
|
||||
{items.map((item, i) => (
|
||||
<span key={i}>
|
||||
{item.k && <span className="k">{item.k}</span>}
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
{right !== undefined && <span className="r">{right}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
app/renderer/components/Panel.tsx
Normal file
30
app/renderer/components/Panel.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
/** Dash0-style card: optional `.phead` title strip + `.pbody` content. */
|
||||
export function Panel({
|
||||
title,
|
||||
right,
|
||||
className,
|
||||
bodyStyle,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
right?: ReactNode
|
||||
className?: string
|
||||
bodyStyle?: CSSProperties
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={className ? `panel ${className}` : 'panel'}>
|
||||
{title !== undefined && (
|
||||
<div className="phead">
|
||||
<b>{title}</b>
|
||||
{right !== undefined && <span className="r">{right}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="pbody" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
app/renderer/components/ProviderPop.tsx
Normal file
8
app/renderer/components/ProviderPop.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** The `.pop` provider selector. Mutation (opening the menu) lands in T8. */
|
||||
export function ProviderPop({ label, onClick }: { label: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div className="pop" role="button" tabIndex={0} onClick={onClick}>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
app/renderer/components/SegTabs.tsx
Normal file
33
app/renderer/components/SegTabs.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export type SegOption = { value: string; label: string }
|
||||
|
||||
/** The `.seg` segmented control used for period and lens switching. */
|
||||
export function SegTabs({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
style,
|
||||
}: {
|
||||
options: SegOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<div className="seg" style={style}>
|
||||
{options.map(opt => (
|
||||
<span
|
||||
key={opt.value}
|
||||
className={opt.value === value ? 'on' : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onChange(opt.value)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
app/renderer/components/Sidebar.test.tsx
Normal file
28
app/renderer/components/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders all six nav items', () => {
|
||||
render(<Sidebar active="overview" onNavigate={() => {}} />)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(6)
|
||||
for (const label of ['Overview', 'Spend', 'Optimize', 'Models', 'Plans', 'Settings']) {
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('calls onNavigate with the section id when a nav item is clicked', () => {
|
||||
const onNavigate = vi.fn()
|
||||
render(<Sidebar active="overview" onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
|
||||
expect(onNavigate).toHaveBeenCalledWith('spend')
|
||||
})
|
||||
|
||||
it('marks the active item with the "on" class', () => {
|
||||
render(<Sidebar active="models" onNavigate={() => {}} />)
|
||||
expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on')
|
||||
expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on')
|
||||
})
|
||||
})
|
||||
46
app/renderer/components/Sidebar.tsx
Normal file
46
app/renderer/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Section = 'overview' | 'spend' | 'optimize' | 'models' | 'plans' | 'settings'
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string }> = [
|
||||
{ id: 'overview', label: 'Overview', key: '⌘1' },
|
||||
{ id: 'spend', label: 'Spend', key: '⌘2' },
|
||||
{ id: 'optimize', label: 'Optimize', key: '⌘3' },
|
||||
{ id: 'models', label: 'Models', key: '⌘4' },
|
||||
{ id: 'plans', label: 'Plans', key: '⌘5' },
|
||||
{ id: 'settings', label: 'Settings', key: '⌘,' },
|
||||
]
|
||||
|
||||
export function Sidebar({
|
||||
active,
|
||||
onNavigate,
|
||||
status,
|
||||
}: {
|
||||
active: Section
|
||||
onNavigate: (section: Section) => void
|
||||
status?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<nav className="sb">
|
||||
<div className="lights"><i /><i /><i /></div>
|
||||
<div className="app"><b>CodeBurn</b></div>
|
||||
{NAV_ITEMS.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={item.id === active ? 'ni on' : 'ni'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id)
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
<span className="k">{item.key}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="push" />
|
||||
<div className="status">{status}</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
26
app/renderer/components/Stat.tsx
Normal file
26
app/renderer/components/Stat.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export type StatTone = 'info' | 'bad' | 'hot' | 'ok'
|
||||
|
||||
/** A `.panel.stat` metric card: label strip + big value + tinted delta line. */
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
tone,
|
||||
}: {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
delta?: ReactNode
|
||||
tone?: StatTone
|
||||
}) {
|
||||
return (
|
||||
<div className="panel stat">
|
||||
<div className="phead"><b>{label}</b></div>
|
||||
<div className="pbody">
|
||||
<div className="v">{value}</div>
|
||||
{delta !== undefined && <div className={tone ? `d ${tone}` : 'd'}>{delta}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
app/renderer/components/TopBar.tsx
Normal file
41
app/renderer/components/TopBar.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ProviderPop } from './ProviderPop'
|
||||
import { SegTabs, type SegOption } from './SegTabs'
|
||||
|
||||
/** Full period vocabulary per the wireframe. 6M/Custom (date ranges) land in T8. */
|
||||
export const PERIOD_OPTIONS: SegOption[] = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'week', label: '7D' },
|
||||
{ value: '30days', label: '30D' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: '6m', label: '6M' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */
|
||||
export function TopBar({
|
||||
title,
|
||||
scope,
|
||||
period,
|
||||
onPeriodChange,
|
||||
providerLabel,
|
||||
onProviderClick,
|
||||
}: {
|
||||
title: ReactNode
|
||||
scope?: ReactNode
|
||||
period: string
|
||||
onPeriodChange: (value: string) => void
|
||||
providerLabel: string
|
||||
onProviderClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bar">
|
||||
<div className="t">{title}</div>
|
||||
{scope !== undefined && <span className="scope">{scope}</span>}
|
||||
<div className="sp" />
|
||||
<SegTabs options={PERIOD_OPTIONS} value={period} onChange={onPeriodChange} />
|
||||
<ProviderPop label={providerLabel} onClick={onProviderClick} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
app/renderer/components/Window.tsx
Normal file
6
app/renderer/components/Window.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
/** The `.win` shell: sidebar + content area live inside as children. */
|
||||
export function Window({ children }: { children: ReactNode }) {
|
||||
return <div className="win">{children}</div>
|
||||
}
|
||||
60
app/renderer/hooks/usePolled.ts
Normal file
60
app/renderer/hooks/usePolled.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { normalizeCliError } from '../lib/ipc'
|
||||
import type { CliError } from '../lib/types'
|
||||
|
||||
export type Polled<T> = {
|
||||
data: T | null
|
||||
error: CliError | null
|
||||
loading: boolean
|
||||
/** Re-run the fetcher immediately (period/provider change, manual refresh). */
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then
|
||||
* re-polls every `intervalMs`. Errors are normalized to the CliError shape so
|
||||
* sections can branch on `error.kind`. Last-good data is retained on error.
|
||||
*/
|
||||
export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs = 30_000): Polled<T> {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<CliError | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetcher()
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setData(result)
|
||||
setError(null)
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) setError(normalizeCliError(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// deps are intentionally the caller-provided dependency list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useEffect(() => {
|
||||
const cancel = load()
|
||||
const id = setInterval(() => load(), intervalMs)
|
||||
return () => {
|
||||
cancel()
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [load, intervalMs])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { data, error, loading, refresh }
|
||||
}
|
||||
34
app/renderer/index.html
Normal file
34
app/renderer/index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ws: http://localhost:5173"
|
||||
/>
|
||||
<title>CodeBurn</title>
|
||||
<style>
|
||||
/* App-shell glue only — indigo.css is the verbatim wireframe port.
|
||||
Make the window fill the OS window instead of the doc-page card. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#root {
|
||||
display: flex;
|
||||
}
|
||||
.win {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
app/renderer/main.tsx
Normal file
14
app/renderer/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { App } from './App'
|
||||
import './styles/indigo.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root not found')
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue