diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index ef3fd993..81c7e090 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -215,7 +215,7 @@ describe('App shortcuts', () => { render() 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() @@ -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 }) @@ -251,7 +254,7 @@ describe('App shortcuts', () => { it('re-polls visible section data when period or provider changes', async () => { render() - 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')) @@ -389,7 +392,7 @@ describe('App shortcuts', () => { it('applies a calendar range to overview and visible section polls', async () => { render() - 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' })) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 8dae38a9..1540bd31 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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' @@ -106,6 +107,7 @@ export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Ma const SECTION_TITLES: Record = { overview: 'Overview', sessions: 'Sessions', + pullRequests: 'Pull requests', spend: 'Spend', optimize: 'Optimize', models: 'Models', @@ -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 @@ -514,6 +517,8 @@ function AppMain() { ) : section === 'sessions' ? ( + ) : section === 'pullRequests' ? ( + ) : section === 'spend' ? ( ) : section === 'optimize' ? ( @@ -532,7 +537,7 @@ function AppMain() { {section !== 'settings' && ( { - it('renders all eight nav items in the desktop order', () => { + it('renders all nine nav items in the desktop order', () => { render( {}} />) 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', () => { diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx index 8c493a55..ab4c5530 100644 --- a/app/renderer/components/Sidebar.tsx +++ b/app/renderer/components/Sidebar.tsx @@ -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: ( @@ -13,19 +13,22 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R { id: 'sessions', label: 'Sessions', key: '⌘2', icon: ( ) }, - { id: 'spend', label: 'Spend', key: '⌘3', icon: ( + { id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: ( + + ) }, + { id: 'spend', label: 'Spend', key: '⌘4', icon: ( ) }, - { id: 'optimize', label: 'Optimize', key: '⌘4', icon: ( + { id: 'optimize', label: 'Optimize', key: '⌘5', icon: ( ) }, - { id: 'models', label: 'Models', key: '⌘5', icon: ( + { id: 'models', label: 'Models', key: '⌘6', icon: ( ) }, - { id: 'compare', label: 'Compare', key: '⌘6', icon: ( + { id: 'compare', label: 'Compare', key: '⌘7', icon: ( ) }, - { id: 'plans', label: 'Plans', key: '⌘7', icon: ( + { id: 'plans', label: 'Plans', key: '⌘8', icon: ( ) }, { id: 'settings', label: 'Settings', key: '⌘,', icon: ( diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 9a87f25f..0d49639e 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 diff --git a/app/renderer/sections/PullRequests.test.tsx b/app/renderer/sections/PullRequests.test.tsx new file mode 100644 index 00000000..264884a8 --- /dev/null +++ b/app/renderer/sections/PullRequests.test.tsx @@ -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 + +const { getOverview, openExternal } = vi.hoisted(() => ({ + getOverview: vi.fn<(period: string, provider: string) => Promise>(), + openExternal: vi.fn<(url: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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() + + 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() + + 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() + + 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() + + 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() + + expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() + expect(screen.queryByRole('table')).toBeNull() + }) +}) diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx new file mode 100644 index 00000000..53b97969 --- /dev/null +++ b/app/renderer/sections/PullRequests.tsx @@ -0,0 +1,102 @@ +import type { MouseEvent } from 'react' + +import { CliErrorPanel } from '../components/CliErrorPanel' +import { EmptyNote } from '../components/EmptyState' +import { Panel } from '../components/Panel' +import { SectionSkeleton } from '../components/Skeleton' +import { StaleBanner } from '../components/StaleBanner' +import { type Polled, usePolled } from '../hooks/usePolled' +import { formatDayShort, formatUsd } from '../lib/format' +import { codeburn } from '../lib/ipc' +import type { CliError, DateRange, MenubarPayload, Period } from '../lib/types' + +type PullRequests = NonNullable +type PrRow = PullRequests['rows'][number] + +// A PR's active window: one day collapses to a single label, otherwise the two +// endpoints joined with a hyphen (never an en/em dash, per repo copy rules). +function spanLabel(firstStarted: string, lastEnded: string): string { + const start = formatDayShort(firstStarted) + const end = formatDayShort(lastEnded) + if (start === '—' && end === '—') return '—' + return start === end ? start : `${start} - ${end}` +} + +function openPr(event: MouseEvent, url: string): void { + event.preventDefault() + void codeburn.openExternal(url) +} + +/** Standalone entry: self-fetches the overview payload (used in tests). The App + * passes its shared overview poll straight into PullRequestsContent instead. */ +export function PullRequests({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { + const overview = usePolled( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + return +} + +export function PullRequestsContent({ overview }: { overview: Polled }) { + if (!overview.data) { + if (overview.error) return + return + } + return +} + +function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullRequests; staleError: CliError | null }) { + return ( + <> + {staleError && } + + {pullRequests && pullRequests.rows.length > 0 + ? + : PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.} + + + ) +} + +function PrTable({ pullRequests }: { pullRequests: PullRequests }) { + const { rows, distinctCost, distinctSessions } = pullRequests + const sessionWord = distinctSessions === 1 ? 'session' : 'sessions' + return ( + <> +
+ + + + + + + + + + + + {rows.map(pr => )} + +
Pull requestCostSessionsCallsActive
+
+

+ {formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord} produced pull requests. + {' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed. +

+ + ) +} + +function PrRowView({ pr }: { pr: PrRow }) { + return ( + + + openPr(event, pr.url)}>{pr.label} + + {formatUsd(pr.cost)} + {pr.sessions.toLocaleString('en-US')} + {pr.calls.toLocaleString('en-US')} + {spanLabel(pr.firstStarted, pr.lastEnded)} + + ) +} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index f666e056..be3e0f91 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -580,6 +580,10 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .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: var(--fw-medium); text-overflow: ellipsis; } .ov-models td.mono { font-family: var(--mono); color: var(--ink); } +.pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; } +.pr-link:hover { text-decoration: underline; } +.pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; } +.pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; } .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; } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index c2933c28..82adf711 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -42,6 +42,26 @@ export type PeriodData = { topReworkedFiles?: ReworkedFile[] /// Share (0-1) of cost-bearing calls that resolved a price. pricingCoverage?: number + /// Spend attributed by referenced pull request (from Claude session + /// transcripts). Rows are by-reference — a session referencing several PRs + /// counts toward EACH — so never sum them; `distinctCost`/`distinctSessions` + /// are the multi-link-safe total. Absent when no PR links were observed. + pullRequests?: PullRequestsPayload + /// Per-branch spend, last-seen branch carried forward across each session's + /// turns. A `null` branch is unbranched spend inside a branch-bearing session. + /// Rows are by-reference (a session that switched branches counts toward each), + /// so never sum them. Absent when no branch data was observed. + byBranch?: BranchRow[] +} + +export type PullRequestsPayload = { + /// Per-PR rows, cost-descending, capped at the top 20 by the producer. + rows: PrRow[] + /// Distinct-session spend across every PR-linked session — the figure safe to + /// present as a total, since the per-PR rows double-count sessions that + /// reference more than one PR. + distinctCost: number + distinctSessions: number } export type ProviderCost = { @@ -55,6 +75,7 @@ import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' import type { GranularHistory } from './granular-history.js' import type { ReworkedFile } from './workflow-insights.js' +import type { PrRow, BranchRow } from './sessions-report.js' const TOP_ACTIVITIES_LIMIT = 20 const TOP_MODELS_LIMIT = 20 @@ -272,6 +293,17 @@ 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 attributed by referenced pull request (top 20 by cost) plus the + /// multi-link-safe distinct total. Rows are by-reference (a session + /// referencing several PRs counts toward each), so never summed. Absent when + /// no PR links were observed, and on payloads produced before the field + /// existed — the client renders its empty state for both. + pullRequests?: PullRequestsPayload + /// Per-branch spend (top 15 by cost), last-seen branch carried forward across + /// each session's turns; a `null` branch is unbranched spend inside a + /// branch-bearing session. By-reference like the PR rows. Absent when no + /// branch data was observed, and on payloads produced before the field. + byBranch?: BranchRow[] } optimize: { findingCount: number @@ -486,6 +518,11 @@ export function buildMenubarPayload( skills: breakdowns?.skills ?? [], subagents: breakdowns?.subagents ?? [], mcpServers: breakdowns?.mcpServers ?? [], + // Add-only: emitted only when the producer computed them (all-provider + // path), omitted otherwise so the schema stays stable for consumers that + // predate the fields. + ...(current.pullRequests ? { pullRequests: current.pullRequests } : {}), + ...(current.byBranch ? { byBranch: current.byBranch } : {}), }, optimize: buildOptimize(optimize), history: buildHistory(dailyHistory, granularHistory), diff --git a/src/parser.ts b/src/parser.ts index 7c911341..807639d5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2026,7 +2026,19 @@ async function scanProjectDirs( const cachedFile = section.files[filePath] if (!cachedFile || cachedFile.turns.length === 0) continue - let classifiedTurns = cachedFile.turns.map(cachedTurnToClassified) + // Carry the git branch forward BEFORE the date filter below: the cache + // stores a turn's branch only when it changes, so resolving here (over the + // full ordered turn list) means a later date slice can drop the anchor turn + // without the surviving turns losing their branch. + let carriedBranch: string | undefined + let classifiedTurns = cachedFile.turns.map(turn => { + if (turn.gitBranch) carriedBranch = turn.gitBranch + return cachedTurnToClassified(turn, carriedBranch) + }) + // Captured from the FULL turn list, before the date slice below can drop the + // turn a branch was first seen on. Lets the by-branch report keep this + // session's in-range unbranched spend as `null` instead of discarding it. + const everHadBranch = carriedBranch !== undefined if (dateRange) { classifiedTurns = classifiedTurns.filter(turn => { @@ -2046,6 +2058,7 @@ async function scanProjectDirs( const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source) session.agentType = cachedFile.agentType + if (everHadBranch) session.everHadBranch = true if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort() if (cachedFile.title) session.title = cachedFile.title @@ -2318,12 +2331,19 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall { }) } -function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn { +// `resolvedBranch` restores the turn's git branch after the cache's per-turn +// dedup (branch stored only when it changes). Callers that serve a full session's +// turns in order carry the last stored value forward and pass it here, so each +// reconstructed turn regains the "branch active for this turn" the cache elided — +// and downstream date/day filtering can slice turns without losing the anchor. +function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): ClassifiedTurn { + const branch = turn.gitBranch ?? resolvedBranch const parsed: ParsedTurn = { userMessage: turn.userMessage, assistantCalls: turn.calls.map(cachedCallToApiCall), timestamp: turn.timestamp, sessionId: turn.sessionId, + ...(branch ? { gitBranch: branch } : {}), } return classifyTurn(parsed) } @@ -2938,7 +2958,9 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set turnIsInDateRange(turn, dateRange)) if (turns.length === 0) continue - sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source)) + const rebuilt = buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source) + if (session.everHadBranch) rebuilt.everHadBranch = true + sessions.push(rebuilt) } if (sessions.length === 0) continue filtered.push(summarizeProject(project.project, project.projectPath, sessions)) diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 27c6752f..bba367f6 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -146,3 +146,54 @@ export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sess } return { cost, sessions } } + +export type BranchRow = { + /// The git branch active for the attributed turns, or `null` for spend that + /// occurred before any branch was observed within a branch-bearing session. + branch: string | null + cost: number + calls: number + sessions: number +} + +/// Per-branch spend, carrying each session's last-seen git branch forward across +/// its turns. The cache stores a turn's branch only when it CHANGES, so a report +/// must reconstruct each turn's branch from the last stored value — this walks a +/// session's turns in order and does exactly that. +/// +/// Only sessions that EVER observed a branch participate: a provider that never +/// captures branch data (only Claude does today) would otherwise pile all of its +/// spend into one `null` bucket that dwarfs every real branch. Within a +/// participating session, turns before the first observed branch are attributed +/// to a single explicit `null` row the caller can label honestly. +/// +/// A session that switches branches counts toward EACH branch it touched (like +/// the by-PR by-reference attribution), so rows must never be summed into a grand +/// total. Sorted by cost, descending. +export function aggregateByBranch(projects: ProjectSummary[]): BranchRow[] { + const byBranch = new Map }>() + for (const project of projects) { + for (const session of project.sessions) { + // Participate when the session observed a branch anywhere in its full + // transcript (`everHadBranch`, set pre-date-filter) — falling back to the + // turns in hand for producers/fixtures that don't set the flag. A session + // that never observed a branch (every non-Claude provider) is skipped so + // it can't pile into the null bucket. + if (!session.everHadBranch && !session.turns.some(turn => turn.gitBranch)) continue + let current: string | null = null + for (const turn of session.turns) { + if (turn.gitBranch) current = turn.gitBranch + if (turn.assistantCalls.length === 0) continue + const turnCost = turn.assistantCalls.reduce((sum, call) => sum + call.costUSD, 0) + const row = byBranch.get(current) ?? { cost: 0, calls: 0, sessions: new Set() } + row.cost += turnCost + row.calls += turn.assistantCalls.length + row.sessions.add(session.sessionId) + byBranch.set(current, row) + } + } + } + return [...byBranch.entries()] + .map(([branch, d]) => ({ branch, cost: d.cost, calls: d.calls, sessions: d.sessions.size })) + .sort((a, b) => b.cost - a.cost) +} diff --git a/src/types.ts b/src/types.ts index aaefc866..248289cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,13 @@ export type SessionSummary = { /// Human session title captured from the transcript (last ai-title entry). /// Absent when the transcript never produced one. title?: string + /// True when the session observed a git branch on ANY turn of its FULL + /// (pre-date-filter) transcript. Set before turns are sliced to a range so the + /// by-branch report can still tell a branch-bearing Claude session (whose + /// in-range turns may all predate its first branch → the `null` bucket) apart + /// from a provider that never captures branches (→ contributes nothing). + /// Claude only; absent otherwise. + everHadBranch?: boolean modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 6be58f70..c728b72e 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -10,10 +10,15 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggreg import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModels } from './models-report.js' import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js' +import { aggregateByPr, prLinkedTotals, aggregateByBranch } from './sessions-report.js' import { scanAndDetect } from './optimize.js' import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' +// Row caps for the by-PR / by-branch payload aggregations, ranked by cost. +const TOP_PULL_REQUESTS = 20 +const TOP_BRANCHES = 15 + export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) const catTotals: Record = {} @@ -649,6 +654,28 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: })) ).sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)).slice(0, 5) + // PULL REQUESTS + BRANCHES (all-provider path only). Both are session-layer + // aggregations over the surviving-session parse, so carried history cannot + // contribute — expected and fine. PR links and per-turn git branches are + // captured only from Claude transcripts today; other providers add nothing. + // Set only when non-empty so the payload omits them (and the app renders its + // quiet empty state) whenever there is nothing to show. Excluded on the + // Claude-config-scoped path (which replaces scanProjects with one config's + // sessions) so this stays the genuine unscoped all-provider aggregation. + if (isAllProviders && !effectivelyScoped) { + const prRows = aggregateByPr(scanProjects) + if (prRows.length > 0) { + const prTotals = prLinkedTotals(scanProjects) + currentData.pullRequests = { + rows: prRows.slice(0, TOP_PULL_REQUESTS), + distinctCost: prTotals.cost, + distinctSessions: prTotals.sessions, + } + } + const branchRows = aggregateByBranch(scanProjects) + if (branchRows.length > 0) currentData.byBranch = branchRows.slice(0, TOP_BRANCHES) + } + // Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits), // then compute how much each pricier model overpaid. const reliableModels = [...effMap.values()] diff --git a/tests/sessions-by-branch.test.ts b/tests/sessions-by-branch.test.ts new file mode 100644 index 00000000..a0944785 --- /dev/null +++ b/tests/sessions-by-branch.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' + +import { aggregateByBranch } from '../src/sessions-report.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' + +const ZERO_USAGE: TokenUsage = { + inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, +} + +let keySeq = 0 +function call(cost: number): ParsedApiCall { + return { + provider: 'claude', model: 'claude', usage: ZERO_USAGE, costUSD: cost, + tools: [], mcpTools: [], skills: [], subagentTypes: [], + hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', + timestamp: '2026-07-01T10:00:00Z', bashCommands: [], deduplicationKey: `k${keySeq++}`, + } +} + +// A turn whose total cost `cost` is split across `calls` API calls. `gitBranch` +// is set only when supplied — the cache stores it only on the turn where the +// branch CHANGES, so omitting it here is exactly what the carry-forward under +// test must reconstruct. +function turn(cost: number, calls = 1, gitBranch?: string): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: Array.from({ length: calls }, () => call(cost / calls)), + timestamp: '2026-07-01T10:00:00Z', sessionId: 's', + category: 'coding', retries: 0, hasEdits: false, + ...(gitBranch ? { gitBranch } : {}), + } +} + +function session(id: string, turns: ClassifiedTurn[], everHadBranch?: boolean): SessionSummary { + return { + sessionId: id, project: 'p', + firstTimestamp: '2026-07-01T10:00:00Z', lastTimestamp: '2026-07-01T11:00:00Z', + totalCostUSD: 0, totalSavingsUSD: 0, totalEstimatedCostUSD: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 0, turns, + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {} as SessionSummary['skillBreakdown'], + subagentBreakdown: {} as SessionSummary['subagentBreakdown'], + ...(everHadBranch ? { everHadBranch } : {}), + } +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 } +} + +describe('aggregateByBranch', () => { + it('carries the last-seen branch forward across turns that omit it', () => { + // The cache stored `main` on turn 1 (its first appearance) and nothing on + // turn 2 (unchanged); turn 3 changed to `feature`. The report must attribute + // turn 2 to `main` by carry-forward. + const rows = aggregateByBranch([project([ + session('s', [ + turn(100, 2, 'main'), + turn(50, 1), + turn(30, 1, 'feature'), + ]), + ])]) + expect(rows.map(r => r.branch)).toEqual(['main', 'feature']) + const main = rows[0]! + expect(main.cost).toBeCloseTo(150, 6) + expect(main.calls).toBe(3) + expect(main.sessions).toBe(1) + const feature = rows[1]! + expect(feature.cost).toBeCloseTo(30, 6) + expect(feature.calls).toBe(1) + }) + + it('attributes spend before the first observed branch to an explicit null row', () => { + const rows = aggregateByBranch([project([ + session('s', [ + turn(20, 1), // unbranched prefix + turn(80, 1, 'main'), + ]), + ])]) + expect(rows.map(r => r.branch)).toEqual(['main', null]) + expect(rows.find(r => r.branch === null)!.cost).toBeCloseTo(20, 6) + expect(rows.find(r => r.branch === 'main')!.cost).toBeCloseTo(80, 6) + }) + + it('keeps in-range unbranched spend as null when the branch anchor was filtered out (everHadBranch)', () => { + // A branch-bearing session whose only in-range turns predate its first + // branch: the date slice dropped the anchor, but everHadBranch (captured + // pre-filter) still lets its $20 land in the explicit null row. + const rows = aggregateByBranch([project([ + session('s', [turn(20, 1)], true), + ])]) + expect(rows).toEqual([{ branch: null, cost: 20, calls: 1, sessions: 1 }]) + }) + + it('drops sessions that never observed a branch (no null bucket that dwarfs the rest)', () => { + const rows = aggregateByBranch([project([ + session('branched', [turn(10, 1, 'main')]), + session('unbranched', [turn(999, 3), turn(999, 3)]), + ])]) + expect(rows).toEqual([{ branch: 'main', cost: 10, calls: 1, sessions: 1 }]) + }) + + it('counts a session toward each branch it touched, once', () => { + const rows = aggregateByBranch([project([ + session('a', [turn(40, 1, 'main'), turn(10, 1, 'feature')]), + session('b', [turn(30, 1, 'main')]), + ])]) + const main = rows.find(r => r.branch === 'main')! + expect(main.sessions).toBe(2) + expect(main.cost).toBeCloseTo(70, 6) + const feature = rows.find(r => r.branch === 'feature')! + expect(feature.sessions).toBe(1) + }) + + it('sorts rows by cost, descending', () => { + const rows = aggregateByBranch([project([ + session('a', [turn(5, 1, 'small'), turn(200, 1, 'big'), turn(50, 1, 'mid')]), + ])]) + expect(rows.map(r => r.branch)).toEqual(['big', 'mid', 'small']) + }) + + it('returns nothing when no session carries branch data', () => { + expect(aggregateByBranch([project([session('a', [turn(100, 1)])])])).toEqual([]) + }) +})