}) {
+ 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 (
+ <>
+
+
+
+
+ | Pull request |
+ Cost |
+ Sessions |
+ Calls |
+ Active |
+
+
+
+ {rows.map(pr => )}
+
+
+
+
+ {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([])
+ })
+})