Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions app/renderer/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('App shortcuts', () => {
render(<App />)

expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.getByText('⌘1-7')).toBeInTheDocument()
expect(screen.getByText('⌘1-8')).toBeInTheDocument()
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
expect(screen.getByText('⌘R')).toBeInTheDocument()
expect(screen.queryByText('Command')).not.toBeInTheDocument()
Expand All @@ -225,18 +225,21 @@ describe('App shortcuts', () => {
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()

fireEvent.keyDown(document, { key: '3', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()

fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()

fireEvent.keyDown(document, { key: '5', metaKey: true })
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()

fireEvent.keyDown(document, { key: '6', metaKey: true })
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()

fireEvent.keyDown(document, { key: '7', metaKey: true })
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()

fireEvent.keyDown(document, { key: '8', metaKey: true })
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()

fireEvent.keyDown(document, { key: ',', metaKey: true })
Expand All @@ -251,7 +254,7 @@ describe('App shortcuts', () => {
it('re-polls visible section data when period or provider changes', async () => {
render(<App />)

fireEvent.keyDown(document, { key: '3', metaKey: true })
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()

fireEvent.click(screen.getByText('Today'))
Expand Down Expand Up @@ -389,7 +392,7 @@ describe('App shortcuts', () => {
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)

fireEvent.keyDown(document, { key: '3', metaKey: true })
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))

Expand Down
17 changes: 11 additions & 6 deletions app/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { OverviewContent } from './sections/Overview'
import { OptimizeContent } from './sections/Optimize'
import { Models } from './sections/Models'
import { Sessions } from './sections/Sessions'
import { PullRequestsContent } from './sections/PullRequests'
import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
Expand Down Expand Up @@ -106,6 +107,7 @@ export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Ma
const SECTION_TITLES: Record<Section, string> = {
overview: 'Overview',
sessions: 'Sessions',
pullRequests: 'Pull requests',
spend: 'Spend',
optimize: 'Optimize',
models: 'Models',
Expand Down Expand Up @@ -424,11 +426,12 @@ function AppMain() {
const key = event.key.toLowerCase()
if (key === '1') navigate('overview')
else if (key === '2') navigate('sessions')
else if (key === '3') navigate('spend')
else if (key === '4') navigate('optimize')
else if (key === '5') navigate('models')
else if (key === '6') navigate('compare')
else if (key === '7') navigate('plans')
else if (key === '3') navigate('pullRequests')
else if (key === '4') navigate('spend')
else if (key === '5') navigate('optimize')
else if (key === '6') navigate('models')
else if (key === '7') navigate('compare')
else if (key === '8') navigate('plans')
else if (key === ',') navigate('settings')
else if (key === 'r') refreshVisible()
else return
Expand Down Expand Up @@ -514,6 +517,8 @@ function AppMain() {
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (
<PullRequestsContent overview={overview} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} ready={ready} />
) : section === 'optimize' ? (
Expand All @@ -532,7 +537,7 @@ function AppMain() {
{section !== 'settings' && (
<Hint
items={[
{ k: '⌘1-7', label: 'Navigate' },
{ k: '⌘1-8', label: 'Navigate' },
{ k: '⌘,', label: 'Settings' },
{ k: '⌘R', label: 'Refresh' },
]}
Expand Down
9 changes: 5 additions & 4 deletions app/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from './Sidebar'

describe('Sidebar', () => {
it('renders all eight nav items in the desktop order', () => {
it('renders all nine nav items in the desktop order', () => {
render(<Sidebar active="overview" onNavigate={() => {}} />)
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, ''))
expect(labels).toEqual(['Overview', 'Sessions', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Compare.*⌘6/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Plans.*⌘7/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Pull requests.*⌘3/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Compare.*⌘7/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Plans.*⌘8/ })).toBeInTheDocument()
})

it('calls onNavigate with the section id when a nav item is clicked', () => {
Expand Down
15 changes: 9 additions & 6 deletions app/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { codeburn } from '../lib/ipc'
import { AboutModal, type SocialLink } from './AboutModal'
import { FlameMark } from './FlameMark'

export type Section = 'overview' | 'sessions' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'

export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
{ id: 'overview', label: 'Overview', key: '⌘1', icon: (
Expand All @@ -13,19 +13,22 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R
{ id: 'sessions', label: 'Sessions', key: '⌘2', icon: (
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
) },
{ id: 'spend', label: 'Spend', key: '⌘3', icon: (
{ id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: (
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
) },
{ id: 'spend', label: 'Spend', key: '⌘4', icon: (
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
) },
{ id: 'optimize', label: 'Optimize', key: '⌘4', icon: (
{ id: 'optimize', label: 'Optimize', key: '⌘5', icon: (
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
) },
{ id: 'models', label: 'Models', key: '⌘5', icon: (
{ id: 'models', label: 'Models', key: '⌘6', icon: (
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
) },
{ id: 'compare', label: 'Compare', key: '⌘6', icon: (
{ id: 'compare', label: 'Compare', key: '⌘7', icon: (
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
) },
{ id: 'plans', label: 'Plans', key: '⌘7', icon: (
{ id: 'plans', label: 'Plans', key: '⌘8', icon: (
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
) },
{ id: 'settings', label: 'Settings', key: '⌘,', icon: (
Expand Down
19 changes: 19 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ export type MenubarPayload = {
skills: Array<{ name: string; turns: number; cost: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
mcpServers: Array<{ name: string; calls: number }>
// Spend by referenced pull request (top 20 by cost) plus the multi-link-safe
// distinct total. Optional: older CLIs omit it, and it is absent when no PR
// links were observed. Rows are by-reference — a session referencing several
// PRs counts toward each — so `rows[].cost` must never be summed; use
// `distinctCost`/`distinctSessions` for a total.
pullRequests?: {
rows: Array<{
url: string
label: string
cost: number
savingsUSD: number
sessions: number
calls: number
firstStarted: string
lastEnded: string
}>
distinctCost: number
distinctSessions: number
}
}
optimize: {
findingCount: number
Expand Down
110 changes: 110 additions & 0 deletions app/renderer/sections/PullRequests.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { formatDayShort } from '../lib/format'
import type { MenubarPayload } from '../lib/types'
import { PullRequests } from './PullRequests'

type PrPayload = NonNullable<MenubarPayload['current']['pullRequests']>

const { getOverview, openExternal } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
openExternal: vi.fn<(url: string) => Promise<void>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, openExternal } }
})

// Mirror the component's span rule so the assertion stays timezone-safe.
function expectedSpan(first: string, last: string): string {
const start = formatDayShort(first)
const end = formatDayShort(last)
return start === end ? start : `${start} - ${end}`
}

function makePayload(pullRequests?: PrPayload): MenubarPayload {
return {
generated: '2026-07-20T00:00:00Z',
current: {
label: 'Lifetime', cost: 0, calls: 0, sessions: 0, 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: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [], skills: [], subagents: [], mcpServers: [],
...(pullRequests ? { pullRequests } : {}),
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: { daily: [] },
}
}

const SAMPLE: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z' },
{ url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z' },
],
distinctCost: 300.75,
distinctSessions: 3,
}

describe('PullRequests', () => {
beforeEach(() => {
getOverview.mockReset()
openExternal.mockReset()
openExternal.mockResolvedValue(undefined)
})

it('renders PR rows as a table with linked labels, cost, and a date span', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780')
expect(screen.getByText('$240.50')).toBeInTheDocument()
expect(screen.getByText('512')).toBeInTheDocument()
expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument()
// A same-day PR collapses its span to a single label.
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
})

it('opens the PR URL externally instead of navigating', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(link)
expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780')
})

it('states the distinct-total footer explaining by-reference attribution', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const note = await screen.findByText(/produced pull requests/)
expect(note.textContent).toContain('$300.75')
expect(note.textContent).toContain('3 distinct sessions')
expect(note.textContent).toContain('counts toward each')
})

it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
getOverview.mockResolvedValue(makePayload())
render(<PullRequests period="lifetime" provider="all" />)

expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
expect(screen.queryByRole('table')).toBeNull()
})

it('shows the empty state when the PR array is present but empty', async () => {
getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0 }))
render(<PullRequests period="lifetime" provider="all" />)

expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
expect(screen.queryByRole('table')).toBeNull()
})
})
Loading