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
3 changes: 3 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ export type MenubarPayload = {
}>
distinctCost: number
distinctSessions: number
// Count of subagent (sidechain) runs folded into the PR-linked parent
// sessions. Optional (absent when none folded, or from an older producer).
subagentSessions?: number
attributedCost?: number
unattributedCost?: number
otherPrCount?: number
Expand Down
17 changes: 17 additions & 0 deletions app/renderer/sections/PullRequests.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,23 @@ describe('PullRequests', () => {
expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30')
})

it('notes folded-in subagent runs in the footer when present', async () => {
getOverview.mockResolvedValue(makePayload({ ...SAMPLE, subagentSessions: 32 }))
render(<PullRequests period="lifetime" provider="all" />)

const note = await screen.findByText(/attributed to the rows above/)
expect(note.textContent).toContain('3 PR-linked sessions')
expect(note.textContent).toContain('32 folded-in subagent runs')
})

it('omits the subagent note when none were folded', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const note = await screen.findByText(/attributed to the rows above/)
expect(note.textContent).not.toContain('subagent')
})

it('marks an approximate (legacy) row with a ~ prefix and a tooltip', async () => {
const approxPayload: PrPayload = {
rows: [
Expand Down
5 changes: 3 additions & 2 deletions app/renderer/sections/PullRequests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
}

function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
const { rows, distinctCost, distinctSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
const [expandedUrl, setExpandedUrl] = useState<string | null>(null)
// Reset any open expansion when the PR set changes (a period/provider switch or
// a refresh that alters the list): a stale expandedUrl would otherwise linger
Expand Down Expand Up @@ -145,7 +145,8 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
</div>
{summable ? (
<p className="pr-footnote">
{formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}.
{formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}
{subagentSessions ? ` + ${subagentSessions.toLocaleString('en-US')} folded-in subagent ${subagentSessions === 1 ? 'run' : 'runs'}` : ''}.
{' '}Each turn's cost goes to the PR it was working on, so the rows are summable.
</p>
) : (
Expand Down
13 changes: 8 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2011,7 +2011,7 @@ program
.action(async (opts) => {
assertProvider(opts.provider, 'sessions')
assertFormat(opts.format, ['table', 'json'], 'sessions')
const { aggregateSessions, aggregateByPr, prLinkedTotals, renderJson, renderTable } = await import('./sessions-report.js')
const { aggregateSessions, buildPrAttribution, renderJson, renderTable } = await import('./sessions-report.js')
await loadPricing()

let range
Expand All @@ -2028,16 +2028,16 @@ program

const projects = await parseAllSessions(range, opts.provider)
if (opts.byPr) {
const prRows = aggregateByPr(projects)
const { rows: prRows, totals } = buildPrAttribution(projects)
if (opts.format === 'json') {
process.stdout.write(JSON.stringify({ prs: prRows, distinct: prLinkedTotals(projects) }, null, 2) + '\n')
process.stdout.write(JSON.stringify({ prs: prRows, distinct: totals }, null, 2) + '\n')
return
}
if (prRows.length === 0) {
process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n')
return
}
const { unattributedCost, sessions } = prLinkedTotals(projects)
const { unattributedCost, sessions, subagentSessions } = totals
const { renderTable: renderTextTable } = await import('./text-table.js')
const modelsCell = (models: string[]): string =>
models.length === 0 ? '' : models.slice(0, 2).join(', ') + (models.length > 2 ? ` +${models.length - 2}` : '')
Expand Down Expand Up @@ -2069,7 +2069,10 @@ program
const approxNote = prRows.some(r => r.approx)
? ' ~ marks rows estimated from a whole-session even split (transcript expired before per-turn capture).'
: ''
process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`)
const subagentNote = subagentSessions > 0
? ` + ${subagentSessions} folded-in subagent run${subagentSessions === 1 ? '' : 's'}`
: ''
process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}${subagentNote}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`)
return
}
const rows = aggregateSessions(projects)
Expand Down
10 changes: 8 additions & 2 deletions src/menubar-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,16 @@ export type PeriodData = {
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. Equals
/// `attributedCost + unattributedCost`; kept for backward compatibility.
/// PR-linked spend, now INCLUDING the subagent runs folded into those sessions
/// (so it can exceed the parents' own spend). Equals `attributedCost +
/// unattributedCost`; kept for backward compatibility.
distinctCost: number
/// Count of distinct PR-linked PARENT sessions.
distinctSessions: number
/// Count of subagent (sidechain) runs whose spend was folded into those parent
/// sessions. Each remains a standalone row in the sessions list; here it only
/// explains why the totals exceed the parents' own spend. 0 when none folded.
subagentSessions?: number
/// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20).
attributedCost: number
/// PR-linked spend not tied to any specific PR (pre-reference session
Expand Down
Loading