mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 16:33:14 +00:00
feat(app): actionable Optimize — wire the CLI's rich findings + copy-paste fixes
- new getOptimizeReport bridge (main + preload + renderer types) running `codeburn optimize --format json` (needs a full relaunch) - restore the Waste / Reverts / Abandoned / Fixes tabs - Waste findings expand inline to show the explanation (why you're losing tokens) + a copy-paste fix block with a Copy button, like the TUI - summary header: findings count / potential savings / health score typecheck clean; 133/133 tests pass.
This commit is contained in:
parent
82f0402abd
commit
905d94253a
9 changed files with 324 additions and 113 deletions
|
|
@ -39,6 +39,7 @@ const CHANNELS = [
|
|||
'codeburn:getCompare',
|
||||
'codeburn:getYield',
|
||||
'codeburn:getSpendFlow',
|
||||
'codeburn:getOptimizeReport',
|
||||
'codeburn:getDevices',
|
||||
'codeburn:getDevicesScan',
|
||||
'codeburn:getShareStatus',
|
||||
|
|
@ -69,10 +70,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
|
|||
{ channel: 'codeburn:getCompare', args: ['month', 'all', 'model-a', 'model-b'], argv: ['compare', '--format', 'json', '--period', 'month', '--model-a', 'model-a', '--model-b', 'model-b'] },
|
||||
{ channel: 'codeburn:getYield', args: ['today'], argv: ['yield', '--format', 'json', '--period', 'today'] },
|
||||
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
|
||||
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
|
||||
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getYield', args: ['today', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['optimize', '--format', 'json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
|
||||
{ channel: 'codeburn:getDevices', args: ['week'], argv: ['devices', '--format', 'json', '--period', 'week'] },
|
||||
{ channel: 'codeburn:getDevicesScan', args: [], argv: ['devices', 'scan', '--format', 'json'] },
|
||||
{ channel: 'codeburn:getShareStatus', args: [], argv: ['share', 'status', '--format', 'json'] },
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
|
|||
'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [
|
||||
'spend', '--format', 'flow-json', '--period', period, ...providerArgs(provider), ...rangeArgs(range),
|
||||
]),
|
||||
'codeburn:getOptimizeReport': run((period: string, provider: string, range?: DateRange) => [
|
||||
'optimize', '--format', 'json', '--period', period, ...providerArgs(provider), ...rangeArgs(range),
|
||||
]),
|
||||
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', period]),
|
||||
'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),
|
||||
'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const bridge = {
|
|||
getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB),
|
||||
getYield: (period: string, range?: DateRange) => invoke('codeburn:getYield', period, range),
|
||||
getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range),
|
||||
getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range),
|
||||
getDevices: (period: string) => invoke('codeburn:getDevices', period),
|
||||
getDevicesScan: () => invoke('codeburn:getDevicesScan'),
|
||||
getShareStatus: () => invoke('codeburn:getShareStatus'),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App } from './App'
|
||||
import type { DateRange, MenubarPayload, SpendFlow } from './lib/types'
|
||||
import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types'
|
||||
|
||||
const stored = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
|
|
@ -15,6 +15,7 @@ vi.stubGlobal('localStorage', {
|
|||
const mocks = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<MenubarPayload>>(),
|
||||
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
|
||||
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
|
||||
getModels: vi.fn(),
|
||||
getSessions: vi.fn(),
|
||||
getCompareModels: vi.fn(),
|
||||
|
|
@ -91,6 +92,15 @@ describe('App shortcuts', () => {
|
|||
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([])
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function App() {
|
|||
) : section === 'spend' ? (
|
||||
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
|
||||
) : section === 'optimize' ? (
|
||||
<OptimizeContent period={period} range={customRange} overview={overview} refreshToken={refreshToken} />
|
||||
<OptimizeContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
|
||||
) : section === 'models' ? (
|
||||
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} />
|
||||
) : section === 'compare' ? (
|
||||
|
|
|
|||
|
|
@ -325,6 +325,39 @@ export type SpendFlow = {
|
|||
links: SpendFlowLink[]
|
||||
}
|
||||
|
||||
// ————— src/optimize.ts —————
|
||||
|
||||
export type WasteAction =
|
||||
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' }
|
||||
| { type: 'command'; label: string; text: string }
|
||||
| { type: 'file-content'; label: string; path: string; content: string }
|
||||
|
||||
export type OptimizeJsonReport = {
|
||||
period: { label: string; start: string | null; end: string | null }
|
||||
summary: {
|
||||
healthScore: number
|
||||
healthGrade: 'A' | 'B' | 'C' | 'D' | 'F'
|
||||
findingCount: number
|
||||
periodCostUSD: number
|
||||
sessions: number
|
||||
calls: number
|
||||
potentialSavingsTokens: number
|
||||
potentialSavingsCostUSD: number
|
||||
potentialSavingsPercent: number | null
|
||||
costRateUSD: number
|
||||
}
|
||||
findings: Array<{
|
||||
id: string
|
||||
title: string
|
||||
explanation: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
trend: 'active' | 'improving' | null
|
||||
tokensSaved: number
|
||||
estimatedSavingsUSD: number
|
||||
fix: WasteAction
|
||||
}>
|
||||
}
|
||||
|
||||
// ————— T1b: src/sharing/* (defined by the shared contract) —————
|
||||
|
||||
export type PendingPairing = { id: string; name: string; code: string }
|
||||
|
|
@ -445,6 +478,7 @@ export interface CodeburnBridge {
|
|||
getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise<CompareJsonReport>
|
||||
getYield(period: Period, range?: DateRange): Promise<YieldJsonReport>
|
||||
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
|
||||
getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise<OptimizeJsonReport>
|
||||
getDevices(period: Period): Promise<CombinedUsage>
|
||||
getDevicesScan(): Promise<DeviceScanResult>
|
||||
getShareStatus(): Promise<ShareStatus>
|
||||
|
|
|
|||
|
|
@ -1,47 +1,32 @@
|
|||
// @vitest-environment jsdom
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MenubarPayload, YieldJsonReport } from '../lib/types'
|
||||
import type { MenubarPayload, OptimizeJsonReport, YieldJsonReport } from '../lib/types'
|
||||
import { Optimize, OptimizeContent } from './Optimize'
|
||||
|
||||
const { getOverview, getYield } = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
|
||||
getYield: vi.fn<(period: string) => Promise<YieldJsonReport>>(),
|
||||
const { getOverview, getOptimizeReport, getYield } = vi.hoisted(() => ({
|
||||
getOverview: vi.fn(),
|
||||
getOptimizeReport: vi.fn(),
|
||||
getYield: vi.fn(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
return { ...actual, codeburn: { getOverview, getYield } }
|
||||
return { ...actual, codeburn: { getOverview, getOptimizeReport, getYield } }
|
||||
})
|
||||
|
||||
function makePayload(): MenubarPayload {
|
||||
return {
|
||||
generated: '2026-07-10T19:00:00.000Z',
|
||||
current: {
|
||||
label: 'Last 30 days',
|
||||
cost: 612.48,
|
||||
calls: 1220,
|
||||
sessions: 88,
|
||||
oneShotRate: null,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheHitPercent: 0,
|
||||
codexCredits: 0,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
label: 'Last 30 days', cost: 612.48, calls: 1220, sessions: 88, oneShotRate: null,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
cacheHitPercent: 0, codexCredits: 0, topActivities: [], topModels: [],
|
||||
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
|
||||
providers: {},
|
||||
topProjects: [],
|
||||
modelEfficiency: [],
|
||||
topSessions: [],
|
||||
providers: {}, topProjects: [], modelEfficiency: [], topSessions: [],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [],
|
||||
skills: [],
|
||||
subagents: [],
|
||||
mcpServers: [],
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
optimize: {
|
||||
findingCount: 3,
|
||||
|
|
@ -56,6 +41,36 @@ function makePayload(): MenubarPayload {
|
|||
}
|
||||
}
|
||||
|
||||
function makeOptimizeReport(): OptimizeJsonReport {
|
||||
return {
|
||||
period: { label: 'Last 30 days', start: '2026-06-11', end: '2026-07-10' },
|
||||
summary: {
|
||||
healthScore: 72, healthGrade: 'C', findingCount: 3, periodCostUSD: 612.48,
|
||||
sessions: 88, calls: 1220, potentialSavingsTokens: 184_000,
|
||||
potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005,
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
id: 'cost-outliers', title: 'Opus is doing your small talk',
|
||||
explanation: 'Small conversational requests are running on an expensive model.',
|
||||
severity: 'high', trend: 'active', tokensSaved: 18_200, estimatedSavingsUSD: 9.1,
|
||||
fix: { type: 'paste', label: 'Paste into CLAUDE.md', text: 'Use Sonnet for routine questions.', destination: 'claude-md' },
|
||||
},
|
||||
{
|
||||
id: 'context-heavy-sessions', title: 'Cache hit is low in agentseal-dash',
|
||||
explanation: 'Repeated context is not being served from cache.', severity: 'medium',
|
||||
trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7,
|
||||
fix: { type: 'command', label: 'Run this command', text: 'codeburn cache inspect' },
|
||||
},
|
||||
{
|
||||
id: 'warmup-heavy', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.',
|
||||
severity: 'low', trend: 'improving', tokensSaved: 4_800, estimatedSavingsUSD: 2.4,
|
||||
fix: { type: 'file-content', label: 'Create configuration', path: '~/.codeburn/config.json', content: '{"batch":true}' },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeYield(): YieldJsonReport {
|
||||
return {
|
||||
period: { label: 'Last 30 days', start: '2026-06-11', end: '2026-07-10' },
|
||||
|
|
@ -63,8 +78,7 @@ function makeYield(): YieldJsonReport {
|
|||
productive: { costUSD: 440, sessions: 19, costPercent: 72, sessionPercent: 70 },
|
||||
reverted: { costUSD: 107, sessions: 4, costPercent: 17, sessionPercent: 15 },
|
||||
abandoned: { costUSD: 65.4, sessions: 3, costPercent: 11, sessionPercent: 15 },
|
||||
total: { costUSD: 612.4, sessions: 26 },
|
||||
productiveToRevertedCostRatio: 4.1,
|
||||
total: { costUSD: 612.4, sessions: 26 }, productiveToRevertedCostRatio: 4.1,
|
||||
},
|
||||
details: [
|
||||
{ sessionId: 'rev-1', project: 'codeburn', category: 'reverted', commitCount: 2, costUSD: 55 },
|
||||
|
|
@ -81,6 +95,13 @@ function emptyPayload(): MenubarPayload {
|
|||
return payload
|
||||
}
|
||||
|
||||
function emptyOptimizeReport(): OptimizeJsonReport {
|
||||
const report = makeOptimizeReport()
|
||||
report.summary = { ...report.summary, findingCount: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0, potentialSavingsPercent: 0 }
|
||||
report.findings = []
|
||||
return report
|
||||
}
|
||||
|
||||
function emptyYield(): YieldJsonReport {
|
||||
const report = makeYield()
|
||||
report.summary.reverted = { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }
|
||||
|
|
@ -90,94 +111,120 @@ function emptyYield(): YieldJsonReport {
|
|||
}
|
||||
|
||||
describe('Optimize', () => {
|
||||
const writeText = vi.fn<(text: string) => Promise<void>>()
|
||||
|
||||
beforeEach(() => {
|
||||
getOverview.mockReset()
|
||||
getYield.mockReset()
|
||||
getOverview.mockReset().mockResolvedValue(makePayload())
|
||||
getOptimizeReport.mockReset().mockResolvedValue(makeOptimizeReport())
|
||||
getYield.mockReset().mockResolvedValue(makeYield())
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
})
|
||||
|
||||
it('renders findings, reverted sessions, and abandoned sessions together with section totals', async () => {
|
||||
getOverview.mockResolvedValue(makePayload())
|
||||
getYield.mockResolvedValue(makeYield())
|
||||
|
||||
it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Opus is doing your small talk')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 findings · $94.40 potential · health 72/100')).toBeInTheDocument()
|
||||
expect(screen.getByText('High')).toHaveClass('opt-impact-high')
|
||||
expect(screen.getByText('Medium')).toHaveClass('opt-impact-medium')
|
||||
expect(screen.getByText('Low')).toHaveClass('opt-impact-low')
|
||||
expect(screen.getByText('$9.10')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cache hit is low in agentseal-dash')).toBeInTheDocument()
|
||||
expect(screen.getByText('Optimization findings · 3 findings · $94.40 potential')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reverted sessions · $107.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Abandoned sessions · $65.40')).toBeInTheDocument()
|
||||
expect(screen.getByText('$9.10')).toHaveClass('opt-finding-savings')
|
||||
expect(screen.getByText('18.2K tokens')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Waste $94.40' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reverts $107.00' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Abandoned $65.40' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fixes 3' })).toBeInTheDocument()
|
||||
|
||||
const row = screen.getByRole('button', { name: /Opus is doing your small talk/ })
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(row)
|
||||
expect(row).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Small conversational requests are running on an expensive model.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Use Sonnet for routine questions.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('Use Sonnet for routine questions.'))
|
||||
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps only one finding expanded and renders file-content path and content', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
const first = await screen.findByRole('button', { name: /Opus is doing your small talk/ })
|
||||
fireEvent.click(first)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Batch tiny requests/ }))
|
||||
|
||||
expect(first).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Small conversational requests are running on an expensive model.')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('~/.codeburn/config.json')).toBeInTheDocument()
|
||||
expect(screen.getByText('{"batch":true}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to Reverts and Abandoned and shows only the matching yield details', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reverts $107.00' }))
|
||||
expect(screen.getByText('codeburn')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 commits · rev-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('$55.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('agentseal-dash')).toBeInTheDocument()
|
||||
expect(screen.queryByText('sandbox-spike')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abandoned $65.40' }))
|
||||
expect(screen.getByText('sandbox-spike')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 commits · abn-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('$65.40')).toHaveClass('val')
|
||||
expect(screen.getByText('$65.40')).not.toHaveClass('ok')
|
||||
expect(screen.queryByText('codeburn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('desktop-app')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an honest placeholder for unavailable yield totals and list bodies', async () => {
|
||||
getOverview.mockResolvedValue(makePayload())
|
||||
getYield.mockRejectedValue(new Error('yield failed'))
|
||||
|
||||
it('renders honest placeholders for unavailable yield totals and tab bodies', async () => {
|
||||
getYield.mockReset().mockRejectedValue(new Error('yield failed'))
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Reverted sessions · —')).toBeInTheDocument()
|
||||
expect(screen.getByText('Abandoned sessions · —')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('—')).toHaveLength(2)
|
||||
expect(await screen.findByRole('button', { name: 'Reverts —' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Abandoned —' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reverts —' }))
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows one compact empty note and hides all empty sections', async () => {
|
||||
it('keeps the Fixes tab populated and preserves all four empty tab states', async () => {
|
||||
const { rerender } = render(<Optimize period="30days" provider="all" />)
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fixes 3' }))
|
||||
expect(screen.getByText('Opus is doing your small talk')).toBeInTheDocument()
|
||||
|
||||
getOverview.mockResolvedValue(emptyPayload())
|
||||
getOptimizeReport.mockResolvedValue(emptyOptimizeReport())
|
||||
getYield.mockResolvedValue(emptyYield())
|
||||
rerender(<Optimize period="week" provider="all" />)
|
||||
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Optimization findings ·/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Reverted sessions ·/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Abandoned sessions ·/)).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('No fixes in this range yet.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fixes 0' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Waste $0.00' }))
|
||||
expect(screen.getByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reverts $0.00' }))
|
||||
expect(screen.getByText('No reverted sessions in this range yet.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abandoned $0.00' }))
|
||||
expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides only yield sections whose category has no rows', async () => {
|
||||
const report = makeYield()
|
||||
report.summary.abandoned = { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }
|
||||
report.details = report.details.filter(row => row.category !== 'abandoned')
|
||||
getOverview.mockResolvedValue(makePayload())
|
||||
getYield.mockResolvedValue(report)
|
||||
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Reverted sessions · $107.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Abandoned sessions ·/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Optimization findings · 3 findings · $94.40 potential')).toBeInTheDocument()
|
||||
it('passes provider and custom range to the optimize report bridge', async () => {
|
||||
render(<Optimize period="30days" provider="claude" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
expect(getOptimizeReport).toHaveBeenCalledWith('30days', 'claude', { from: '2026-07-01', to: '2026-07-11' })
|
||||
})
|
||||
|
||||
it('keeps last-good yield totals and rows visible during revalidation', async () => {
|
||||
getYield.mockResolvedValueOnce(makeYield()).mockImplementation(() => new Promise<YieldJsonReport>(() => {}))
|
||||
const overview = {
|
||||
data: makePayload(),
|
||||
error: null,
|
||||
loading: false,
|
||||
lastSuccessAt: Date.now(),
|
||||
refresh: vi.fn(),
|
||||
}
|
||||
|
||||
getYield.mockReset().mockResolvedValueOnce(makeYield()).mockImplementation(() => new Promise<YieldJsonReport>(() => {}))
|
||||
const overview = { data: makePayload(), error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() }
|
||||
const { rerender } = render(<OptimizeContent period="30days" overview={overview} refreshToken={0} />)
|
||||
|
||||
expect(await screen.findByText('Reverted sessions · $107.00')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Reverts $107.00' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reverts $107.00' }))
|
||||
expect(screen.getByText('codeburn')).toBeInTheDocument()
|
||||
|
||||
rerender(<OptimizeContent period="30days" overview={overview} refreshToken={1} />)
|
||||
await waitFor(() => expect(getYield).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(screen.getByText('Reverted sessions · $107.00')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reverts $107.00' })).toBeInTheDocument()
|
||||
expect(screen.getByText('codeburn')).toBeInTheDocument()
|
||||
expect(screen.queryByText('—')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { Fragment, useState } from 'react'
|
||||
|
||||
import { CliErrorPanel } from '../components/CliErrorPanel'
|
||||
import { Panel } from '../components/Panel'
|
||||
import { SegTabs } from '../components/SegTabs'
|
||||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatUsd } from '../lib/format'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { DateRange, MenubarPayload, Period, SessionYieldJson, YieldJsonReport } from '../lib/types'
|
||||
import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
|
||||
|
||||
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
|
||||
|
||||
function EmptyNote({ children }: { children: React.ReactNode }) {
|
||||
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
|
||||
|
|
@ -14,24 +19,31 @@ export function Optimize({ period, provider, range = null }: { period: Period; p
|
|||
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
|
||||
[period, provider, range?.from, range?.to],
|
||||
)
|
||||
return <OptimizeContent period={period} range={range} overview={overview} />
|
||||
return <OptimizeContent period={period} provider={provider} range={range} overview={overview} />
|
||||
}
|
||||
|
||||
export function OptimizeContent({
|
||||
period,
|
||||
provider = 'all',
|
||||
range = null,
|
||||
overview,
|
||||
refreshToken = 0,
|
||||
}: {
|
||||
period: Period
|
||||
provider?: string
|
||||
range?: DateRange | null
|
||||
overview: Polled<MenubarPayload>
|
||||
refreshToken?: number
|
||||
}) {
|
||||
const optimizeReport = usePolled<OptimizeJsonReport>(
|
||||
() => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider),
|
||||
[period, provider, range?.from, range?.to, refreshToken],
|
||||
)
|
||||
const yieldReport = usePolled<YieldJsonReport>(
|
||||
() => range ? codeburn.getYield(period, range) : codeburn.getYield(period),
|
||||
[period, range?.from, range?.to, refreshToken],
|
||||
)
|
||||
const [tab, setTab] = useState<OptimizeTab>('waste')
|
||||
|
||||
if (!overview.data) {
|
||||
if (overview.error) return <CliErrorPanel error={overview.error} subject="optimize findings" />
|
||||
|
|
@ -45,52 +57,130 @@ export function OptimizeContent({
|
|||
const yieldData = yieldReport.error ? null : yieldReport.data
|
||||
const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—'
|
||||
const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—'
|
||||
const revertedRows = !yieldData || yieldData.details.some(row => row.category === 'reverted')
|
||||
const abandonedRows = !yieldData || yieldData.details.some(row => row.category === 'abandoned')
|
||||
const hasFindings = overview.data.optimize.topFindings.length > 0
|
||||
const isEmpty = !hasFindings && !revertedRows && !abandonedRows
|
||||
const options = [
|
||||
{ value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` },
|
||||
{ value: 'reverts', label: `Reverts ${revertedTotal}` },
|
||||
{ value: 'abandoned', label: `Abandoned ${abandonedTotal}` },
|
||||
{ value: 'fixes', label: `Fixes ${overview.data.optimize.findingCount.toLocaleString('en-US')}` },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasFindings && (
|
||||
<Panel title={`Optimization findings · ${overview.data.optimize.findingCount.toLocaleString('en-US')} findings · ${formatUsd(overview.data.optimize.savingsUSD)} potential`}>
|
||||
<WasteRows data={overview.data} />
|
||||
</Panel>
|
||||
)}
|
||||
{revertedRows && (
|
||||
<Panel title={`Reverted sessions · ${revertedTotal}`}>
|
||||
<SegTabs
|
||||
options={options}
|
||||
value={tab}
|
||||
onChange={value => setTab(value as OptimizeTab)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
<Panel>
|
||||
{tab === 'waste' ? (
|
||||
<WasteRows report={optimizeReport} />
|
||||
) : tab === 'reverts' ? (
|
||||
<YieldRows report={yieldReport} category="reverted" empty="No reverted sessions in this range yet." />
|
||||
</Panel>
|
||||
)}
|
||||
{abandonedRows && (
|
||||
<Panel title={`Abandoned sessions · ${abandonedTotal}`}>
|
||||
) : tab === 'abandoned' ? (
|
||||
<YieldRows report={yieldReport} category="abandoned" empty="No abandoned sessions in this range yet." />
|
||||
</Panel>
|
||||
)}
|
||||
{isEmpty && <EmptyNote>No waste findings in this range yet.</EmptyNote>}
|
||||
) : (
|
||||
<FixesRows data={overview.data} />
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WasteRows({ data }: { data: MenubarPayload }) {
|
||||
return <FindingRows findings={data.optimize.topFindings} empty="No waste findings in this range yet." />
|
||||
function WasteRows({ report }: { report: Polled<OptimizeJsonReport> }) {
|
||||
if (!report.data) {
|
||||
if (report.error) return <CliErrorPanel error={report.error} subject="optimize findings" />
|
||||
return <EmptyNote>Scanning optimize findings…</EmptyNote>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="opt-waste">
|
||||
<div className="opt-summary">
|
||||
{report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100
|
||||
</div>
|
||||
<ActionableFindingRows findings={report.data.findings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Finding = MenubarPayload['optimize']['topFindings'][number]
|
||||
type OptimizeFinding = OptimizeJsonReport['findings'][number]
|
||||
|
||||
const IMPACT_ICON: Record<Finding['impact'], string> = {
|
||||
const IMPACT_ICON: Record<'high' | 'medium' | 'low', string> = {
|
||||
high: '↑',
|
||||
medium: '→',
|
||||
low: '↓',
|
||||
}
|
||||
|
||||
function actionText(fix: WasteAction): string {
|
||||
return fix.type === 'file-content' ? fix.content : fix.text
|
||||
}
|
||||
|
||||
function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
if (!findings.length) return <EmptyNote>No waste findings in this range yet.</EmptyNote>
|
||||
|
||||
const copyFix = async (finding: OptimizeFinding) => {
|
||||
await navigator.clipboard.writeText(actionText(finding.fix))
|
||||
setCopiedId(finding.id)
|
||||
window.setTimeout(() => setCopiedId(current => current === finding.id ? null : current), 1_500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="opt-findings">
|
||||
{findings.map(finding => {
|
||||
const expanded = expandedId === finding.id
|
||||
return (
|
||||
<Fragment key={finding.id}>
|
||||
<button
|
||||
className="opt-finding opt-finding-toggle"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedId(current => current === finding.id ? null : finding.id)}
|
||||
>
|
||||
<span className={`opt-impact opt-impact-${finding.severity}`}>
|
||||
<span aria-hidden="true">{IMPACT_ICON[finding.severity]}</span>
|
||||
{finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1)}
|
||||
</span>
|
||||
<b className="opt-finding-title">{finding.title}</b>
|
||||
<span className="opt-finding-savings">{formatUsd(finding.estimatedSavingsUSD)}</span>
|
||||
<span className="opt-finding-tokens">{formatCompact(finding.tokensSaved)} tokens</span>
|
||||
<span className="opt-finding-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="opt-finding-detail" role="region" aria-label={`${finding.title} details`}>
|
||||
<p className="opt-explanation">{finding.explanation}</p>
|
||||
<div className={`opt-fix opt-fix-${finding.fix.type}`}>
|
||||
<div className="opt-fix-head">
|
||||
<div>
|
||||
<b>{finding.fix.label}</b>
|
||||
{finding.fix.type === 'file-content' && <span className="opt-fix-path">{finding.fix.path}</span>}
|
||||
</div>
|
||||
<button className="opt-copy" type="button" onClick={() => void copyFix(finding)}>
|
||||
{copiedId === finding.id ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="opt-fix-code"><code>{actionText(finding.fix)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Finding = MenubarPayload['optimize']['topFindings'][number]
|
||||
|
||||
function FindingRows({ findings, empty }: { findings: Finding[]; empty: string }) {
|
||||
if (!findings.length) return <EmptyNote>{empty}</EmptyNote>
|
||||
|
||||
return (
|
||||
<div className="opt-findings">
|
||||
{findings.map((finding, i) => (
|
||||
<div className="opt-finding" key={`${finding.title}-${i}`}>
|
||||
<div className="opt-finding opt-finding-legacy" key={`${finding.title}-${i}`}>
|
||||
<span className="opt-finding-rank">{String(i + 1).padStart(2, '0')}</span>
|
||||
<b className="opt-finding-title">{finding.title}</b>
|
||||
<span className={`opt-impact opt-impact-${finding.impact}`}>
|
||||
|
|
@ -135,3 +225,7 @@ function YieldRows({
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FixesRows({ data }: { data: MenubarPayload }) {
|
||||
return <FindingRows findings={data.optimize.topFindings} empty="No fixes in this range yet." />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -504,9 +504,15 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
|
|||
.ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; }
|
||||
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: 550; text-overflow: ellipsis; }
|
||||
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
|
||||
.opt-findings { display: grid; }
|
||||
.opt-finding { display: grid; grid-template-columns: 28px minmax(0, 1fr) 104px 86px; align-items: center; column-gap: 12px; min-height: 43px; border-top: 1px solid var(--line2); }
|
||||
.opt-waste { min-width: 0; }
|
||||
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.opt-findings { display: grid; min-width: 0; }
|
||||
.opt-finding { display: grid; align-items: center; column-gap: 12px; min-height: 43px; border-top: 1px solid var(--line2); }
|
||||
.opt-finding:first-child { border-top: 0; }
|
||||
.opt-finding-legacy { grid-template-columns: 28px minmax(0, 1fr) 104px 86px; }
|
||||
.opt-finding-toggle { width: 100%; grid-template-columns: 104px minmax(0, 1fr) 86px 92px 18px; padding: 0; border-right: 0; border-bottom: 0; border-left: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.opt-finding-toggle:hover, .opt-finding-toggle:focus-visible { background: var(--hover); outline: none; }
|
||||
.opt-finding-toggle:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.opt-finding-rank { color: var(--mut2); font-family: var(--mono); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.opt-finding-title { overflow: hidden; color: var(--ink); font-size: 12px; font-weight: 570; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.opt-impact { --impact: var(--mut2); display: inline-flex; width: fit-content; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 7px; background: color-mix(in srgb, var(--impact) 12%, transparent); color: var(--impact); font-size: 10px; font-weight: 650; line-height: 1; }
|
||||
|
|
@ -514,6 +520,19 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
|
|||
.opt-impact-medium { --impact: var(--warn); }
|
||||
.opt-impact-low { --impact: var(--mut2); }
|
||||
.opt-finding-savings { color: var(--ok); font-family: var(--mono); font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.opt-finding-tokens { color: var(--mut); font-family: var(--mono); font-size: 10.5px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.opt-finding-chevron { color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; }
|
||||
.opt-finding-toggle[aria-expanded="true"] .opt-finding-chevron { transform: rotate(90deg); }
|
||||
.opt-finding-detail { min-width: 0; padding: 12px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 84%, var(--hover)); }
|
||||
.opt-explanation { margin: 0 0 12px; color: var(--mut); font-size: 12px; line-height: 1.55; }
|
||||
.opt-fix { min-width: 0; }
|
||||
.opt-fix-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 7px; }
|
||||
.opt-fix-head b { display: block; color: var(--ink); font-size: 10.5px; font-weight: 620; }
|
||||
.opt-fix-path { display: block; margin-top: 3px; color: var(--mut2); font-family: var(--mono); font-size: 10px; }
|
||||
.opt-fix-code { max-width: 100%; overflow-x: auto; margin: 0; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--phead); color: var(--ink); font-family: var(--mono); font-size: 11px; line-height: 1.5; white-space: pre; }
|
||||
.opt-fix-command .opt-fix-code code::before { content: '$ '; color: var(--mut2); user-select: none; }
|
||||
.opt-copy { flex: 0 0 auto; padding: 4px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--mut); font: inherit; font-size: 10.5px; cursor: pointer; }
|
||||
.opt-copy:hover, .opt-copy:focus-visible { border-color: var(--accent); color: var(--ink); outline: none; }
|
||||
.ov-analytics-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: stretch; }
|
||||
.ov-analytics-row > :only-child { grid-column: 1 / -1; }
|
||||
.ov-body-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue