mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 06:54:26 +00:00
feat(app): production-ready Sessions screen
Search (project/model/id), sort (cost/recent/turns/tokens), group-by-
provider toggle, and a summary bar (count · cost · tokens) over the
filtered set. Dependency-free "show more" paging keeps large lists fast;
provider headers are sticky and the list flows in the page scroll.
Richer detail: 8 real stat cards (cost/calls/turns/saved/input/output/
cache read+hit%/cache write), full date range + duration, Esc-to-back,
selection tracked by id so background refreshes keep the detail live.
Centralizes formatCompact/date/duration helpers in lib/format ("184K"
not "0.2M"); Compare now uses the shared helper. Search-aware empty state.
This commit is contained in:
parent
e331c7da63
commit
d9c56e3c6b
7 changed files with 444 additions and 97 deletions
35
app/renderer/lib/format.test.ts
Normal file
35
app/renderer/lib/format.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatCompact, formatDayLong, formatDayShort, formatDuration } from './format'
|
||||
|
||||
describe('formatCompact', () => {
|
||||
it('formats zero, plain counts, thousands, and millions compactly', () => {
|
||||
expect(formatCompact(0)).toBe('0')
|
||||
expect(formatCompact(842)).toBe('842')
|
||||
expect(formatCompact(1_842)).toBe('1.8K')
|
||||
expect(formatCompact(184_000)).toBe('184K')
|
||||
expect(formatCompact(1_200_000)).toBe('1.2M')
|
||||
})
|
||||
|
||||
it('trims trailing decimals and rejects non-finite values', () => {
|
||||
expect(formatCompact(2_000_000)).toBe('2M')
|
||||
expect(formatCompact(Number.NaN)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date and duration formatters', () => {
|
||||
it('formats short and long calendar dates and handles invalid input', () => {
|
||||
const date = '2026-07-10T12:00:00'
|
||||
expect(formatDayShort(date)).toBe('Jul 10')
|
||||
expect(formatDayLong(date)).toBe('Jul 10, 2026')
|
||||
expect(formatDayShort('not-a-date')).toBe('—')
|
||||
expect(formatDayLong('not-a-date')).toBe('—')
|
||||
})
|
||||
|
||||
it('formats seconds, minutes, hours, and invalid durations', () => {
|
||||
expect(formatDuration(29_000)).toBe('29s')
|
||||
expect(formatDuration(47 * 60_000)).toBe('47m')
|
||||
expect(formatDuration(134 * 60_000)).toBe('2h 14m')
|
||||
expect(formatDuration(0)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,41 @@
|
|||
export function formatUsd(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
/** 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 '—'
|
||||
if (n === 0) return '0'
|
||||
const abs = Math.abs(n)
|
||||
if (abs < 1_000) return String(Math.round(n))
|
||||
if (abs < 1_000_000) return `${trim(n / 1_000)}K`
|
||||
if (abs < 1_000_000_000) return `${trim(n / 1_000_000)}M`
|
||||
return `${trim(n / 1_000_000_000)}B`
|
||||
}
|
||||
|
||||
// One decimal, but drop a trailing ".0" (184.0K → "184K", 1.2K stays "1.2K").
|
||||
function trim(v: number): string {
|
||||
const s = v.toFixed(1)
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s
|
||||
}
|
||||
|
||||
/** "Jul 10" — short month + day, no year. */
|
||||
export function formatDayShort(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** "Jul 10, 2026" — full date. */
|
||||
export function formatDayLong(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** "2h 14m" / "47m" / "38s" from a duration in ms. */
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
const totalMin = Math.round(ms / 60_000)
|
||||
if (totalMin < 1) return `${Math.round(ms / 1000)}s`
|
||||
if (totalMin < 60) return `${totalMin}m`
|
||||
return `${Math.floor(totalMin / 60)}h ${totalMin % 60}m`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe('Compare', () => {
|
|||
expect(screen.getByText('$0.03')).toHaveClass('cmp-best')
|
||||
expect(screen.getByText('$331.20')).toBeInTheDocument()
|
||||
expect(screen.getByText('152.6M')).toBeInTheDocument()
|
||||
expect(screen.getByText('9.64M')).toBeInTheDocument()
|
||||
expect(screen.getByText('9.6M')).toBeInTheDocument()
|
||||
|
||||
const context = screen.getByText('Context').closest<HTMLElement>('.cmp-card')!
|
||||
expect(within(context).getByText('Cache hit rate')).toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -3,19 +3,10 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { CliErrorPanel } from '../components/CliErrorPanel'
|
||||
import { Panel } from '../components/Panel'
|
||||
import { usePolled } from '../hooks/usePolled'
|
||||
import { formatUsd } from '../lib/format'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { CompareJsonReport, ComparisonRow, ModelStats, Period, WorkingStyleRow } from '../lib/types'
|
||||
|
||||
function fmtCompact(n: number): string {
|
||||
if (n === 0) return '0'
|
||||
if (n < 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: n >= 10_000_000 ? 1 : 2,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
|
||||
if (v === null) return '—'
|
||||
if (fn === 'cost') return formatUsd(v)
|
||||
|
|
@ -254,8 +245,8 @@ function ContextCard({ modelA, modelB }: { modelA: ModelStats; modelB: ModelStat
|
|||
const rows = [
|
||||
['Calls', modelA.calls.toLocaleString(), modelB.calls.toLocaleString()],
|
||||
['Total cost', formatUsd(modelA.cost), formatUsd(modelB.cost)],
|
||||
['Input tokens', fmtCompact(modelA.inputTokens), fmtCompact(modelB.inputTokens)],
|
||||
['Output tokens', fmtCompact(modelA.outputTokens), fmtCompact(modelB.outputTokens)],
|
||||
['Input tokens', formatCompact(modelA.inputTokens), formatCompact(modelB.inputTokens)],
|
||||
['Output tokens', formatCompact(modelA.outputTokens), formatCompact(modelB.outputTokens)],
|
||||
['Edit turns', modelA.editTurns.toLocaleString(), modelB.editTurns.toLocaleString()],
|
||||
['Self-corrections', modelA.selfCorrections.toLocaleString(), modelB.selfCorrections.toLocaleString()],
|
||||
['Cache hit rate', cacheHitRate(modelA), cacheHitRate(modelB)],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionRow } from '../lib/types'
|
||||
import { Sessions } from './Sessions'
|
||||
import { INITIAL_VISIBLE, Sessions } from './Sessions'
|
||||
|
||||
const { getSessions } = vi.hoisted(() => ({
|
||||
getSessions: vi.fn<(period: string, provider: string) => Promise<SessionRow[]>>(),
|
||||
|
|
@ -13,8 +14,26 @@ vi.mock('../lib/ipc', async orig => {
|
|||
return { ...actual, codeburn: { getSessions } }
|
||||
})
|
||||
|
||||
function session(overrides: Partial<SessionRow> & Pick<SessionRow, 'sessionId' | 'project' | 'provider'>): SessionRow {
|
||||
return {
|
||||
models: ['Default model'],
|
||||
cost: 0,
|
||||
savingsUSD: 0,
|
||||
calls: 1,
|
||||
turns: 1,
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
startedAt: '2026-07-01T10:00:00.000Z',
|
||||
endedAt: '2026-07-01T10:01:00.000Z',
|
||||
durationMs: 60_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const rows: SessionRow[] = [
|
||||
{
|
||||
session({
|
||||
sessionId: 'claude-session-123456789',
|
||||
project: 'codeburn',
|
||||
provider: 'claude',
|
||||
|
|
@ -30,51 +49,171 @@ const rows: SessionRow[] = [
|
|||
startedAt: '2026-07-11T10:00:00.000Z',
|
||||
endedAt: '2026-07-11T11:35:00.000Z',
|
||||
durationMs: 5_700_000,
|
||||
},
|
||||
{
|
||||
}),
|
||||
session({
|
||||
sessionId: 'codex-session-987654321',
|
||||
project: 'client-api',
|
||||
provider: 'codex',
|
||||
models: ['GPT-5.5 Codex'],
|
||||
cost: 3.92,
|
||||
savingsUSD: 0,
|
||||
calls: 25,
|
||||
turns: 22,
|
||||
inputTokens: 120_000,
|
||||
outputTokens: 16_000,
|
||||
cacheReadTokens: 40_000,
|
||||
cacheWriteTokens: 4_000,
|
||||
startedAt: '2026-07-10T10:00:00.000Z',
|
||||
endedAt: '2026-07-10T10:30:00.000Z',
|
||||
durationMs: 1_800_000,
|
||||
},
|
||||
}),
|
||||
session({
|
||||
sessionId: 'claude-alpha-session',
|
||||
project: 'alpha-worker',
|
||||
provider: 'claude',
|
||||
models: ['Haiku 4.5'],
|
||||
cost: 1.10,
|
||||
turns: 80,
|
||||
inputTokens: 45_000,
|
||||
outputTokens: 5_000,
|
||||
endedAt: '2026-07-09T08:00:00.000Z',
|
||||
}),
|
||||
session({
|
||||
sessionId: 'codex-zeta-session',
|
||||
project: 'zeta-search',
|
||||
provider: 'codex',
|
||||
models: ['GPT-5.5 Codex'],
|
||||
cost: 6,
|
||||
turns: 10,
|
||||
inputTokens: 1_900_000,
|
||||
outputTokens: 100_000,
|
||||
endedAt: '2026-07-12T08:00:00.000Z',
|
||||
}),
|
||||
session({
|
||||
sessionId: 'claude-docs-session',
|
||||
project: 'docs-site',
|
||||
provider: 'claude',
|
||||
models: ['Sonnet 4.6'],
|
||||
cost: 0.50,
|
||||
turns: 5,
|
||||
inputTokens: 8_000,
|
||||
outputTokens: 2_000,
|
||||
endedAt: '2026-07-08T08:00:00.000Z',
|
||||
}),
|
||||
session({
|
||||
sessionId: 'codex-tools-session',
|
||||
project: 'tools-service',
|
||||
provider: 'codex',
|
||||
models: ['GPT-5.4 Mini'],
|
||||
cost: 2,
|
||||
turns: 30,
|
||||
inputTokens: 450_000,
|
||||
outputTokens: 50_000,
|
||||
endedAt: '2026-07-07T08:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
|
||||
describe('Sessions', () => {
|
||||
beforeEach(() => getSessions.mockReset())
|
||||
|
||||
it('groups real rows by provider and opens the four-stat detail', async () => {
|
||||
it('shows a summary of every filtered session and groups providers', async () => {
|
||||
getSessions.mockResolvedValue(rows)
|
||||
const { container } = render(<Sessions period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText('Claude · 1 session · $8.41')).toBeInTheDocument()
|
||||
expect(screen.getByText('Codex · 1 session · $3.92')).toBeInTheDocument()
|
||||
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(container.querySelectorAll('.session-row')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('filters by project and offers to clear a search with no matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
getSessions.mockResolvedValue(rows)
|
||||
const { container } = render(<Sessions period="30days" provider="all" />)
|
||||
const search = await screen.findByRole('textbox', { name: 'Search 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('client-api')).toBeInTheDocument()
|
||||
expect(screen.getByText('$8.41')).toBeInTheDocument()
|
||||
expect(screen.getByText('$3.92')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude-session-123')).toBeInTheDocument()
|
||||
expect(screen.queryByText('client-api')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /codeburn/ }))
|
||||
await user.clear(search)
|
||||
await user.type(search, 'nothing-here')
|
||||
expect(screen.getByText('No sessions match "nothing-here".')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.session-row')).toHaveLength(0)
|
||||
|
||||
expect(await screen.findByRole('button', { name: '← Back to sessions' })).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.stat')).toHaveLength(4)
|
||||
for (const label of ['Cost', 'Input', 'Output', 'Cache read']) {
|
||||
await user.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.getByText('6 sessions · $21.93 · 4.2M tokens')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.session-row')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('reorders rows when the sort changes', 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')
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('turns provider grouping off and back on', async () => {
|
||||
const user = userEvent.setup()
|
||||
getSessions.mockResolvedValue(rows)
|
||||
const { container } = render(<Sessions period="30days" provider="all" />)
|
||||
const toggle = await screen.findByRole('button', { name: 'Group by provider' })
|
||||
|
||||
expect(container.querySelectorAll('.provider-h')).toHaveLength(2)
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(container.querySelectorAll('.provider-h')).toHaveLength(0)
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(container.querySelectorAll('.provider-h')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('caps a large list and reveals the remaining rows without another fetch', async () => {
|
||||
const user = userEvent.setup()
|
||||
const largeRows = Array.from({ length: INITIAL_VISIBLE + 5 }, (_, index) => session({
|
||||
sessionId: `session-${index}`,
|
||||
project: `project-${index}`,
|
||||
provider: 'codex',
|
||||
cost: INITIAL_VISIBLE + 5 - index,
|
||||
}))
|
||||
getSessions.mockResolvedValue(largeRows)
|
||||
const { container } = render(<Sessions period="30days" provider="all" />)
|
||||
|
||||
expect(await screen.findByText(`Showing ${INITIAL_VISIBLE} of ${INITIAL_VISIBLE + 5}`)).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.session-row')).toHaveLength(INITIAL_VISIBLE)
|
||||
await user.click(screen.getByRole('button', { name: 'Show 5 more · 5 remaining' }))
|
||||
expect(screen.getByText(`Showing ${INITIAL_VISIBLE + 5} of ${INITIAL_VISIBLE + 5}`)).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.session-row')).toHaveLength(INITIAL_VISIBLE + 5)
|
||||
expect(screen.queryByRole('button', { name: /remaining/ })).not.toBeInTheDocument()
|
||||
expect(getSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the live eight-stat detail and returns to the list with Escape', 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/ }))
|
||||
|
||||
expect(screen.getByRole('button', { name: '← Back to sessions' })).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)
|
||||
for (const label of ['Cost', 'Calls', 'Turns', 'Saved', 'Input', 'Output', 'Cache read', 'Cache write']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText('Saved $1.25 vs baseline')).toBeInTheDocument()
|
||||
expect(screen.getByText('Duration 1h 35m')).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()
|
||||
expect(container.querySelectorAll('.session-row')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('renders the honest empty state', async () => {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import { useState } from 'react'
|
||||
import { 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 { formatUsd } from '../lib/format'
|
||||
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { DateRange, Period, SessionRow } from '../lib/types'
|
||||
|
||||
function fmtCompact(n: number): string {
|
||||
if (n === 0) return '0'
|
||||
if (n < 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: n >= 10_000_000 ? 1 : 2,
|
||||
}).format(n)
|
||||
}
|
||||
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
|
||||
|
|
@ -25,15 +32,31 @@ function providerName(provider: string): string {
|
|||
.join(' ')
|
||||
}
|
||||
|
||||
function durationLabel(durationMs: number): string {
|
||||
const minutes = Math.round(durationMs / 60_000)
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
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 EmptyNote({ children }: { children: React.ReactNode }) {
|
||||
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
|
||||
return <p style={{ color: 'var(--mut)', margin: 0, fontSize: 12 }}>{children}</p>
|
||||
}
|
||||
|
||||
export function Sessions({
|
||||
|
|
@ -47,11 +70,72 @@ export function Sessions({
|
|||
range?: DateRange | null
|
||||
refreshToken?: number
|
||||
}) {
|
||||
const [selected, setSelected] = useState<SessionRow | null>(null)
|
||||
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],
|
||||
)
|
||||
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,
|
||||
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" />
|
||||
|
|
@ -62,7 +146,7 @@ export function Sessions({
|
|||
)
|
||||
}
|
||||
|
||||
if (selected) return <SessionDetail session={selected} onBack={() => setSelected(null)} />
|
||||
if (selected) return <SessionDetail session={selected} onBack={() => setSelectedId(null)} />
|
||||
|
||||
if (!report.data.length) {
|
||||
return (
|
||||
|
|
@ -72,74 +156,114 @@ export function Sessions({
|
|||
)
|
||||
}
|
||||
|
||||
const grouped = report.data.reduce((map, row) => {
|
||||
const rows = map.get(row.provider) ?? []
|
||||
rows.push(row)
|
||||
map.set(row.provider, rows)
|
||||
return map
|
||||
}, new Map<string, SessionRow[]>())
|
||||
const groups = [...grouped.entries()]
|
||||
.map(([name, rows]) => ({
|
||||
name,
|
||||
rows: [...rows].sort((a, b) => b.cost - a.cost),
|
||||
cost: rows.reduce((sum, row) => sum + row.cost, 0),
|
||||
}))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
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">
|
||||
<div className="session-list">
|
||||
{groups.map(group => (
|
||||
<div className="session-provider" key={group.name}>
|
||||
<div className="provider-h">
|
||||
{providerName(group.name)} · {group.rows.length.toLocaleString('en-US')} {group.rows.length === 1 ? 'session' : 'sessions'} · {formatUsd(group.cost)}
|
||||
</div>
|
||||
{group.rows.map(row => (
|
||||
<button className="session-row" key={row.sessionId} type="button" onClick={() => setSelected(row)}>
|
||||
<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 "{query}".</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}`}>
|
||||
{providerName(entry.provider)} · {entry.count.toLocaleString('en-US')} sessions · {formatUsd(entry.cost)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="session-row"
|
||||
key={entry.row.sessionId}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(entry.row.sessionId)}
|
||||
>
|
||||
<span className="session-primary">
|
||||
<span className="session-title">{row.project}</span>
|
||||
<span className="session-project">{String(row.sessionId).slice(0, 18)}</span>
|
||||
<span className="session-title">{entry.row.project}</span>
|
||||
<span className="session-project">{entry.row.sessionId.slice(0, 18)}</span>
|
||||
</span>
|
||||
<span className="session-meta">
|
||||
<span>{row.models.join(', ')}</span>
|
||||
<span>{row.turns} turns</span>
|
||||
<span>{formatUsd(row.cost)}</span>
|
||||
<span>{fmtCompact(row.inputTokens + row.outputTokens)} tok</span>
|
||||
<span className="session-when">{formatDayShort(entry.row.endedAt)}</span>
|
||||
<span>{entry.row.models.join(', ')}</span>
|
||||
<span>{entry.row.turns} turns</span>
|
||||
<span>{formatUsd(entry.row.cost)}</span>
|
||||
<span>{formatCompact(entry.row.inputTokens + entry.row.outputTokens)} tok</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</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, onBack }: { session: SessionRow; onBack: () => void }) {
|
||||
const cacheTotal = session.inputTokens + session.cacheReadTokens
|
||||
const cacheHit = cacheTotal > 0 ? `${Math.round(session.cacheReadTokens / cacheTotal * 100)}% hit` : '—'
|
||||
const cacheHit = cacheTotal > 0 ? Math.round(session.cacheReadTokens / cacheTotal * 100) : 0
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onBack()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onBack])
|
||||
|
||||
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="detail-line">{session.provider} · {session.models.join(', ')}</div>
|
||||
<div className="detail-line">
|
||||
{session.provider} · {session.models.join(', ')} · {new Date(session.startedAt).toLocaleDateString()} · {formatUsd(session.cost)} · {session.turns} turns
|
||||
{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="Input" value={fmtCompact(session.inputTokens)} delta="tokens sent" />
|
||||
<Stat label="Output" value={fmtCompact(session.outputTokens)} delta="tokens generated" />
|
||||
<Stat label="Cache read" value={fmtCompact(session.cacheReadTokens)} delta={cacheHit} />
|
||||
<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>
|
||||
{(session.savingsUSD > 0 || session.durationMs > 0) && (
|
||||
<div className="session-detail-notes">
|
||||
{session.savingsUSD > 0 && <span>Saved {formatUsd(session.savingsUSD)} vs baseline</span>}
|
||||
{session.durationMs > 0 && <span>Duration {durationLabel(session.durationMs)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,17 +383,33 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
|
|||
}
|
||||
|
||||
/* Sessions */
|
||||
.sessions-list-view { width: 100%; }
|
||||
.session-list { max-height: 460px; overflow-y: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
|
||||
.sessions-list-view { display: flex; width: 100%; flex-direction: column; gap: 10px; }
|
||||
.sessions-toolbar { display: flex; align-items: center; align-self: flex-start; flex-wrap: wrap; gap: 8px; }
|
||||
.sessions-search { min-width: 240px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); color: var(--ink); font: inherit; font-size: 12px; }
|
||||
.sessions-search::placeholder { color: var(--mut2); }
|
||||
.sessions-search:focus-visible { border-color: var(--accent); outline: 1px solid var(--accent); outline-offset: 1px; }
|
||||
.sessions-toggle { padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--mut); font: inherit; font-size: 11.5px; cursor: pointer; }
|
||||
.sessions-toggle:hover, .sessions-toggle:focus-visible { color: var(--ink); outline: none; }
|
||||
.sessions-toggle:focus-visible { box-shadow: 0 0 0 1px var(--accent); }
|
||||
.sessions-toggle[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--hover); color: var(--ink); }
|
||||
.sessions-summary { color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.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); }
|
||||
.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; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.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-row:hover, .session-row:focus-visible { background: var(--hover); outline: none; }
|
||||
.session-primary { min-width: 0; flex: 1; }
|
||||
.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: minmax(110px, 1fr) 58px 70px 76px; align-items: center; gap: 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.session-meta span:first-child { overflow: hidden; text-align: left; text-overflow: ellipsis; }
|
||||
.session-meta { display: grid; grid-template-columns: 64px minmax(110px, 1fr) 58px 70px 76px; align-items: center; gap: 10px; color: var(--mut); 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; }
|
||||
.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; }
|
||||
|
|
@ -443,7 +459,7 @@ 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: 90px 52px 62px; }
|
||||
.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; }
|
||||
|
|
@ -451,6 +467,10 @@ 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