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
4 changes: 1 addition & 3 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ 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), attributed at turn
// Spend by referenced pull request (every PR, cost-descending), attributed at turn
// granularity. Optional: older CLIs omit it, and it is absent when no PR links
// were observed. Rows carry attributed cost/calls and ARE summable;
// `attributedCost + unattributedCost === distinctCost`. `approx` marks a row
Expand Down Expand Up @@ -250,8 +250,6 @@ export type MenubarPayload = {
subagentSessions?: number
attributedCost?: number
unattributedCost?: number
otherPrCount?: number
otherPrCost?: number
}
}
optimize: {
Expand Down
63 changes: 32 additions & 31 deletions app/renderer/sections/PullRequests.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ const SAMPLE: PrPayload = {
distinctSessions: 3,
attributedCost: 330.75,
unattributedCost: 45.3,
otherPrCount: 0,
otherPrCost: 0,
}

// Get the button-role row wrapping a given PR link, for click/keyboard toggling.
Expand All @@ -71,26 +69,27 @@ describe('PullRequests', () => {
openExternal.mockResolvedValue(undefined)
})

it('renders PR rows as a table with linked labels, cost, and a date span', async () => {
it('renders PR cards with linked labels, cost, activity, 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('512 calls')).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('renders the Models column with a "+N" overflow tag', async () => {
it('renders every model explicitly instead of hiding models behind an overflow count', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

// Three models collapse to the first two plus a count of the rest.
expect(await screen.findByText('fable, opus +1')).toBeInTheDocument()
// A single model renders as-is, with no overflow tag.
expect(await screen.findByText('fable')).toBeInTheDocument()
expect(screen.getByText('opus')).toBeInTheDocument()
expect(screen.getByText('haiku')).toBeInTheDocument()
expect(screen.queryByText('+1')).toBeNull()
expect(screen.getByText('sonnet')).toBeInTheDocument()
})

Expand Down Expand Up @@ -178,27 +177,29 @@ describe('PullRequests', () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const note = await screen.findByText(/attributed to the rows above/)
expect(note.textContent).toContain('$330.75')
expect(note.textContent).toContain('3 PR-linked sessions')
expect(note.textContent).toContain('summable')
expect(await screen.findByText('Attributed spend')).toBeInTheDocument()
expect(screen.getByText('$330.75')).toBeInTheDocument()
expect(screen.getByLabelText('Pull request attribution summary')).toHaveTextContent('Linked sessions3')
const note = screen.getByText(/attributed turn by turn/)
expect(note.textContent).toContain('without double counting')
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')
expect(await screen.findByText('Folded agent runs')).toBeInTheDocument()
expect(screen.getByText('32')).toBeInTheDocument()
const note = screen.getByText(/attributed turn by turn/)
expect(note.textContent).toContain('32 subagent runs are included')
})

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/)
const note = await screen.findByText(/attributed turn by turn/)
expect(note.textContent).not.toContain('subagent')
})

Expand Down Expand Up @@ -260,23 +261,23 @@ describe('PullRequests', () => {
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
})

