mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 23:24:32 +00:00
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:
parent
9676f53d0c
commit
8a1ef59056
6 changed files with 249 additions and 1 deletions
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue