feat(desktop): add findings tips, collapsible activity, loading overlay, empty state, star banner

This commit is contained in:
AgentSeal 2026-04-29 15:05:39 -07:00 committed by iamtoruk
parent 026795cd34
commit 864a7e29a1
8 changed files with 568 additions and 99 deletions

View file

@ -15,6 +15,11 @@ import { TrendInsight } from './components/TrendInsight'
import { ForecastInsight } from './components/ForecastInsight'
import { PulseInsight } from './components/PulseInsight'
import { StatsInsight } from './components/StatsInsight'
import { FindingsSection } from './components/FindingsSection'
import { ActivitySection } from './components/ActivitySection'
import { LoadingOverlay } from './components/LoadingOverlay'
import { EmptyProviderState } from './components/EmptyProviderState'
import { StarBanner } from './components/StarBanner'
const payloadCache = new PayloadCache<MenubarPayload>()
@ -28,6 +33,10 @@ const PERIODS: Array<{ id: Period; label: string }> = [
{ id: 'all', label: 'All' },
]
const PERIOD_LABELS: Record<Period, string> = {
today: 'Today', week: '7 Days', '30days': '30 Days', month: 'Month', all: 'All',
}
const REFRESH_INTERVAL_MS = 60_000
export function App() {
@ -75,14 +84,12 @@ export function App() {
}
}, [period, provider, currency])
// Initial + interval refresh
useEffect(() => {
refresh(true)
const id = setInterval(() => refresh(false), REFRESH_INTERVAL_MS)
return () => clearInterval(id)
}, [refresh])
// Tray menu "Refresh" event
useEffect(() => {
const unlisten = listen('codeburn://refresh', () => refresh(true))
return () => { unlisten.then(fn => fn()) }
@ -101,9 +108,7 @@ export function App() {
invoke('open_terminal_command', { args: ['report'] }).catch(console.error)
}
const openOptimize = () => {
invoke('open_terminal_command', { args: ['optimize'] }).catch(console.error)
}
const isFilteredEmpty = provider !== 'all' && payload.current.cost <= 0 && payload.current.calls === 0
return (
<div className="popover">
@ -122,104 +127,90 @@ export function App() {
currency={currency}
/>
<section className="hero">
<div className="hero-label">
<span className="hero-dot" /> {payload.current.label}
</div>
<div className="hero-amount">
{formatCurrency(payload.current.cost, currency)}
</div>
<div className="hero-meta">
<span>{payload.current.calls.toLocaleString()} calls</span>
<span>{payload.current.sessions} sessions</span>
</div>
</section>
<nav className="period-tabs">
{PERIODS.map(p => (
<button
key={p.id}
className={`period ${period === p.id ? 'period-active' : ''}`}
onClick={() => setPeriod(p.id)}
>
{p.label}
</button>
))}
</nav>
<div className="insight-area">
<InsightPills
selected={insight}
onSelect={setInsight}
modes={['trend', 'forecast', 'pulse', 'stats']}
/>
{insight === 'trend' && (
<TrendInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'forecast' && (
<ForecastInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'pulse' && (
<PulseInsight payload={payload} currency={currency} />
)}
{insight === 'stats' && (
<StatsInsight payload={payload} currency={currency} />
)}
</div>
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
<section className="empty-state">
<h2 className="section-title">No session data yet</h2>
<p>
CodeBurn reads local session logs from your AI coding tools. It looks like
none of the supported tools have written any sessions on this machine yet.
</p>
<p>Supported sources:</p>
<ul>
<li><code>~/.claude/projects/</code> (Claude Code)</li>
<li><code>~/.codex/sessions/</code> (Codex CLI)</li>
<li>Cursor IDE local database</li>
<li>GitHub Copilot session events</li>
</ul>
<p>Run one of those tools for a session, then hit Refresh.</p>
<div className="main-content">
<section className="hero">
<div className="hero-label">
<span className="hero-dot" /> {payload.current.label}
</div>
<div className="hero-amount">
{formatCurrency(payload.current.cost, currency)}
</div>
<div className="hero-meta">
<span>{payload.current.calls.toLocaleString()} calls</span>
<span>{payload.current.sessions} sessions</span>
</div>
</section>
) : (
<section className="activity">
<h2 className="section-title">Activity</h2>
{payload.current.topActivities.length === 0 && (
<p className="empty">No activity for this period.</p>
)}
{payload.current.topActivities.map(a => (
<div key={a.name} className="row">
<div className="row-label">{a.name}</div>
<div className="row-cost">{formatCompactCurrency(a.cost, currency)}</div>
<div className="row-turns">{a.turns}</div>
<div className="row-oneshot">
{a.oneShotRate == null ? '—' : `${Math.round(a.oneShotRate * 100)}%`}
</div>
</div>
<nav className="period-tabs">
{PERIODS.map(p => (
<button
key={p.id}
className={`period ${period === p.id ? 'period-active' : ''}`}
onClick={() => setPeriod(p.id)}
>
{p.label}
</button>
))}
</section>
)}
</nav>
<ModelsSection
models={payload.current.topModels}
inputTokens={payload.current.inputTokens}
outputTokens={payload.current.outputTokens}
cacheHitPercent={payload.current.cacheHitPercent}
currency={currency}
/>
{isFilteredEmpty ? (
<EmptyProviderState provider={provider} period={period} />
) : (
<>
<div className="insight-area">
<InsightPills
selected={insight}
onSelect={setInsight}
modes={['trend', 'forecast', 'pulse', 'stats']}
/>
{insight === 'trend' && (
<TrendInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'forecast' && (
<ForecastInsight days={payload.history.daily} currency={currency} />
)}
{insight === 'pulse' && (
<PulseInsight payload={payload} currency={currency} />
)}
{insight === 'stats' && (
<StatsInsight payload={payload} currency={currency} />
)}
</div>
{payload.optimize.findingCount > 0 && (
<section className="findings">
<button className="findings-cta" onClick={openOptimize}>
<span className="findings-count">{payload.optimize.findingCount} findings</span>
<span className="findings-savings">
save ~{formatCompactCurrency(payload.optimize.savingsUSD, currency)}
</span>
</button>
</section>
)}
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
<section className="empty-state">
<h2 className="section-title">No session data yet</h2>
<p>
CodeBurn reads local session logs from your AI coding tools. It looks like
none of the supported tools have written any sessions on this machine yet.
</p>
<p>Supported sources:</p>
<ul>
<li><code>~/.claude/projects/</code> (Claude Code)</li>
<li><code>~/.codex/sessions/</code> (Codex CLI)</li>
<li>Cursor IDE local database</li>
<li>GitHub Copilot session events</li>
</ul>
<p>Run one of those tools for a session, then hit Refresh.</p>
</section>
) : (
<ActivitySection payload={payload} currency={currency} />
)}
<ModelsSection
models={payload.current.topModels}
inputTokens={payload.current.inputTokens}
outputTokens={payload.current.outputTokens}
cacheHitPercent={payload.current.cacheHitPercent}
currency={currency}
/>
<FindingsSection payload={payload} />
</>
)}
{loading && <LoadingOverlay periodLabel={PERIOD_LABELS[period] ?? period} />}
</div>
<footer className="footer">
<select
@ -237,6 +228,8 @@ export function App() {
<button className="quit" onClick={() => invoke('quit_app').catch(console.error)} title="Quit CodeBurn">×</button>
</footer>
<StarBanner />
{error && <div className="error-toast">{error}</div>}
</div>
)

View file

@ -0,0 +1,55 @@
import { useState } from 'react'
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency } from '../lib/currency'
type Props = {
payload: MenubarPayload
currency: CurrencyState
}
export function ActivitySection({ payload, currency }: Props) {
const [expanded, setExpanded] = useState(true)
const activities = payload.current.topActivities
if (activities.length === 0) return null
const maxCost = Math.max(...activities.map(a => a.cost), 0.01)
return (
<section className="activity-section">
<button className="section-header" onClick={() => setExpanded(!expanded)}>
<div className="section-header-left">
<span className="section-dot" />
<span className="section-caption">Activity</span>
</div>
<div className="section-header-right">
<span className="col-header">Cost</span>
<span className="col-header col-header-sm">Turns</span>
<span className="col-header col-header-sm">1-shot</span>
<span className={`chevron ${expanded ? 'chevron-open' : ''}`}></span>
</div>
</button>
{expanded && (
<div className="section-body">
{activities.map(a => (
<div key={a.name} className="activity-row">
<div className="row-bar-container">
<div
className="row-bar-fill"
style={{ width: `${Math.max(2, (a.cost / maxCost) * 100)}%` }}
/>
</div>
<div className="row-label">{a.name}</div>
<div className="row-cost">{formatCompactCurrency(a.cost, currency)}</div>
<div className="row-turns">{a.turns}</div>
<div className="row-oneshot">
{a.oneShotRate == null ? '—' : `${Math.round(a.oneShotRate * 100)}%`}
</div>
</div>
))}
</div>
)}
</section>
)
}

View file

@ -0,0 +1,33 @@
type Props = {
provider: string
period: string
}
const PERIOD_PHRASES: Record<string, string> = {
today: 'today',
week: 'the last 7 days',
'30days': 'the last 30 days',
month: 'this month',
all: 'all time',
}
const DISPLAY_NAMES: Record<string, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
copilot: 'Copilot',
opencode: 'OpenCode',
pi: 'Pi',
}
export function EmptyProviderState({ provider, period }: Props) {
const name = DISPLAY_NAMES[provider] ?? provider
const phrase = PERIOD_PHRASES[period] ?? period
return (
<div className="empty-provider">
<div className="empty-provider-icon">📭</div>
<div className="empty-provider-text">No {name} data for {phrase}</div>
</div>
)
}

View file

@ -0,0 +1,64 @@
import { useState } from 'react'
import type { MenubarPayload } from '../lib/payload'
import { computeTipGroups, type TipGroup } from '../lib/tips'
import { invoke } from '@tauri-apps/api/core'
type Props = {
payload: MenubarPayload
}
export function FindingsSection({ payload }: Props) {
const [expanded, setExpanded] = useState(true)
const groups = computeTipGroups(payload)
const totalSignals = groups.reduce((s, g) => s + g.items.length, 0)
if (totalSignals === 0) return null
return (
<section className="findings-section">
<button className="findings-header" onClick={() => setExpanded(!expanded)}>
<div className="findings-header-left">
<span className="findings-icon">💡</span>
<span className="findings-title">Tips for you</span>
</div>
<div className="findings-header-right">
<span className="findings-count">{totalSignals} signals</span>
<span className={`chevron ${expanded ? 'chevron-open' : ''}`}></span>
</div>
</button>
{expanded && (
<div className="findings-body">
{groups.map(g => g.items.length > 0 && (
<TipsGroupView key={g.label} group={g} />
))}
{payload.optimize.findingCount > 0 && (
<button
className="findings-open-optimize"
onClick={() => invoke('open_terminal_command', { args: ['optimize'] }).catch(() => {})}
>
Open Full Optimize
</button>
)}
</div>
)}
</section>
)
}
function TipsGroupView({ group }: { group: TipGroup }) {
return (
<div className="tips-group">
<div className="tips-group-header">
<span className="tips-group-icon">{group.icon}</span>
<span className="tips-group-label">{group.label}</span>
</div>
{group.items.map((item, i) => (
<div key={i} className="tips-item">
<span className="tips-bullet" />
<span className="tips-text">{item.text}</span>
{item.trailing && <span className="tips-trailing">{item.trailing}</span>}
</div>
))}
</div>
)
}

View file

@ -0,0 +1,15 @@
type Props = { periodLabel: string }
export function LoadingOverlay({ periodLabel }: Props) {
return (
<div className="loading-overlay">
<div className="loading-content">
<div className="loading-flame">
<div className="flame-outline">🔥</div>
<div className="flame-fill">🔥</div>
</div>
<div className="loading-text">Loading {periodLabel}</div>
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
import { useState } from 'react'
const STORAGE_KEY = 'codeburn.starBannerDismissed'
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
export function StarBanner() {
const [dismissed, setDismissed] = useState(() => {
return localStorage.getItem(STORAGE_KEY) === 'true'
})
if (dismissed) return null
const dismiss = () => {
localStorage.setItem(STORAGE_KEY, 'true')
setDismissed(true)
}
return (
<div className="star-banner">
<span className="star-banner-icon"></span>
<a
className="star-banner-link"
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
>
<span>Enjoying CodeBurn?</span>{' '}
<span className="star-banner-cta">Star us on GitHub</span>
</a>
<span style={{ flex: 1 }} />
<button className="star-banner-close" onClick={dismiss} title="Hide this banner">
×
</button>
</div>
)
}

70
desktop/src/lib/tips.ts Normal file
View file

@ -0,0 +1,70 @@
import type { MenubarPayload, DailyEntry } from './payload'
import { formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth, dayOfMonth } from './dates'
export type TipItem = { text: string; trailing: string | null }
export type TipGroup = { label: string; icon: string; items: TipItem[] }
function historyStats(history: DailyEntry[]) {
const now = new Date()
const today = startOfDay(now)
const costByDate = new Map(history.map(d => [d.date, d.cost]))
const lastWeekStart = formatDateKey(addDays(today, -6))
const priorWeekStart = formatDateKey(addDays(today, -13))
const priorWeekEnd = formatDateKey(addDays(today, -7))
const thisWeek = history.filter(d => d.date >= lastWeekStart).reduce((s, d) => s + d.cost, 0)
const prior = history.filter(d => d.date >= priorWeekStart && d.date <= priorWeekEnd).reduce((s, d) => s + d.cost, 0)
const weekDelta = prior > 0 ? ((thisWeek - prior) / prior) * 100 : null
let streak = 0
for (let i = 0; i < 60; i++) {
const key = formatDateKey(addDays(today, -i))
if ((costByDate.get(key) ?? 0) > 0) streak++
else break
}
const fom = firstOfMonth(now)
const fomStr = formatDateKey(fom)
const mtd = history.filter(d => d.date >= fomStr).reduce((s, d) => s + d.cost, 0)
const dom = dayOfMonth(now)
const projected = dom > 0 ? (mtd / dom) * daysInMonth(now) : null
const prevMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
const prevFirstStr = formatDateKey(prevMonth)
const prevLastStr = formatDateKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)))
const prevEntries = history.filter(d => d.date >= prevFirstStr && d.date <= prevLastStr)
const prevTotal = prevEntries.length > 0 ? prevEntries.reduce((s, d) => s + d.cost, 0) : null
return { weekDelta, streak, projected, prevTotal }
}
export function computeTipGroups(payload: MenubarPayload): TipGroup[] {
const stats = historyStats(payload.history.daily)
const { cacheHitPercent, oneShotRate } = payload.current
const wins: TipItem[] = []
if (cacheHitPercent >= 80) wins.push({ text: `Cache hit at ${Math.round(cacheHitPercent)}% — most prompts reuse cache`, trailing: null })
if (oneShotRate != null && oneShotRate >= 0.75) wins.push({ text: `${Math.round(oneShotRate * 100)}% one-shot — edits landing first try`, trailing: null })
if (stats.weekDelta != null && stats.weekDelta < -10) wins.push({ text: `Spend down ${Math.round(Math.abs(stats.weekDelta))}% vs last 7 days`, trailing: null })
if (stats.streak >= 5) wins.push({ text: `${stats.streak}-day usage streak`, trailing: null })
const improvements: TipItem[] = payload.optimize.topFindings.slice(0, 3).map(f => ({
text: f.title,
trailing: `$${f.savingsUSD.toFixed(2)}`,
}))
const risks: TipItem[] = []
if (stats.weekDelta != null && stats.weekDelta > 25) risks.push({ text: `Spend up ${Math.round(stats.weekDelta)}% vs prior 7 days`, trailing: null })
if (cacheHitPercent > 0 && cacheHitPercent < 50) risks.push({ text: `Cache hit only ${Math.round(cacheHitPercent)}% — paying for cold prompts`, trailing: null })
if (oneShotRate != null && oneShotRate < 0.5) risks.push({ text: `${Math.round(oneShotRate * 100)}% one-shot — lots of iteration`, trailing: null })
if (stats.projected != null && stats.prevTotal != null && stats.projected > stats.prevTotal * 1.3) {
const pct = Math.round(((stats.projected - stats.prevTotal) / stats.prevTotal) * 100)
risks.push({ text: `On pace for $${stats.projected.toFixed(2)} this month (+${pct}% vs last)`, trailing: null })
}
return [
{ label: "What's working", icon: '✓', items: wins },
{ label: 'What to improve', icon: '↗', items: improvements },
{ label: 'Risks', icon: '⚠', items: risks },
]
}

View file

@ -588,3 +588,206 @@ html, body, #root {
font-variant-numeric: tabular-nums;
color: var(--brand-accent);
}
/* ---- activity section ---- */
.activity-section { padding: var(--spacing-sm) var(--spacing-lg); }
.activity-row {
display: grid;
grid-template-columns: 56px 1fr auto 40px 40px;
align-items: center;
gap: var(--spacing-md);
padding: 3px 0;
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.col-header-sm { min-width: 36px; }
/* ---- findings section ---- */
.findings-section {
margin: var(--spacing-sm) var(--spacing-lg);
padding: 12px;
background: rgba(0, 0, 0, 0.04);
border-radius: 8px;
}
.findings-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
color: var(--text-primary);
padding: 0;
}
.findings-header-left {
display: flex;
align-items: center;
gap: 6px;
}
.findings-icon { font-size: 11px; }
.findings-title {
font-size: 12.5px;
font-weight: 600;
}
.findings-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.findings-count {
font-size: 10.5px;
color: var(--text-secondary);
}
.findings-body { margin-top: 10px; }
.tips-group { margin-bottom: 10px; }
.tips-group-header {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--brand-accent);
margin-bottom: 5px;
}
.tips-group-icon { font-size: 10px; }
.tips-item {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11.5px;
padding: 2px 0;
color: var(--text-primary);
}
.tips-bullet {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--brand-accent);
flex-shrink: 0;
margin-top: 6px;
}
.tips-text { flex: 1; }
.tips-trailing {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.findings-open-optimize {
border: 0;
background: transparent;
color: var(--brand-accent);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
padding: 4px 0 0;
}
/* ---- loading overlay ---- */
.loading-overlay {
position: absolute;
inset: 0;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(250, 247, 243, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
animation: fadeIn 0.2s ease-in-out;
}
@media (prefers-color-scheme: dark) {
.loading-overlay { background: rgba(28, 24, 22, 0.6); }
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.loading-flame {
position: relative;
width: 64px;
height: 64px;
font-size: 48px;
line-height: 64px;
text-align: center;
}
.flame-outline {
opacity: 0.25;
}
.flame-fill {
position: absolute;
inset: 0;
overflow: hidden;
animation: flameFill 1.4s ease-in-out infinite alternate;
}
@keyframes flameFill {
from { clip-path: inset(100% 0 0 0); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.loading-text {
font-size: 11.5px;
font-weight: 500;
color: var(--text-secondary);
}
.main-content {
position: relative;
flex: 1;
overflow-y: auto;
}
/* ---- empty provider ---- */
.empty-provider {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 0;
gap: 10px;
}
.empty-provider-icon {
font-size: 26px;
opacity: 0.4;
}
.empty-provider-text {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-align: center;
}
/* ---- star banner ---- */
.star-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(201, 82, 29, 0.08);
border-top: 0.5px solid rgba(128, 128, 128, 0.18);
}
.star-banner-icon { font-size: 10px; }
.star-banner-link {
font-size: 10.5px;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
cursor: pointer;
}
.star-banner-cta {
color: var(--brand-accent);
text-decoration: underline;
}
.star-banner-close {
border: 0;
background: transparent;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
}