it('renders an Other (N more PRs) reconciliation row when PRs are capped', async () => {
const cappedPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 200, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable'], categories: [{ name: 'Feature work', cost: 200 }] },
],
distinctCost: 288.4,
distinctSessions: 4,
attributedCost: 288.4,
unattributedCost: 0,
otherPrCount: 5,
otherPrCost: 88.4,
}
getOverview.mockResolvedValue(makePayload(cappedPayload))
it('renders the complete PR list without an opaque Other row', async () => {
const manyRows = Array.from({ length: 32 }, (_, index) => ({
...SAMPLE.rows[0]!,
url: `https://github.com/getagentseal/codeburn/pull/${800 + index}`,
label: `getagentseal/codeburn#${800 + index}`,
}))
getOverview.mockResolvedValue(makePayload({
...SAMPLE,
rows: manyRows,
attributedCost: manyRows.reduce((sum, row) => sum + row.cost, 0),
}))
render(<PullRequests period="lifetime" provider="all" />)

expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument()
expect(screen.getByText('$88.40')).toBeInTheDocument()
expect(await screen.findByText('getagentseal/codeburn#800')).toBeInTheDocument()
expect(screen.getByText('getagentseal/codeburn#831')).toBeInTheDocument()
expect(screen.getByText('32 total')).toBeInTheDocument()
expect(screen.queryByText(/Other \(/)).toBeNull()
})

it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
Expand Down
173 changes: 90 additions & 83 deletions app/renderer/sections/PullRequests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ function sessionWord(n: number): string {
return n === 1 ? 'session' : 'sessions'
}

// Up to two short model names, then a "+N" overflow tag; empty for no models.
function modelsLabel(models: string[]): string {
if (models.length <= 2) return models.join(', ')
return `${models.slice(0, 2).join(', ')} +${models.length - 2}`
function ModelChips({ models }: { models: string[] }) {
return (
<div className="pr-model-list" aria-label={models.length ? `Models used: ${models.join(', ')}` : 'No model data'}>
{models.map(model => <span className="pr-model-chip" key={model}>{model}</span>)}
</div>
)
}

function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
Expand All @@ -41,7 +43,7 @@ function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {

// Keyboard activation for the button-role row, guarded so Enter/Space fired on
// the inner link (its own control) never doubles up as a row toggle.
function rowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, toggle: () => void): void {
function rowKeyDown(event: KeyboardEvent<HTMLDivElement>, toggle: () => void): void {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
Expand Down Expand Up @@ -73,7 +75,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
return (
<>
{staleError && <StaleBanner error={staleError} />}
<Panel title="Spend by pull request">
<Panel title="Pull request spend">
{pullRequests && pullRequests.rows.length > 0
? <PrTable pullRequests={pullRequests} />
: <EmptyNote>PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.</EmptyNote>}
Expand All @@ -83,7 +85,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq
}

function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost } = 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 All @@ -95,59 +97,51 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
// payload omits it, so the rows are not summable and the footer must differ.
const summable = attributedCost !== undefined
const unattributed = unattributedCost ?? 0
const otherCount = otherPrCount ?? 0
const otherCost = otherPrCost ?? 0
// Reconcile to the visible numbers: sum the rounded row costs (plus any
// capped-away remainder) so the footer total equals what the eye adds up.
const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) + otherCost
// Reconcile to the visible numbers: every PR is present, so the summary is
// exactly the sum of the rounded cards a person can inspect below.
const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0)

return (
<>
<div className="pr-scroll">
<table className="ov-models pr-table" aria-label="Spend by pull request">
<thead>
<tr>
<th>Pull request</th>
<th className="pr-models">Models</th>
<th className="num">Cost</th>
<th className="num">Sessions</th>
<th className="num">Calls</th>
<th className="num">Active</th>
<th className="pr-chevron-cell" aria-hidden="true"></th>
</tr>
</thead>
<tbody>
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</tbody>
{otherCount > 0 && (
// A muted summary line, kept out of the sorted rows: its cost is an
// aggregate of the capped-away PRs and can exceed a visible row.
<tfoot>
<tr className="pr-other-row">
<td className="pr-other-label">Other ({otherCount.toLocaleString('en-US')} more PRs)</td>
<td className="pr-models"></td>
<td className="num mono">{formatUsd(otherCost)}</td>
<td className="num"></td>
<td className="num"></td>
<td className="num pr-span"></td>
<td className="pr-chevron-cell"></td>
</tr>
</tfoot>
)}
</table>
<div className="pr-summary" aria-label="Pull request attribution summary">
<div className="pr-summary-item">
<span>Attributed spend</span>
<strong>{formatUsd(summable ? displayedAttributed : distinctCost)}</strong>
</div>
<div className="pr-summary-item">
<span>Pull requests</span>
<strong>{rows.length.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Linked sessions</span>
<strong>{distinctSessions.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Folded agent runs</span>
<strong>{(subagentSessions ?? 0).toLocaleString('en-US')}</strong>
</div>
</div>
<div className="pr-list-head">
<div>
<strong>Attributed pull requests</strong>
<span>Sorted by spend, highest first</span>
</div>
<span className="pr-list-count">{rows.length.toLocaleString('en-US')} total</span>
</div>
<div className="pr-list" aria-label="Spend by pull request">
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</div>
{summable ? (
<p className="pr-footnote">
{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.
Costs are attributed turn by turn, so every row adds up without double counting.
{subagentSessions ? ` ${subagentSessions.toLocaleString('en-US')} subagent ${subagentSessions === 1 ? 'run is' : 'runs are'} included in the PR where the work happened.` : ''}
</p>
) : (
<p className="pr-footnote">
Expand All @@ -170,50 +164,63 @@ function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; o
const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0

return (
<>
<tr
className="pr-row"
<article className={expanded ? 'pr-card is-open' : 'pr-card'}>
<div
className="pr-card-trigger"
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onToggle}
onKeyDown={event => rowKeyDown(event, onToggle)}
>
<td className="ov-model-name">
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
</td>
<td className="pr-models">{models.length ? modelsLabel(models) : ''}</td>
<td className="num mono" {...(pr.approx ? { title: APPROX_TITLE } : {})}>
{pr.approx ? '~' : ''}{formatUsd(pr.cost)}
</td>
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
<td className="pr-chevron-cell"><span className="pr-chevron" aria-hidden="true">›</span></td>
</tr>
<div className="pr-card-identity">
<span className="pr-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M6 7.5V19M11 5h4a3 3 0 0 1 3 3v8.5"/></svg>
</span>
<div>
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
<div className="pr-card-meta">
<span>{spanLabel(pr.firstStarted, pr.lastEnded)}</span>
<span>{pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)}</span>
<span>{pr.calls.toLocaleString('en-US')} calls</span>
</div>
</div>
</div>
<div className="pr-card-models">
<span className="pr-card-label">Models</span>
<ModelChips models={models} />
</div>
<div className="pr-card-cost">
<span className="pr-card-label">Spend</span>
<strong {...(pr.approx ? { title: APPROX_TITLE } : {})}>{pr.approx ? '~' : ''}{formatUsd(pr.cost)}</strong>
</div>
<span className="pr-chevron" aria-hidden="true">›</span>
</div>
{expanded && (
<tr className="pr-detail-row">
<td className="pr-detail-cell" colSpan={7}>
<div className="pr-detail-cell">
{categories.length > 0 ? (
<div className="pr-cats" role="region" aria-label={`${pr.label} cost breakdown`}>
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<div className="pr-cat-main">
<div className="pr-detail" role="region" aria-label={`${pr.label} cost breakdown`}>
<div className="pr-detail-head">
<span>Work breakdown</span>
<strong>{formatUsd(pr.cost)} total</strong>
</div>
<div className="pr-cats">
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<span className="pr-cat-name">{cat.name}</span>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<strong>{formatUsd(cat.cost)}</strong>
</div>
</div>
))}
))}
</div>
</div>
) : (
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
)}
</td>
</tr>
</div>
)}
</>
</article>
)
}
Loading