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
18 changes: 18 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,20 @@ export type MenubarPayload = {
calls: number
date: string
}>
// Workflow-intelligence rollups (src/menubar-json.ts buildWorkflow /
// buildTopReworkedFiles). Optional: older CLIs omit them, so the Overview
// workflow card renders only when they are present with real signal.
workflow?: {
corrections: number
correctionRate: number | null
medianTimeToFirstEditMs: number | null
}
// Files most reworked by edit-family calls, basename-only, ranked by
// distinct sessions then edits (src/menubar-json.ts buildTopReworkedFiles).
topReworkedFiles?: Array<{ path: string; sessions: number; edits: number }>
// Share (0-1) of cost-bearing calls that resolved a price. Below 1 means some
// usage priced against no table entry; null when not computable.
pricingCoverage?: number | null
retryTax: {
totalUSD: number
retries: number
Expand Down Expand Up @@ -421,6 +435,10 @@ export type ActReportJson = {
// ————— src/sessions-report.ts —————
export type SessionRow = {
sessionId: string
// Captured human title (src/sessions-report.ts). Empty string when the
// transcript produced none; optional so older CLIs that predate the field
// render unchanged (the row falls back to the project as its primary label).
title?: string
project: string
provider: string
models: string[]
Expand Down
110 changes: 110 additions & 0 deletions app/renderer/sections/Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,113 @@ describe('Overview', () => {
expect(within(outcome).getByText('€36.00')).toBeInTheDocument()
})
})

type WorkflowOverrides = {
workflow?: MenubarPayload['current']['workflow']
topReworkedFiles?: MenubarPayload['current']['topReworkedFiles']
pricingCoverage?: MenubarPayload['current']['pricingCoverage']
}

function workflowPayload(now: Date, over: WorkflowOverrides): MenubarPayload {
const base = makePayload(now)
return { ...base, current: { ...base.current, ...over } }
}

describe('Overview workflow card', () => {
beforeEach(() => {
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
getOverview.mockReset()
getActReport.mockReset()
getYield.mockReset()
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
getYield.mockResolvedValue(makeYieldReport())
})
afterEach(() => vi.useRealTimers())

function workflowRegion(): HTMLElement {
return screen.getByRole('heading', { name: 'Workflow' }).closest('.ov-workflow-widget') as HTMLElement
}

it('renders correction rate, time to first edit, top rework, coverage chip, and a coaching note', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 7, correctionRate: 0.2, medianTimeToFirstEditMs: 45_000 },
topReworkedFiles: [{ path: 'parser.ts', sessions: 4, edits: 12 }],
pricingCoverage: 0.92,
}))

render(<Overview period="30days" provider="all" />)

const card = await waitFor(() => workflowRegion())
expect(within(card).getByText('Correction rate')).toBeInTheDocument()
expect(within(card).getByText('20%')).toBeInTheDocument()
expect(within(card).getByText('7 corrections')).toBeInTheDocument()
expect(within(card).getByText('Time to first edit')).toBeInTheDocument()
// Under 60s renders as seconds, not minutes.
expect(within(card).getByText('45s')).toBeInTheDocument()
expect(within(card).getByText(/Top rework:/)).toHaveTextContent('Top rework: parser.ts · 4 sessions · 12 edits')
// pricingCoverage 0.92 → a "92% priced" caveat chip.
expect(within(card).getByText('92% priced')).toBeInTheDocument()
// Corrections clears its bar first, so its coaching line wins.
expect(within(card).getByText(/You corrected the assistant on 20% of prompts \(7 times\)/)).toBeInTheDocument()
})

it('does not render at all when the payload carries no workflow signal', async () => {
const now = new Date()
// makePayload omits workflow/topReworkedFiles/pricingCoverage entirely.
getOverview.mockResolvedValue(makePayload(now))

render(<Overview period="30days" provider="all" />)

await screen.findByText('$312.40')
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
})

it('stays hidden when workflow exists but every metric is empty', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 0, correctionRate: null, medianTimeToFirstEditMs: null },
topReworkedFiles: [],
pricingCoverage: 1,
}))

render(<Overview period="30days" provider="all" />)

await screen.findByText('$312.40')
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
})

it('picks the churn note and formats minutes when corrections are below the bar', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 8 * 60 * 1000 },
topReworkedFiles: [{ path: 'router.ts', sessions: 5, edits: 30 }],
pricingCoverage: null,
}))

render(<Overview period="30days" provider="all" />)

