mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 20:05:15 +00:00
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:
parent
44ef875a6d
commit
a02ae15b6b
5 changed files with 93 additions and 73 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 '—'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue