feat(app): rebuild Sessions — fixed columns, clean paths, inline expand

- share a shortenProjectPath helper (Sankey + Sessions) so rows show
  "projects/eywa" instead of the dash-mangled full path
- fixed-column table layout so values align and stop shifting row-to-row
- TUI-style inline row expansion: click a row to unfold its 8-stat detail in
  place (one at a time, chevron + aria-expanded); replaces the navigate-away view

typecheck clean; 128/128 tests pass.
This commit is contained in:
iamtoruk 2026-07-12 13:38:45 -07:00
parent 44ef875a6d
commit a02ae15b6b
5 changed files with 93 additions and 73 deletions

View file

@ -1,4 +1,4 @@
import { formatUsd } from '../lib/format'
import { formatUsd, shortenProjectPath } from '../lib/format'
import { isOtherNode, seriesColorForModel } from '../lib/modelSeries'
import type { SpendFlow, SpendFlowNode } from '../lib/types'
@ -152,21 +152,7 @@ function modelDisplayLabel(raw: string): string {
function projectDisplayLabel(raw: string): string {
const value = raw.trim()
if (isOtherNode(value)) return 'Other'
const pathDelimited = /[\\/]/.test(value)
const parts = value.split(pathDelimited ? /[\\/]+/ : /-+/).filter(Boolean)
let displayParts = parts.slice(-3)
// A three-segment tail rooted directly under a home directory starts with
// the user name, which adds noise rather than useful project context.
if (displayParts.length === 3 && /^(users?|home)$/i.test(parts.at(-4) ?? '')) {
displayParts = displayParts.slice(1)
}
if (/^(projects|src|config)$/i.test(displayParts[0] ?? '')) {
displayParts[0] = displayParts[0].toLowerCase()
}
return ellipsize(displayParts.join('/') || value, 24)
return ellipsize(shortenProjectPath(value), 24)
}
function shortenId(value: string): string {

View file

@ -2,6 +2,27 @@ export function formatUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
/** Shorten filesystem and CLI-mangled project paths to their useful trailing segments. */
export function shortenProjectPath(value: string, maxSegments = 3): string {
const trimmed = value.trim()
const pathDelimited = /[\\/]/.test(trimmed)
const parts = trimmed.split(pathDelimited ? /[\\/]+/ : /-+/).filter(Boolean)
let displayParts = parts.slice(-Math.max(1, maxSegments))
// A tail rooted directly under a home directory starts with the user name,
// which adds noise rather than useful project context.
const precedingPart = parts.at(-(displayParts.length + 1))
if (displayParts.length > 1 && /^(users?|home)$/i.test(precedingPart ?? '')) {
displayParts = displayParts.slice(1)
}
if (/^(projects|src|config)$/i.test(displayParts[0] ?? '')) {
displayParts[0] = displayParts[0].toLowerCase()
}
return displayParts.join('/') || trimmed
}
/** Compact token/count formatting: 1_842 → "1.8K", 184_000 → "184K", 1_200_000 → "1.2M". */
export function formatCompact(n: number): string {
if (!Number.isFinite(n)) return '—'

View file

@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -35,7 +35,7 @@ function session(overrides: Partial<SessionRow> & Pick<SessionRow, 'sessionId' |
const rows: SessionRow[] = [
session({
sessionId: 'claude-session-123456789',
project: 'codeburn',
project: '-Users-torukmakto-Projects-codeburn',
provider: 'claude',
models: ['Opus 4.8'],
cost: 8.41,
@ -119,9 +119,11 @@ describe('Sessions', () => {
const { container } = render(<Sessions period="30days" provider="all" />)
expect(await screen.findByText('6 sessions · $21.93 · 4.2M tokens')).toBeInTheDocument()
expect(screen.getByText('Claude · 3 sessions · $10.01')).toBeInTheDocument()
expect(screen.getByText('Codex · 3 sessions · $11.92')).toBeInTheDocument()
expect(screen.getByText('Claude').closest('.provider-h')).toHaveTextContent('Claude3 sessions$10.01')
expect(screen.getByText('Codex').closest('.provider-h')).toHaveTextContent('Codex3 sessions$11.92')
expect(container.querySelectorAll('.session-row')).toHaveLength(6)
expect(screen.getByText('projects/codeburn')).toBeInTheDocument()
expect(screen.queryByText('-Users-torukmakto-Projects-codeburn')).not.toBeInTheDocument()
})
it('filters by project and offers to clear a search with no matches', async () => {
@ -133,7 +135,7 @@ describe('Sessions', () => {
await user.type(search, 'codeb')
expect(screen.getByText('1 sessions · $8.41 · 1.5M tokens')).toBeInTheDocument()
expect(container.querySelectorAll('.session-row')).toHaveLength(1)
expect(screen.getByText('codeburn')).toBeInTheDocument()
expect(screen.getByText('projects/codeburn')).toBeInTheDocument()
expect(screen.queryByText('client-api')).not.toBeInTheDocument()
await user.clear(search)
@ -152,9 +154,9 @@ describe('Sessions', () => {
const { container } = render(<Sessions period="30days" provider="all" />)
await screen.findByText('6 sessions · $21.93 · 4.2M tokens')
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('zeta-search')
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('zeta/search')
await user.click(screen.getByRole('button', { name: 'Turns' }))
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('alpha-worker')
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('alpha/worker')
})
it('turns provider grouping off and back on', async () => {
@ -192,27 +194,33 @@ describe('Sessions', () => {
expect(getSessions).toHaveBeenCalledTimes(1)
})
it('opens the live eight-stat detail and returns to the list with Escape', async () => {
it('expands the live eight-stat detail inline and collapses the row in place', async () => {
const user = userEvent.setup()
getSessions.mockResolvedValue(rows)
const { container } = render(<Sessions period="30days" provider="all" />)
await screen.findByText('6 sessions · $21.93 · 4.2M tokens')
await user.click(screen.getByRole('button', { name: /codeburn/ }))
const row = screen.getByRole('button', { name: /projects\/codeburn/ })
await user.click(row)
expect(screen.getByRole('button', { name: '← Back to sessions' })).toBeInTheDocument()
expect(row).toHaveAttribute('aria-expanded', 'true')
const detail = screen.getByRole('region', { name: 'projects/codeburn session details' })
expect(detail).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '← Back to sessions' })).not.toBeInTheDocument()
expect(screen.getByText('claude · Opus 4.8')).toBeInTheDocument()
expect(screen.getByText(/Jul 11, 2026 → Jul 11, 2026 · 1h 35m/)).toBeInTheDocument()
expect(container.querySelectorAll('.stat')).toHaveLength(8)
expect(container.querySelectorAll('.session-row')).toHaveLength(6)
for (const label of ['Cost', 'Calls', 'Turns', 'Saved', 'Input', 'Output', 'Cache read', 'Cache write']) {
expect(screen.getByText(label)).toBeInTheDocument()
expect(within(detail).getByText(label)).toBeInTheDocument()
}
expect(screen.getByText('44')).toBeInTheDocument()
expect(screen.getByText('44% hit')).toBeInTheDocument()
expect(screen.queryByText('Context window')).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
expect(await screen.findByText('6 sessions · $21.93 · 4.2M tokens')).toBeInTheDocument()
await user.click(row)
expect(row).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('region', { name: 'projects/codeburn session details' })).not.toBeInTheDocument()
expect(container.querySelectorAll('.session-row')).toHaveLength(6)
})

View file

@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { Stat } from '../components/Stat'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd } from '../lib/format'
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { DateRange, Period, SessionRow } from '../lib/types'
@ -80,7 +80,6 @@ export function Sessions({
[period, provider, range?.from, range?.to, refreshToken],
)
const rows = report.data ?? []
const selected = rows.find(row => row.sessionId === selectedId) ?? null
const q = query.trim().toLowerCase()
const filtered = rows.filter(row => q === '' || [
row.project,
@ -146,8 +145,6 @@ export function Sessions({
)
}
if (selected) return <SessionDetail session={selected} onBack={() => setSelectedId(null)} />
if (!report.data.length) {
return (
<Panel title="Sessions">
@ -197,27 +194,35 @@ export function Sessions({
<div className="session-list">
{renderedSequence.map(entry => entry.type === 'header' ? (
<div className="provider-h" key={`provider-${entry.provider}`}>
{providerName(entry.provider)} · {entry.count.toLocaleString('en-US')} sessions · {formatUsd(entry.cost)}
<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>
) : (
<button
className="session-row"
key={entry.row.sessionId}
type="button"
onClick={() => setSelectedId(entry.row.sessionId)}
>
<span className="session-primary">
<span className="session-title">{entry.row.project}</span>
<span className="session-project">{entry.row.sessionId.slice(0, 18)}</span>
</span>
<span className="session-meta">
<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>{entry.row.models.join(', ')}</span>
<span>{entry.row.turns} turns</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)} tok</span>
</span>
</button>
<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>
@ -232,23 +237,22 @@ export function Sessions({
)
}
function SessionDetail({ session, onBack }: { session: SessionRow; onBack: () => void }) {
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') onBack()
if (event.key === 'Escape') onCollapse()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onBack])
}, [onCollapse])
return (
<div className="session-detail">
<button className="back-link" type="button" onClick={onBack}> Back to sessions</button>
<div className="panel detail-head">
<h3 className="detail-title">{session.project}</h3>
<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)}

View file

@ -594,23 +594,28 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.sessions-empty { display: flex; align-items: center; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
.sessions-clear { padding: 0; border: 0; background: transparent; color: var(--mut); font: inherit; font-size: 11.5px; cursor: pointer; }
.sessions-clear:hover, .sessions-clear:focus-visible { color: var(--accent); text-decoration: underline; outline: none; }
.session-list { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: var(--card-shadow); }
.session-provider { display: contents; }
.provider-h { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 8px; padding: 11px 14px 6px; background: var(--panel); color: var(--mut2); font-size: 10.5px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: .05em; text-transform: uppercase; }
.session-row { display: flex; width: 100%; align-items: center; gap: 18px; padding: 11px 14px; border: 0; border-top: 1px solid var(--line2); background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
.session-list { --session-columns: minmax(160px, 1fr) 64px 144px 48px 68px 72px; overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: var(--card-shadow); }
.provider-h, .session-row { display: grid; min-width: 650px; grid-template-columns: var(--session-columns); align-items: center; column-gap: 8px; }
.provider-h { position: sticky; top: 0; z-index: 2; padding: 9px 12px 6px; background: var(--panel); color: var(--mut2); font-size: 10.5px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: .05em; text-transform: uppercase; }
.provider-h > span:not(:first-child) { font-family: var(--mono); letter-spacing: 0; text-align: right; }
.provider-count { grid-column: 4; }
.provider-cost { grid-column: 5; }
.session-row { width: 100%; padding: 9px 12px; border: 0; border-top: 1px solid var(--line2); background: transparent; color: var(--ink); font: inherit; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; cursor: pointer; }
.session-row:hover, .session-row:focus-visible { background: var(--hover); outline: none; }
.session-primary { min-width: 0; flex: 1; }
.session-row:focus-visible { box-shadow: inset 0 0 0 1px var(--accent); }
.session-primary { display: flex; min-width: 0; align-items: center; gap: 7px; font-family: inherit; text-align: left; }
.session-chevron { flex: 0 0 auto; color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; }
.session-row[aria-expanded="true"] .session-chevron { transform: rotate(90deg); }
.session-project-copy { min-width: 0; }
.session-title { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.session-project { display: block; overflow: hidden; margin-top: 2px; color: var(--mut2); font-family: var(--mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.session-meta { display: grid; grid-template-columns: 64px minmax(110px, 1fr) 58px 70px 76px; align-items: center; gap: 10px; color: var(--mut); font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.session-meta span:nth-child(2) { overflow: hidden; text-align: left; text-overflow: ellipsis; }
.session-when { min-width: 64px; color: var(--mut2); font-variant-numeric: tabular-nums; text-align: left; }
.session-row > span:not(:first-child) { overflow: hidden; color: var(--mut); text-overflow: ellipsis; }
.session-when { color: var(--mut2) !important; }
.session-models { text-align: right; }
.sessions-more { width: 100%; padding: 9px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); color: var(--mut); font: inherit; font-size: 11.5px; font-variant-numeric: tabular-nums; cursor: pointer; }
.sessions-more:hover, .sessions-more:focus-visible { border-color: var(--accent); background: var(--hover); color: var(--ink); outline: none; }
.sessions-more-caption { color: var(--mut2); font-size: 10.5px; font-variant-numeric: tabular-nums; }
.session-detail { display: flex; width: 100%; flex-direction: column; gap: 16px; }
.back-link { align-self: flex-start; padding: 0; border: 0; background: transparent; color: var(--accent); font: inherit; font-weight: 520; cursor: pointer; }
.back-link:hover { text-decoration: underline; }
.session-inline-detail { display: flex; min-width: 650px; flex-direction: column; gap: 12px; padding: 12px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 84%, var(--hover)); }
.detail-head { padding: 14px 15px; }
.detail-head h3 { margin: 0 0 6px; font-size: 15px; font-weight: 620; }
.detail-line { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; }
@ -657,8 +662,6 @@ select.cmp-select:focus-visible { border-color: var(--accent); outline: 1px soli
.cmp-turns { color: var(--mut2); }
@media (max-width: 860px) {
.session-meta { grid-template-columns: 64px 90px 52px 62px; }
.session-meta span:last-child { display: none; }
select.cmp-select { min-width: 180px; }
.cmp-pair { grid-template-columns: 1fr; }
.cmp-metric-head, .cmp-metric { grid-template-columns: minmax(110px, 1fr) 76px 76px; }
@ -667,8 +670,6 @@ select.cmp-select:focus-visible { border-color: var(--accent); outline: 1px soli
@media (max-width: 620px) {
.sessions-toolbar, .sessions-search { width: 100%; }
.sessions-search { min-width: 0; }
.session-row { align-items: flex-start; flex-direction: column; gap: 7px; }
.session-meta { width: 100%; grid-template-columns: 64px minmax(80px, 1fr) 52px 62px; }
.cmp-picker { gap: 5px; }
select.cmp-select { min-width: 0; max-width: 42vw; }
.cmp-category { grid-template-columns: 70px minmax(0, 1fr); }