diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 9a87f25..28649b2 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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[] diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index f76e498..3138792 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 66ac77e..06cb9c6 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -129,6 +129,88 @@ function CostPerOutcome({ outcome }: { outcome: Polled }) { ) } +// 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 +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 ( +
+
+

Workflow

+ {showCoverage && {Math.min(99, Math.round(coverage * 100))}% priced} +
+
+
+
+ Correction rate + {correctionRate === null ? '—' : `${Math.round(correctionRate * 100)}%`} + {correctionRate !== null && {corrections} {corrections === 1 ? 'correction' : 'corrections'}} +
+
+ Time to first edit + {medianTimeToFirstEditMs === null ? '—' : formatWorkflowDuration(medianTimeToFirstEditMs)} + median +
+
+ {topReworked && ( +
+ Top rework: {topReworked.path} · {topReworked.sessions} {topReworked.sessions === 1 ? 'session' : 'sessions'} · {topReworked.edits} {topReworked.edits === 1 ? 'edit' : 'edits'} +
+ )} +

{note ?? 'Corrections, first-edit latency, and file churn across your sessions.'}

+
+
+ ) +} + export type Signal = { text: string; trailing?: string } export type SignalGroups = { wins: Signal[]; improvements: Signal[]; risks: Signal[] } @@ -659,6 +741,8 @@ export function OverviewContent({
{data.history.daily.length ? : No spend yet.}
+ +
diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 8b38ed0..710daf0 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -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() + 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() + 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') + }) }) diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 4baea6e..3d0297e 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -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({ - {shortenProjectPath(entry.row.project)} + {entry.row.title || shortenProjectPath(entry.row.project)} {entry.row.sessionId.slice(0, 18)} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index f666e05..27af73f 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; }