From 905d94253ab9a02147b87543a35b0a59fbb193d5 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 12 Jul 2026 16:28:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20actionable=20Optimize=20=E2=80=94?= =?UTF-8?q?=20wire=20the=20CLI's=20rich=20findings=20+=20copy-paste=20fixe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/electron/main.test.ts | 3 + app/electron/main.ts | 3 + app/electron/preload.ts | 1 + app/renderer/App.test.tsx | 12 +- app/renderer/App.tsx | 2 +- app/renderer/lib/types.ts | 34 ++++ app/renderer/sections/Optimize.test.tsx | 213 +++++++++++++++--------- app/renderer/sections/Optimize.tsx | 146 +++++++++++++--- app/renderer/styles/plain.css | 23 ++- 9 files changed, 324 insertions(+), 113 deletions(-) diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 88cd1352..f0e03a87 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -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'] }, diff --git a/app/electron/main.ts b/app/electron/main.ts index 43a41747..ba0fedee 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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']), diff --git a/app/electron/preload.ts b/app/electron/preload.ts index e835a867..16e0b6be 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -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'), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 386717ae..78de7ac9 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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() vi.stubGlobal('localStorage', { @@ -15,6 +15,7 @@ vi.stubGlobal('localStorage', { const mocks = vi.hoisted(() => ({ getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), 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([]) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 5f1c08b8..ed44cb78 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -179,7 +179,7 @@ export function App() { ) : section === 'spend' ? ( ) : section === 'optimize' ? ( - + ) : section === 'models' ? ( ) : section === 'compare' ? ( diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 3b718602..b61eed2c 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 getYield(period: Period, range?: DateRange): Promise getSpendFlow(period: Period, provider: string, range?: DateRange): Promise + getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise getDevices(period: Period): Promise getDevicesScan(): Promise getShareStatus(): Promise diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index c2bc8a91..5a176b24 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -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>(), - getYield: vi.fn<(period: string) => Promise>(), +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() - 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>() + 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() 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() + 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() + 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() - 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() + 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() - render() - - 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() - - 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() + 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(() => {})) - const overview = { - data: makePayload(), - error: null, - loading: false, - lastSuccessAt: Date.now(), - refresh: vi.fn(), - } - + getYield.mockReset().mockResolvedValueOnce(makeYield()).mockImplementation(() => new Promise(() => {})) + const overview = { data: makePayload(), error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() } const { rerender } = render() - 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() 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() }) }) diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 273862ec..8ac09ab7 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -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

{children}

@@ -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 + return } export function OptimizeContent({ period, + provider = 'all', range = null, overview, refreshToken = 0, }: { period: Period + provider?: string range?: DateRange | null overview: Polled refreshToken?: number }) { + const optimizeReport = usePolled( + () => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) const yieldReport = usePolled( () => range ? codeburn.getYield(period, range) : codeburn.getYield(period), [period, range?.from, range?.to, refreshToken], ) + const [tab, setTab] = useState('waste') if (!overview.data) { if (overview.error) return @@ -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 && ( - - - - )} - {revertedRows && ( - + setTab(value as OptimizeTab)} + style={{ alignSelf: 'flex-start' }} + /> + + {tab === 'waste' ? ( + + ) : tab === 'reverts' ? ( - - )} - {abandonedRows && ( - + ) : tab === 'abandoned' ? ( - - )} - {isEmpty && No waste findings in this range yet.} + ) : ( + + )} + ) } -function WasteRows({ data }: { data: MenubarPayload }) { - return +function WasteRows({ report }: { report: Polled }) { + if (!report.data) { + if (report.error) return + return Scanning optimize findings… + } + + return ( +
+
+ {report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100 +
+ +
+ ) } -type Finding = MenubarPayload['optimize']['topFindings'][number] +type OptimizeFinding = OptimizeJsonReport['findings'][number] -const IMPACT_ICON: Record = { +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(null) + const [copiedId, setCopiedId] = useState(null) + + if (!findings.length) return No waste findings in this range yet. + + 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 ( +
+ {findings.map(finding => { + const expanded = expandedId === finding.id + return ( + + + {expanded && ( +
+

{finding.explanation}

+
+
+
+ {finding.fix.label} + {finding.fix.type === 'file-content' && {finding.fix.path}} +
+ +
+
{actionText(finding.fix)}
+
+
+ )} +
+ ) + })} +
+ ) +} + +type Finding = MenubarPayload['optimize']['topFindings'][number] + function FindingRows({ findings, empty }: { findings: Finding[]; empty: string }) { if (!findings.length) return {empty} return (
{findings.map((finding, i) => ( -
+
{String(i + 1).padStart(2, '0')} {finding.title} @@ -135,3 +225,7 @@ function YieldRows({ ) } + +function FixesRows({ data }: { data: MenubarPayload }) { + return +} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 2d2b1f60..88890345 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; }