const card = await waitFor(() => workflowRegion())
// >= 60s renders as whole minutes.
expect(within(card).getByText('8m')).toBeInTheDocument()
// Corrections (5%) is below 0.15, so the churn note wins over TTFE.
expect(within(card).getByText(/router\.ts was reworked across 5 sessions \(30 edits\)/)).toBeInTheDocument()
// pricingCoverage null → no chip.
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
})

it('falls back to a neutral caption and hides the chip at full coverage when no note fires', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 30_000 },
topReworkedFiles: [{ path: 'small.ts', sessions: 1, edits: 2 }],
pricingCoverage: 1,
}))

render(<Overview period="30days" provider="all" />)

const card = await waitFor(() => workflowRegion())
expect(within(card).getByText('Corrections, first-edit latency, and file churn across your sessions.')).toBeInTheDocument()
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
})
})
84 changes: 84 additions & 0 deletions app/renderer/sections/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,88 @@ function CostPerOutcome({ outcome }: { outcome: Polled<YieldJsonReport> }) {
)
}

// Coaching-note thresholds, mirrored from the CLI so the card and the CLI never
// disagree (src/workflow-insights.ts buildCoachingNotes).
const WORKFLOW_CORRECTION_RATE = 0.15
const WORKFLOW_CORRECTION_COUNT = 3
const WORKFLOW_CHURN_SESSIONS = 3
const WORKFLOW_TTFE_SLOW_MS = 5 * 60 * 1000

/** Median time to first edit: `<60s → Ns`, else `Nm` (src/workflow-insights.ts formatDurationShort). */
function formatWorkflowDuration(ms: number): string {
if (ms >= 60_000) return `${Math.round(ms / 60_000)}m`
return `${Math.round(ms / 1000)}s`
}

type WorkflowRollup = NonNullable<MenubarPayload['current']['workflow']>
type ReworkedFile = { path: string; sessions: number; edits: number }

/**
* One coaching line derived with the CLI's thresholds and dry copy voice
* (src/workflow-insights.ts buildCoachingNotes): corrections, then file churn,
* then time-to-first-edit; the first that fires. Null when none clears its bar.
*/
function workflowCoachingNote(workflow: WorkflowRollup, topReworked?: ReworkedFile): string | null {
const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow
if (correctionRate !== null && correctionRate >= WORKFLOW_CORRECTION_RATE && corrections >= WORKFLOW_CORRECTION_COUNT) {
return `You corrected the assistant on ${Math.round(correctionRate * 100)}% of prompts (${corrections} times). State the requirements in the first message to cut the back and forth.`
}
if (topReworked && topReworked.sessions >= WORKFLOW_CHURN_SESSIONS) {
return `${topReworked.path} was reworked across ${topReworked.sessions} sessions (${topReworked.edits} edits). A focused pass on it may cost less than the repeated churn.`
}
if (medianTimeToFirstEditMs !== null && medianTimeToFirstEditMs >= WORKFLOW_TTFE_SLOW_MS) {
return `Median time to first edit is ${formatWorkflowDuration(medianTimeToFirstEditMs)}. Point the assistant at the target file to cut the exploration before it starts editing.`
}
return null
}

function WorkflowCard({ current }: { current: MenubarPayload['current'] }) {
const workflow = current.workflow
const topReworked = current.topReworkedFiles?.[0]
// Hide when there is no real signal: never show a card of zeros.
const hasSignal = !!workflow && (
workflow.correctionRate !== null ||
workflow.medianTimeToFirstEditMs !== null ||
workflow.corrections > 0 ||
!!topReworked
)
if (!workflow || !hasSignal) return null

const coverage = current.pricingCoverage
const showCoverage = typeof coverage === 'number' && coverage < 1
const note = workflowCoachingNote(workflow, topReworked)
const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow

return (
<div className="ov-card ov-panel ov-workflow-widget">
<div className="ov-panel-head">
<h3>Workflow</h3>
{showCoverage && <span className="ov-priced-chip">{Math.min(99, Math.round(coverage * 100))}% priced</span>}
</div>
<div className="ov-panel-body">
<div className="ov-outcome-metrics">
<div>
<span>Correction rate</span>
<strong>{correctionRate === null ? '—' : `${Math.round(correctionRate * 100)}%`}</strong>
{correctionRate !== null && <span>{corrections} {corrections === 1 ? 'correction' : 'corrections'}</span>}
</div>
<div>
<span>Time to first edit</span>
<strong>{medianTimeToFirstEditMs === null ? '—' : formatWorkflowDuration(medianTimeToFirstEditMs)}</strong>
<span>median</span>
</div>
</div>
{topReworked && (
<div className="ov-workflow-rework">
Top rework: <strong>{topReworked.path}</strong> · {topReworked.sessions} {topReworked.sessions === 1 ? 'session' : 'sessions'} · {topReworked.edits} {topReworked.edits === 1 ? 'edit' : 'edits'}
</div>
)}
<p className="ov-widget-caption">{note ?? 'Corrections, first-edit latency, and file churn across your sessions.'}</p>
</div>
</div>
)
}

