From a02ae15b6bb082fc5c8bdebdb79e67b42d477f6c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 12 Jul 2026 13:38:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20rebuild=20Sessions=20=E2=80=94=20f?= =?UTF-8?q?ixed=20columns,=20clean=20paths,=20inline=20expand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/renderer/components/Sankey.tsx | 18 +------ app/renderer/lib/format.ts | 21 +++++++++ app/renderer/sections/Sessions.test.tsx | 34 ++++++++------ app/renderer/sections/Sessions.tsx | 62 +++++++++++++------------ app/renderer/styles/plain.css | 31 +++++++------ 5 files changed, 93 insertions(+), 73 deletions(-) diff --git a/app/renderer/components/Sankey.tsx b/app/renderer/components/Sankey.tsx index 58364b9..9e47dca 100644 --- a/app/renderer/components/Sankey.tsx +++ b/app/renderer/components/Sankey.tsx @@ -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 { diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts index 9c2bb5a..528016c 100644 --- a/app/renderer/lib/format.ts +++ b/app/renderer/lib/format.ts @@ -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 '—' diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 3a507d0..139b989 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -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 & Pick { const { container } = render() 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() 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() 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) }) diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 362b1e6..a6bfd03 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -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 setSelectedId(null)} /> - if (!report.data.length) { return ( @@ -197,27 +194,35 @@ export function Sessions({
{renderedSequence.map(entry => entry.type === 'header' ? (
- {providerName(entry.provider)} · {entry.count.toLocaleString('en-US')} sessions · {formatUsd(entry.cost)} + {providerName(entry.provider)} + {entry.count.toLocaleString('en-US')} sessions + {formatUsd(entry.cost)}
) : ( - + {formatCompact(entry.row.inputTokens + entry.row.outputTokens)} + + {selectedId === entry.row.sessionId && ( + setSelectedId(null)} /> + )} + ))}
Showing {renderedRows} of {filtered.length}
@@ -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 ( -
- -
-

{session.project}

+
+
+

{shortenProjectPath(session.project)}

{session.provider} · {session.models.join(', ')}
{formatDayLong(session.startedAt)} → {formatDayLong(session.endedAt)} · {formatDuration(session.durationMs)} diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 3339572..6da73b4 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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); }