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
21 changes: 16 additions & 5 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,15 @@ 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.
// Spend by referenced pull request (top 20 by cost), 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
// fed by the legacy whole-session even split (transcript expired). `models` is
// the short model names that processed the PR (cost-desc); `categories` is the
// per-task-category attributed cost (cost-desc), omitted for legacy rows.
// `attributedCost`/`unattributedCost` are optional so a payload from an older
// CLI (by-reference rows, not summable) still type-checks and can be detected.
pullRequests?: {
rows: Array<{
url: string
Expand All @@ -235,9 +239,16 @@ export type MenubarPayload = {
calls: number
firstStarted: string
lastEnded: string
approx?: boolean
models?: string[]
categories?: Array<{ name: string; cost: number }>
}>
distinctCost: number
distinctSessions: number
attributedCost?: number
unattributedCost?: number
otherPrCount?: number
otherPrCost?: number
}
}
optimize: {
Expand Down
190 changes: 180 additions & 10 deletions app/renderer/sections/PullRequests.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -46,11 +46,22 @@ function makePayload(pullRequests?: PrPayload): MenubarPayload {

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' },
{ 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', models: ['fable', 'opus', 'haiku'], categories: [{ name: 'Feature work', cost: 180.25 }, { name: 'Debugging', cost: 60.25 }] },
{ 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', models: ['sonnet'], categories: [{ name: 'Refactoring', cost: 90.25 }] },
],
distinctCost: 300.75,
distinctCost: 376.05,
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.
function rowForLink(link: HTMLElement): HTMLElement {
const row = link.closest('[role="button"]')
if (!row) throw new Error('expected a button-role row around the PR link')
return row as HTMLElement
}

describe('PullRequests', () => {
Expand All @@ -73,23 +84,182 @@ describe('PullRequests', () => {
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
})

it('opens the PR URL externally instead of navigating', async () => {
it('renders the Models column with a "+N" overflow tag', 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(screen.getByText('sonnet')).toBeInTheDocument()
})

it('opens the PR URL externally without navigating or toggling the row', 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')
// Clicking the link must not expand its row.
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Feature work')).toBeNull()
})

it('expands a row to its category breakdown on click, then collapses', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
const row = rowForLink(link)
expect(row).toHaveAttribute('aria-expanded', 'false')

await userEvent.click(row)
expect(row).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Feature work')).toBeInTheDocument()
expect(screen.getByText('$180.25')).toBeInTheDocument()
expect(screen.getByText('Debugging')).toBeInTheDocument()

await userEvent.click(row)
expect(row).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Feature work')).toBeNull()
})

it('closes an open expansion when the period changes the PR set', async () => {
const changed: PrPayload = { ...SAMPLE, rows: [SAMPLE.rows[0]!] } // #781 dropped
getOverview.mockImplementation((period: string) => Promise.resolve(makePayload(period === 'lifetime' ? SAMPLE : changed)))
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(rowForLink(link))
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')

rerender(<PullRequests period="week" provider="all" />)
// The new period drops #781, so the PR set changes and the stale expansion
// resets once the new data lands (wait for the breakdown to disappear).
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
})

it('closes an open expansion on a period switch even when the PR set is identical', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(rowForLink(link))
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')

// Same rows come back for the new period; the expansion must still reset,
// since the row's underlying numbers may differ across periods.
rerender(<PullRequests period="week" provider="all" />)
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
})

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

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
const row = rowForLink(link)
row.focus()

await userEvent.keyboard('{Enter}')
expect(row).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Feature work')).toBeInTheDocument()

await userEvent.keyboard('{Enter}')
expect(row).toHaveAttribute('aria-expanded', 'false')
})

it('states the attributed-total footer and the summable framing', async () => {
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(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30')
})

it('marks an approximate (legacy) row with a ~ prefix and a tooltip', async () => {
const approxPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
],
distinctCost: 12.5,
distinctSessions: 1,
attributedCost: 12.5,
unattributedCost: 0,
}
getOverview.mockResolvedValue(makePayload(approxPayload))
render(<PullRequests period="lifetime" provider="all" />)

const cost = await screen.findByText('~$12.50')
expect(cost).toHaveAttribute('title')
// A zero unattributed remainder hides the muted line.
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
})

it('expands a category-less (legacy) row to a muted note, not an empty box', async () => {
const approxPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
],
distinctCost: 12.5,
distinctSessions: 1,
attributedCost: 12.5,
unattributedCost: 0,
}
getOverview.mockResolvedValue(makePayload(approxPayload))
render(<PullRequests period="lifetime" provider="all" />)

const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#900' })
await userEvent.click(rowForLink(link))
expect(screen.getByText(/No per-turn detail/)).toBeInTheDocument()
})

it('renders the old-CLI by-reference footer without NaN and never claims summable', async () => {
const oldPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/500', label: 'getagentseal/codeburn#500', cost: 120.4, savingsUSD: 0, sessions: 2, calls: 300, firstStarted: '2026-06-01T10:00:00Z', lastEnded: '2026-06-02T12:00:00Z' },
],
distinctCost: 120.4,
distinctSessions: 2,
}
getOverview.mockResolvedValue(makePayload(oldPayload))
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')
expect(note.textContent).toContain('$120.40')
expect(note.textContent).toContain('by reference')
expect(note.textContent).toContain('not summed')
expect(note.textContent).not.toContain('summable')
// No optional field renders as NaN and no unattributed line appears.
expect(screen.queryByText(/NaN/)).toBeNull()
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))
render(<PullRequests period="lifetime" provider="all" />)

expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument()
expect(screen.getByText('$88.40')).toBeInTheDocument()
})

it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
Expand All @@ -101,7 +271,7 @@ describe('PullRequests', () => {
})

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

expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
Expand Down
Loading