export type Signal = { text: string; trailing?: string }
export type SignalGroups = { wins: Signal[]; improvements: Signal[]; risks: Signal[] }

Expand Down Expand Up @@ -659,6 +741,8 @@ export function OverviewContent({
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} dataStart={dataStartKey(data.history.daily)} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>

<WorkflowCard current={data.current} />

<div className="ov-insight-band">
<div className="ov-coach">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>
Expand Down
32 changes: 32 additions & 0 deletions app/renderer/sections/Sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,36 @@ describe('Sessions', () => {
expect(within(filter).getByRole('button', { name: 'Codex' })).toHaveAttribute('aria-pressed', 'true')
expect(within(filter).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
})

it('headlines a captured title, demotes the id, and falls back to the project when untitled', async () => {
getSessions.mockResolvedValue([
session({ sessionId: 'claude-abc123456789xyz', project: 'codeburn', provider: 'claude', title: 'Fix lifetime period in menubar labels', cost: 5 }),
session({ sessionId: 'claude-untitled-000000', project: 'docs-site', provider: 'claude', title: '', cost: 3 }),
])
const { container } = render(<Sessions period="30days" provider="all" />)
await screen.findByText('2 sessions · $8.00 · 2K tokens')

const titles = [...container.querySelectorAll('.session-row .session-title')]
const ids = [...container.querySelectorAll('.session-row .session-project')]
// Titled row (higher cost, first): the title is the headline, the id its mono secondary line.
expect(titles[0]).toHaveTextContent('Fix lifetime period in menubar labels')
expect(ids[0]).toHaveTextContent('claude-abc')
// Untitled row: unchanged behavior, the project (via shortenProjectPath) stays the headline.
expect(titles[1]).toHaveTextContent('docs/site')
expect(ids[1]).toHaveTextContent('claude-untitled')
})

it('matches sessions by their captured title', async () => {
const user = userEvent.setup()
getSessions.mockResolvedValue([
session({ sessionId: 'claude-1', project: 'codeburn', provider: 'claude', title: 'Refactor the parser cache', cost: 5 }),
session({ sessionId: 'codex-2', project: 'client-api', provider: 'codex', title: 'Add billing webhook', cost: 3 }),
])
const { container } = render(<Sessions period="30days" provider="all" />)
const search = await screen.findByRole('textbox', { name: 'Search sessions' })

await user.type(search, 'webhook')
expect(container.querySelectorAll('.session-row')).toHaveLength(1)
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('Add billing webhook')
})
})
3 changes: 2 additions & 1 deletion app/renderer/sections/Sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function Sessions({
const rows = report.data ?? []
const q = query.trim().toLowerCase()
const filtered = rows.filter(row => q === '' || [
row.title ?? '',
row.project,
row.sessionId,
row.models.join(' '),
Expand Down Expand Up @@ -251,7 +252,7 @@ export function Sessions({
<span className="session-primary">
<span className="session-chevron" aria-hidden="true">›</span>
<span className="session-project-copy">
<span className="session-title">{shortenProjectPath(entry.row.project)}</span>
<span className="session-title" title={entry.row.title || undefined}>{entry.row.title || shortenProjectPath(entry.row.project)}</span>
<span className="session-project">{entry.row.sessionId.slice(0, 18)}</span>
</span>
</span>
Expand Down
3 changes: 3 additions & 0 deletions app/renderer/styles/plain.css
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-outcome-metrics span { color: var(--mut); font-size: 10px; line-height: 1.25; }
.ov-outcome-metrics strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: var(--fs-kpi); font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.ov-outcome-split { margin-top: 9px; color: var(--mut); font-size: 10.5px; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-priced-chip { flex: 0 0 auto; margin-left: auto; display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); font-size: 10px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-workflow-rework { margin-top: 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-workflow-rework strong { color: var(--ink); font-family: var(--mono); font-weight: 600; }
.ov-routing { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 13px; }
.ov-routing p { margin: 4px 0 0; color: var(--mut); font-size: 11.5px; line-height: 1.4; }
.ov-routing p strong { color: var(--ink); font-weight: 620; }
Expand Down