codeburn/app/renderer/sections/Sessions.tsx
iamtoruk e9655fa03c fix: hydration completeness markers, cold-only splash, honest switching, prefetch
P0: an interrupted cold hydration left partial caches that the
emptiness check read as warm — the daily backfill froze (missing older
days) while session parsing healed gradually (drifting totals),
poisoning every surface sharing the cache. Both caches now carry an
explicit complete marker: partial resumes cold under the hydration
lock; unmarked caches self-heal with one re-hydration. Regression test
seeds the exact frozen artifact and asserts bit-for-bit convergence
with a never-interrupted run.

- splash indexing reveals only on genuinely cold scans (explicit cold
  flag in the progress protocol), never on warm incremental re-parses
- uncached filter switches clear to skeleton instead of showing the
  previous filter's numbers; cached switches paint same-commit;
  background prefetch warms every detected provider after idle
- build stamp (git sha + date) in splash and About ends build ambiguity
- payload parity test: identical payloads on identical cache, and
  payload totals equal the CLI report path
- Sessions empty state keeps the provider filter row; zero savings
  line hidden; TCC usage-description strings + Full Disk Access docs
- warm profile documented: optimize scan ~1.47s dominant (safe
  refactor deferred), parse ~1s, aggregation ~30ms

Root 1848, app 320, typechecks clean.
2026-07-16 14:05:11 -07:00

315 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.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">{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>
)
}