app: Workflow card on Overview and session titles in the Sessions view

Two Phase-1 desktop additions over the renderer's existing card grammar.

Overview workflow card: a panel below the daily chart with two stat tiles
(correction rate with count, median time to first edit) plus a top-rework
line and one coaching caption. The caption is derived locally with the CLI's
buildCoachingNotes thresholds (corrections, then file churn, then time to
first edit; first that fires). The card hides entirely when no real signal is
present, never renders zeros, and shows a "N% priced" chip only when pricing
coverage is a number below 1. Payload types gain optional workflow,
topReworkedFiles, and pricingCoverage fields so older payloads render as before.

Sessions view: the captured session title becomes the row headline with the id
demoted to the mono secondary line; untitled rows keep the project as the
headline (unchanged). Title is also searchable and added as an optional
SessionRow field so older CLIs render unchanged.
This commit is contained in:
reviewer 2026-07-20 22:23:20 +02:00
parent 9676f53d0c
commit 8a1ef59056
6 changed files with 249 additions and 1 deletions

View file

@ -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
@ -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[]

View file

@ -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()
})
})

View file

@ -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[] }
@ -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>

View file

@ -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')
})
})

View file

@ -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(' '),
@ -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>

View file

@ -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; }