codeburn/app/renderer/sections/Compare.tsx
iamtoruk 76bc80ca67 polish(app): skeleton loaders for Compare and the Audit lens
The motion batch gave five sections shimmer skeletons on first load but
missed Compare and Models' Audit lens (both still showed bare loading
text in an empty panel). Same SectionSkeleton treatment now.

250/250.
2026-07-16 05:04:50 -07:00

269 lines
10 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Dropdown } from '../components/Dropdown'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
if (v === null) return '—'
if (fn === 'cost') return formatUsd(v)
if (fn === 'percent') return `${v.toFixed(0)}%`
if (fn === 'decimal') return v.toFixed(2)
return Math.round(v).toLocaleString('en-US')
}
// The CLI `compare` command has no --from/--to, so a custom range falls back to
// the selected period. Say so instead of silently ignoring the dates.
function RangeNote() {
return (
<p className="cmp-range-note" role="status">
Compare uses the selected period, custom dates are not supported yet.
</p>
)
}
export function Compare({
period,
provider,
range = null,
refreshToken = 0,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
}) {
const models = usePolled<ModelStats[]>(
() => codeburn.getCompareModels(period, provider),
[period, provider, refreshToken],
)
const [modelA, setModelA] = useState<string | null>(null)
const [modelB, setModelB] = useState<string | null>(null)
useEffect(() => {
if (!models.data) return
const available = new Set(models.data.map(model => model.model))
setModelA(current => current && available.has(current) ? current : models.data?.[0]?.model ?? null)
setModelB(current => current && available.has(current) ? current : models.data?.[1]?.model ?? null)
}, [models.data])
const resetToDefaults = useCallback(() => {
if (!models.data) return
setModelA(models.data[0]?.model ?? null)
setModelB(models.data[1]?.model ?? null)
}, [models.data])
if (!models.data) {
if (models.error) return <CliErrorPanel error={models.error} subject="model comparisons" />
return <SectionSkeleton label="Scanning model usage…" rows={4} />
}
if (models.data.length < 2) {
return (
<Panel title="Compare">
<EmptyNote>Need at least two models with usage in this range to compare.</EmptyNote>
</Panel>
)
}
const modelRows = models.data
const nudgeDistinct = (chosen: string) => modelRows.find(model => model.model !== chosen)?.model ?? null
return (
<>
{range && <RangeNote />}
<div className="cmp-picker" aria-label="Models being compared">
<Dropdown
id="compare-first-model"
ariaLabel="First model"
value={modelA ?? ''}
options={modelRows.map(model => ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelA(next)
if (next === modelB) setModelB(nudgeDistinct(next))
}}
/>
<span className="cmp-vs">vs</span>
<Dropdown
id="compare-second-model"
ariaLabel="Second model"
value={modelB ?? ''}
options={modelRows.map(model => ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelB(next)
if (next === modelA) setModelA(nudgeDistinct(next))
}}
/>
</div>
{modelA && modelB && modelA !== modelB && (
<CompareReport
period={period}
provider={provider}
modelA={modelA}
modelB={modelB}
refreshToken={refreshToken}
onError={resetToDefaults}
/>
)}
</>
)
}
function CompareReport({
period,
provider,
modelA,
modelB,
refreshToken,
onError,
}: {
period: Period
provider: string
modelA: string
modelB: string
refreshToken: number
onError: () => void
}) {
const report = usePolled<CompareJsonReport>(
() => codeburn.getCompare(period, provider, modelA, modelB),
[period, provider, modelA, modelB, refreshToken],
)
useEffect(() => {
if (report.error) onError()
}, [report.error, onError])
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="model comparisons" />
return <SectionSkeleton label="Comparing models…" rows={4} />
}
const performance = report.data.metrics.filter(metric => metric.section === 'Performance')
const efficiency = report.data.metrics.filter(metric => metric.section === 'Efficiency')
return (
<div className="cmp-body">
<div className="cmp-pair">
<MetricCard title="Performance" rows={performance} modelA={report.data.modelA.model} modelB={report.data.modelB.model} showWinners />
<MetricCard title="Efficiency" rows={efficiency} modelA={report.data.modelA.model} modelB={report.data.modelB.model} showWinners />
</div>
<CategoryCard report={report.data} />
<div className="cmp-pair">
<MetricCard title="Working style" rows={report.data.workingStyle} modelA={report.data.modelA.model} modelB={report.data.modelB.model} />
<ContextCard modelA={report.data.modelA} modelB={report.data.modelB} />
</div>
</div>
)
}
function MetricCard({
title,
rows,
modelA,
modelB,
showWinners = false,
}: {
title: string
rows: Array<ComparisonRow | WorkingStyleRow>
modelA: string
modelB: string
showWinners?: boolean
}) {
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>{title}</h3></div>
<div className="cmp-metrics">
<MetricHeader modelA={modelA} modelB={modelB} />
{rows.map(row => {
const winner = 'winner' in row ? row.winner : 'none'
return (
<div className="cmp-metric" key={row.label}>
<span className="cmp-label">{row.label}</span>
<span className={`cmp-value${showWinners && winner === 'a' ? ' cmp-best' : ''}`}>{fmtMetric(row.valueA, row.formatFn)}</span>
<span className={`cmp-value${showWinners && winner === 'b' ? ' cmp-best' : ''}`}>{fmtMetric(row.valueB, row.formatFn)}</span>
</div>
)
})}
</div>
{showWinners && <div className="cmp-foot">Green = better on that metric.</div>}
</div>
)
}
function MetricHeader({ modelA, modelB }: { modelA: string; modelB: string }) {
return <div className="cmp-metric-head"><span>Metric</span><span>{modelA}</span><span>{modelB}</span></div>
}
function CategoryCard({ report }: { report: CompareJsonReport }) {
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>Category head-to-head</h3><span className="cmp-head-note">One-shot rate · edit turns</span></div>
<div className="cmp-category-body">
<div className="cmp-legend">
<span className="cmp-legend-item"><span className="cmp-key" />{report.modelA.model}</span>
<span className="cmp-legend-item"><span className="cmp-key cmp-key-b" />{report.modelB.model}</span>
</div>
<div className="cmp-categories">
{report.categories.map(category => (
<div className="cmp-category" key={category.category}>
<span className="cmp-category-name">{category.category}</span>
<div className="cmp-bars">
<div className="cmp-bar-row">
<span className="cmp-track"><span className="cmp-bar" style={{ width: `${category.oneShotRateA ?? 0}%` }} /></span>
<span className={`cmp-bar-value${category.winner === 'a' ? ' cmp-best' : ''}`}>{fmtMetric(category.oneShotRateA, 'percent')} <span className="cmp-turns">({category.editTurnsA})</span></span>
</div>
<div className="cmp-bar-row">
<span className="cmp-track"><span className="cmp-bar cmp-bar-b" style={{ width: `${category.oneShotRateB ?? 0}%` }} /></span>
<span className={`cmp-bar-value${category.winner === 'b' ? ' cmp-best' : ''}`}>{fmtMetric(category.oneShotRateB, 'percent')} <span className="cmp-turns">({category.editTurnsB})</span></span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
function cacheHitRate(model: ModelStats): string {
// reads over reads + fresh input (matches menubar-json + compare-stats).
const total = model.inputTokens + model.cacheReadTokens
return total > 0 ? `${Math.round(model.cacheReadTokens / total * 100)}%` : '—'
}
function daysOfData(model: ModelStats): string {
if (!model.firstSeen || !model.lastSeen) return '—'
return String(Math.max(1, Math.round((new Date(model.lastSeen).getTime() - new Date(model.firstSeen).getTime()) / 86_400_000) + 1))
}
function ContextCard({ modelA, modelB }: { modelA: ModelStats; modelB: ModelStats }) {
const rows = [
['Calls', modelA.calls.toLocaleString(), modelB.calls.toLocaleString()],
['Total cost', formatUsd(modelA.cost), formatUsd(modelB.cost)],
['Input tokens', formatCompact(modelA.inputTokens), formatCompact(modelB.inputTokens)],
['Output tokens', formatCompact(modelA.outputTokens), formatCompact(modelB.outputTokens)],
['Edit turns', modelA.editTurns.toLocaleString(), modelB.editTurns.toLocaleString()],
['Self-corrections', modelA.selfCorrections.toLocaleString(), modelB.selfCorrections.toLocaleString()],
['Cache hit rate', cacheHitRate(modelA), cacheHitRate(modelB)],
['Days of data', daysOfData(modelA), daysOfData(modelB)],
]
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>Context</h3></div>
<div className="cmp-metrics">
<MetricHeader modelA={modelA.model} modelB={modelB.model} />
{rows.map(([label, valueA, valueB]) => (
<div className="cmp-metric" key={label}>
<span className="cmp-label">{label}</span><span className="cmp-value">{valueA}</span><span className="cmp-value">{valueB}</span>
</div>
))}
</div>
</div>
)
}