codeburn/app/renderer/sections/Sessions.tsx
Resham Joshi 4d2bcbac7f
app: Workflow card on Overview and session titles in the Sessions view (#784)
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.

Co-authored-by: reviewer <review@local>
2026-07-20 13:55:33 -07:00

316 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useEffect, useMemo, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { ProviderLogo } from '../components/ProviderLogo'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { Stat } from '../components/Stat'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { DateRange, Period, SessionRow } from '../lib/types'
export const INITIAL_VISIBLE = 120
const STEP = 120
type SessionSort = 'cost' | 'recent' | 'turns' | 'tokens'
type SequenceEntry =
| { type: 'header'; provider: string; count: number; cost: number }
| { type: 'row'; row: SessionRow }
const SORT_OPTIONS = [
{ value: 'cost', label: 'Cost' },
{ value: 'recent', label: 'Recent' },
{ value: 'turns', label: 'Turns' },
{ value: 'tokens', label: 'Tokens' },
]
function providerName(provider: string): string {
return provider
.split(/[-\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function endedAtTime(row: SessionRow): number {
const time = new Date(row.endedAt).getTime()
return Number.isNaN(time) ? 0 : time
}
function compareRows(sort: SessionSort, a: SessionRow, b: SessionRow): number {
if (sort === 'cost') return b.cost - a.cost
if (sort === 'turns') return b.turns - a.turns
if (sort === 'tokens') {
return (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens)
}
return endedAtTime(b) - endedAtTime(a)
}
function groupSortValue(sort: SessionSort, rows: SessionRow[]): number {
if (sort === 'cost') return rows.reduce((sum, row) => sum + row.cost, 0)
if (sort === 'turns') return rows.reduce((sum, row) => sum + row.turns, 0)
if (sort === 'tokens') {
return rows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0)
}
return rows.reduce((latest, row) => Math.max(latest, endedAtTime(row)), 0)
}
function ProviderFilterRow({
provider,
detectedProviders,
onProviderChange,
}: {
provider: string
detectedProviders: Array<{ id: string; label: string }>
onProviderChange: (value: string) => void
}) {
if (detectedProviders.length === 0) return null
return (
<div className="seg session-provider-filter" role="group" aria-label="Filter sessions by provider">
<button
type="button"
className={provider === 'all' ? 'on' : undefined}
aria-pressed={provider === 'all'}
onClick={() => onProviderChange('all')}
>
All
</button>
{detectedProviders.map(entry => (
<button
key={entry.id}
type="button"
className={provider === entry.id ? 'on' : undefined}
aria-pressed={provider === entry.id}
onClick={() => onProviderChange(entry.id)}
>
<ProviderLogo provider={entry.id} size={14} />
{entry.label}
</button>
))}
</div>
)
}
export function Sessions({
period,
provider,
range = null,
refreshToken = 0,
detectedProviders = [],
onProviderChange = () => {},
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
detectedProviders?: Array<{ id: string; label: string }>
onProviderChange?: (value: string) => void
ready?: boolean
}) {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [sort, setSort] = useState<SessionSort>('cost')
const [grouped, setGrouped] = useState(true)
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE)
const report = usePolled<SessionRow[]>(
() => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `sessions|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
)
const rows = report.data ?? []
const q = query.trim().toLowerCase()
const filtered = rows.filter(row => q === '' || [
row.title ?? '',
row.project,
row.sessionId,
row.models.join(' '),
].some(value => value.toLowerCase().includes(q)))
useEffect(() => {
setVisibleCount(INITIAL_VISIBLE)
}, [query, sort, grouped, report.data])
const sequence = useMemo<SequenceEntry[]>(() => {
if (!grouped) {
return [...filtered]
.sort((a, b) => compareRows(sort, a, b))
.map(row => ({ type: 'row' as const, row }))
}
const byProvider = filtered.reduce((map, row) => {
const providerRows = map.get(row.provider) ?? []
providerRows.push(row)
map.set(row.provider, providerRows)
return map
}, new Map<string, SessionRow[]>())
return [...byProvider.entries()]
.map(([providerName, providerRows]) => ({
provider: providerName,
rows: [...providerRows].sort((a, b) => compareRows(sort, a, b)),
cost: providerRows.reduce((sum, row) => sum + row.cost, 0),
sortValue: groupSortValue(sort, providerRows),
}))
.sort((a, b) => b.sortValue - a.sortValue || a.provider.localeCompare(b.provider))
.flatMap(group => [
{ type: 'header' as const, provider: group.provider, count: group.rows.length, cost: group.cost },
...group.rows.map(row => ({ type: 'row' as const, row })),
])
}, [filtered, grouped, sort])
const renderedSequence: SequenceEntry[] = []
let renderedRows = 0
let pendingHeader: SequenceEntry | null = null
for (const entry of sequence) {
if (entry.type === 'header') {
pendingHeader = entry
continue
}
if (renderedRows >= visibleCount) break
if (pendingHeader) {
renderedSequence.push(pendingHeader)
pendingHeader = null
}
renderedSequence.push(entry)
renderedRows++
}
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="sessions" />
return <SectionSkeleton label="Scanning sessions…" rows={5} />
}
if (!report.data.length) {
return (
<Panel title="Sessions">
<ProviderFilterRow provider={provider} detectedProviders={detectedProviders} onProviderChange={onProviderChange} />
<EmptyNote>No sessions in this range yet.</EmptyNote>
</Panel>
)
}
const totalCost = filtered.reduce((sum, row) => sum + row.cost, 0)
const totalTokens = filtered.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0)
const remaining = filtered.length - renderedRows
return (
<div className="sessions-list-view">
{report.error && <StaleBanner error={report.error} />}
<ProviderFilterRow provider={provider} detectedProviders={detectedProviders} onProviderChange={onProviderChange} />
<div className="sessions-toolbar">
<input
className="sessions-search"
aria-label="Search sessions"
placeholder="Search project, model, or id…"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<SegTabs
options={SORT_OPTIONS}
value={sort}
onChange={value => setSort(value as SessionSort)}
/>
<button
className="sessions-toggle"
type="button"
aria-pressed={grouped}
onClick={() => setGrouped(value => !value)}
>
Group by provider
</button>
</div>
<div className="sessions-summary">
{filtered.length} sessions · {formatUsd(totalCost)} · {formatCompact(totalTokens)} tokens
</div>
{filtered.length === 0 ? (
<div className="sessions-empty">
<EmptyNote>No sessions match &quot;{query}&quot;.</EmptyNote>
<button className="sessions-clear" type="button" onClick={() => setQuery('')}>Clear search</button>
</div>
) : (
<>
<div className="session-list">
{renderedSequence.map(entry => entry.type === 'header' ? (
<div className="provider-h" key={`provider-${entry.provider}`}>
<span>{providerName(entry.provider)}</span>
<span className="provider-count">{entry.count.toLocaleString('en-US')} sessions</span>
<span className="provider-cost">{formatUsd(entry.cost)}</span>
</div>
) : (
<Fragment key={entry.row.sessionId}>
<button
className="session-row"
type="button"
aria-expanded={selectedId === entry.row.sessionId}
onClick={() => setSelectedId(current => current === entry.row.sessionId ? null : entry.row.sessionId)}
>
<span className="session-primary">
<span className="session-chevron" aria-hidden="true"></span>
<span className="session-project-copy">
<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>
<span className="session-when">{formatDayShort(entry.row.endedAt)}</span>
<span className="session-models">{entry.row.models.join(', ')}</span>
<span>{entry.row.turns}</span>
<span>{formatUsd(entry.row.cost)}</span>
<span>{formatCompact(entry.row.inputTokens + entry.row.outputTokens)}</span>
</button>
{selectedId === entry.row.sessionId && (
<SessionDetail session={entry.row} onCollapse={() => setSelectedId(null)} />
)}
</Fragment>
))}
</div>
<div className="sessions-more-caption">Showing {renderedRows} of {filtered.length}</div>
{remaining > 0 && (
<button className="sessions-more" type="button" onClick={() => setVisibleCount(value => value + STEP)}>
Show {Math.min(STEP, remaining)} more · {remaining} remaining
</button>
)}
</>
)}
</div>
)
}
function SessionDetail({ session, onCollapse }: { session: SessionRow; onCollapse: () => void }) {
const cacheTotal = session.inputTokens + session.cacheReadTokens
const cacheHit = cacheTotal > 0 ? Math.round(session.cacheReadTokens / cacheTotal * 100) : 0
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCollapse()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onCollapse])
return (
<div className="session-inline-detail" role="region" aria-label={`${shortenProjectPath(session.project)} session details`}>
<div className="detail-head">
<h3 className="detail-title">{shortenProjectPath(session.project)}</h3>
<div className="detail-line">{session.provider} · {session.models.join(', ')}</div>
<div className="detail-line">
{formatDayLong(session.startedAt)} {formatDayLong(session.endedAt)} · {formatDuration(session.durationMs)}
</div>
</div>
<div className="stats">
<Stat label="Cost" value={formatUsd(session.cost)} delta="this session" />
<Stat label="Calls" value={session.calls.toLocaleString()} delta="API calls" />
<Stat label="Turns" value={session.turns.toLocaleString()} delta="assistant turns" />
<Stat label="Saved" value={formatUsd(session.savingsUSD)} delta="vs baseline" />
<Stat label="Input" value={formatCompact(session.inputTokens)} delta="tokens sent" />
<Stat label="Output" value={formatCompact(session.outputTokens)} delta="tokens generated" />
<Stat label="Cache read" value={formatCompact(session.cacheReadTokens)} delta={`${cacheHit}% hit`} />
<Stat label="Cache write" value={formatCompact(session.cacheWriteTokens)} delta="tokens cached" />
</div>
</div>
)
}