From d9c56e3c6bed2d670ff0879a148c6bb4efbbac82 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 11 Jul 2026 13:50:58 -0700 Subject: [PATCH] feat(app): production-ready Sessions screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/renderer/lib/format.test.ts | 35 ++++ app/renderer/lib/format.ts | 38 ++++ app/renderer/sections/Compare.test.tsx | 2 +- app/renderer/sections/Compare.tsx | 15 +- app/renderer/sections/Sessions.test.tsx | 179 ++++++++++++++++-- app/renderer/sections/Sessions.tsx | 240 ++++++++++++++++++------ app/renderer/styles/plain.css | 32 +++- 7 files changed, 444 insertions(+), 97 deletions(-) create mode 100644 app/renderer/lib/format.test.ts diff --git a/app/renderer/lib/format.test.ts b/app/renderer/lib/format.test.ts new file mode 100644 index 00000000..ab6490af --- /dev/null +++ b/app/renderer/lib/format.test.ts @@ -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('—') + }) +}) diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts index e944e50c..9c2bb5a8 100644 --- a/app/renderer/lib/format.ts +++ b/app/renderer/lib/format.ts @@ -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` +} diff --git a/app/renderer/sections/Compare.test.tsx b/app/renderer/sections/Compare.test.tsx index 393140c6..35acb809 100644 --- a/app/renderer/sections/Compare.test.tsx +++ b/app/renderer/sections/Compare.test.tsx @@ -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('.cmp-card')! expect(within(context).getByText('Cache hit rate')).toBeInTheDocument() diff --git a/app/renderer/sections/Compare.tsx b/app/renderer/sections/Compare.tsx index 45dc7665..fe3a3277 100644 --- a/app/renderer/sections/Compare.tsx +++ b/app/renderer/sections/Compare.tsx @@ -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)], diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 1fc561cd..3a507d0a 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -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>(), @@ -13,8 +14,26 @@ vi.mock('../lib/ipc', async orig => { return { ...actual, codeburn: { getSessions } } }) +function session(overrides: Partial & Pick): 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() - 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() + 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() + 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() + 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() + + 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() + 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 () => { diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 8041939f..362b1e6e 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -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

{children}

+ return

{children}

} export function Sessions({ @@ -47,11 +70,72 @@ export function Sessions({ range?: DateRange | null refreshToken?: number }) { - const [selected, setSelected] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [query, setQuery] = useState('') + const [sort, setSort] = useState('cost') + const [grouped, setGrouped] = useState(true) + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE) const report = usePolled( () => 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(() => { + 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()) + + 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 @@ -62,7 +146,7 @@ export function Sessions({ ) } - if (selected) return setSelected(null)} /> + if (selected) return 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()) - 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 (
-
- {groups.map(group => ( -
-
- {providerName(group.name)} · {group.rows.length.toLocaleString('en-US')} {group.rows.length === 1 ? 'session' : 'sessions'} · {formatUsd(group.cost)} -
- {group.rows.map(row => ( - +
+
+ {filtered.length} sessions · {formatUsd(totalCost)} · {formatCompact(totalTokens)} tokens +
+ {filtered.length === 0 ? ( +
+ No sessions match "{query}". + +
+ ) : ( + <> +
+ {renderedSequence.map(entry => entry.type === 'header' ? ( +
+ {providerName(entry.provider)} · {entry.count.toLocaleString('en-US')} sessions · {formatUsd(entry.cost)} +
+ ) : ( + ))}
- ))} -
+
Showing {renderedRows} of {filtered.length}
+ {remaining > 0 && ( + + )} + + )}
) } 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 (

{session.project}

+
{session.provider} · {session.models.join(', ')}
- {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)}
- - - + + + + + + +
- {(session.savingsUSD > 0 || session.durationMs > 0) && ( -
- {session.savingsUSD > 0 && Saved {formatUsd(session.savingsUSD)} vs baseline} - {session.durationMs > 0 && Duration {durationLabel(session.durationMs)}} -
- )}
) } diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 2ff37094..4ce381d7 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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); }