M internal/server/web/dist/index.html => internal/server/web/dist/index.html +2 -2
@@ 10,8 10,8 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
- <script type="module" crossorigin src="/assets/index-BEzVvo_X.js"></script>
- <link rel="stylesheet" crossorigin href="/assets/index-BrQo9wF5.css">
+ <script type="module" crossorigin src="/assets/index-qIakUabY.js"></script>
+ <link rel="stylesheet" crossorigin href="/assets/index-DfMzx7Sc.css">
</head>
<body class="density-compact">
<div id="root"></div>
A web/src/api/adapters.test.ts => web/src/api/adapters.test.ts +59 -0
@@ 0,0 1,59 @@
+import { describe, it, expect } from 'vitest'
+import { adaptSession } from './adapters'
+import type { SessionDTO } from './adapters'
+
+function makeDTO(overrides: Partial<SessionDTO> = {}): SessionDTO {
+ return {
+ owner: 'bigbes',
+ tool: 'claude-code',
+ host: 'laptop',
+ session_id: 'abc123',
+ started_at: 1_000_000,
+ ended_at: 1_001_000,
+ source_file: '/path/to/session.jsonl',
+ summary: 'A test session',
+ turn_count: 5,
+ tokens_in_total: 1234,
+ tokens_out_total: 5678,
+ ...overrides,
+ }
+}
+
+describe('adaptSession', () => {
+ it('started_at=0 → started="1970-01-01T00:00:00.000Z"', () => {
+ const s = adaptSession(makeDTO({ started_at: 0, ended_at: 0 }))
+ expect(s.started).toBe('1970-01-01T00:00:00.000Z')
+ })
+
+ it('working_dir absent → cwd === ""', () => {
+ const s = adaptSession(makeDTO({ working_dir: undefined }))
+ expect(s.cwd).toBe('')
+ })
+
+ it('model absent → model === undefined (not null)', () => {
+ const s = adaptSession(makeDTO({ model: undefined }))
+ expect(s.model).toBeUndefined()
+ })
+
+ it('id is composite ${tool}/${host}/${session_id}', () => {
+ const s = adaptSession(makeDTO({ tool: 'opencode', host: 'workpc', session_id: 'xyz789' }))
+ expect(s.id).toBe('opencode/workpc/xyz789')
+ const parts = s.id.split('/')
+ expect(parts[0]).toBe('opencode')
+ expect(parts[1]).toBe('workpc')
+ expect(parts[2]).toBe('xyz789')
+ })
+
+ it('ended_at === started_at → ended === null', () => {
+ const ts = 1_000_000
+ const s = adaptSession(makeDTO({ started_at: ts, ended_at: ts }))
+ expect(s.ended).toBeNull()
+ })
+
+ it('ended_at > started_at → ended is the ISO of ended_at', () => {
+ const startedAt = 1_000_000
+ const endedAt = 1_001_000
+ const s = adaptSession(makeDTO({ started_at: startedAt, ended_at: endedAt }))
+ expect(s.ended).toBe(new Date(endedAt * 1000).toISOString())
+ })
+})
A web/src/api/adapters.ts => web/src/api/adapters.ts +51 -0
@@ 0,0 1,51 @@
+export type Tool = 'claude-code' | 'opencode' | 'crush' | 'pi' | 'kimi' | string
+export type Host = 'laptop' | 'workpc' | string
+
+export interface SessionDTO {
+ owner: string
+ tool: string
+ host: string
+ session_id: string
+ started_at: number // unix epoch seconds
+ ended_at: number
+ working_dir?: string
+ source_file: string
+ summary: string
+ turn_count: number
+ tokens_in_total: number
+ tokens_out_total: number
+ model?: string
+ metadata?: unknown
+}
+
+export interface Session {
+ id: string // `${tool}/${host}/${session_id}`
+ tool: string
+ host: string
+ cwd: string // working_dir ?? ''
+ model?: string
+ started: string // ISO 8601
+ ended: string | null // null if ended_at <= started_at
+ summary: string
+ turns: number
+ tokensIn: number
+ tokensOut: number
+ hasError: boolean // TODO: always false until error tracking is implemented
+}
+
+export function adaptSession(d: SessionDTO): Session {
+ return {
+ id: `${d.tool}/${d.host}/${d.session_id}`,
+ tool: d.tool,
+ host: d.host,
+ cwd: d.working_dir ?? '',
+ model: d.model,
+ started: new Date(d.started_at * 1000).toISOString(),
+ ended: d.ended_at > d.started_at ? new Date(d.ended_at * 1000).toISOString() : null,
+ summary: d.summary,
+ turns: d.turn_count,
+ tokensIn: d.tokens_in_total,
+ tokensOut: d.tokens_out_total,
+ hasError: false,
+ }
+}
A web/src/api/client.ts => web/src/api/client.ts +45 -0
@@ 0,0 1,45 @@
+export class AuthError extends Error {
+ override name = 'AuthError'
+ constructor(message: string) {
+ super(message)
+ }
+}
+
+export class APIError extends Error {
+ override name = 'APIError'
+ status: number
+ code: string
+ constructor(message: string, status: number, code: string) {
+ super(message)
+ this.status = status
+ this.code = code
+ }
+}
+
+export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
+ const resp = await fetch(path, {
+ ...init,
+ headers: {
+ Accept: 'application/json',
+ ...init?.headers,
+ },
+ })
+
+ if (resp.status === 401) {
+ throw new AuthError('not authenticated')
+ }
+
+ if (!resp.ok) {
+ const ct = resp.headers.get('Content-Type') ?? ''
+ if (ct.includes('application/problem+json')) {
+ const body = await resp.json() as { detail?: string; title?: string; status?: number; code?: string }
+ throw new APIError(body.detail ?? body.title ?? 'error', body.status ?? resp.status, body.code ?? '')
+ }
+ if (resp.status >= 500) {
+ throw new APIError('server error', resp.status, '')
+ }
+ throw new APIError(`request failed: ${resp.status}`, resp.status, '')
+ }
+
+ return resp.json() as Promise<T>
+}
A web/src/features/home/FilterChips.tsx => web/src/features/home/FilterChips.tsx +209 -0
@@ 0,0 1,209 @@
+import React, { useRef, useState, useEffect } from 'react'
+import type { HomeFilters } from './useSessions'
+
+interface FilterChipsProps {
+ value: HomeFilters
+ onChange: (next: HomeFilters) => void
+}
+
+const SINCE_OPTIONS: Array<HomeFilters['since']> = ['1d', '7d', '30d', '90d', 'all']
+const TOOL_OPTIONS = ['claude-code', 'opencode', 'crush', 'pi', 'kimi']
+const HOST_OPTIONS: string[] = []
+
+interface PopoverProps {
+ dim: string
+ current: string
+ options: string[]
+ anchor: { top: number; left: number }
+ onPick: (v: string) => void
+ onClose: () => void
+}
+
+function Popover({ dim, current, options, anchor, onPick, onClose }: PopoverProps): React.JSX.Element {
+ const ref = useRef<HTMLDivElement>(null)
+
+ useEffect(() => {
+ const onDoc = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ onClose()
+ }
+ }
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ // defer so the click that opened the popover doesn't immediately close it
+ const t = setTimeout(() => {
+ document.addEventListener('mousedown', onDoc)
+ document.addEventListener('keydown', onKey)
+ }, 0)
+ return () => {
+ clearTimeout(t)
+ document.removeEventListener('mousedown', onDoc)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [onClose])
+
+ return (
+ <div ref={ref} className="popover" style={{ top: anchor.top + 4, left: anchor.left }}>
+ <div className="ph">{dim}</div>
+ {options.map(o => (
+ <div
+ key={o}
+ className="pi"
+ onClick={() => { onPick(o); onClose() }}
+ >
+ <span style={{
+ width: 10,
+ height: 10,
+ borderRadius: 99,
+ border: '1px solid var(--ink-3)',
+ background: o === current ? 'var(--accent)' : 'transparent',
+ display: 'inline-block',
+ flexShrink: 0,
+ }} />
+ <span className="mono">{o}</span>
+ </div>
+ ))}
+ </div>
+ )
+}
+
+interface ChipProps {
+ dim: string
+ value: string
+ options: string[]
+ isActive: boolean
+ onPick: (v: string) => void
+}
+
+function FilterChip({ dim, value, options, isActive, onPick }: ChipProps): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null)
+ const ref = useRef<HTMLSpanElement>(null)
+
+ function handleClick() {
+ if (!ref.current) return
+ const r = ref.current.getBoundingClientRect()
+ const appEl = ref.current.closest('.app')
+ const parent = appEl ? appEl.getBoundingClientRect() : { top: 0, left: 0 }
+ setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left })
+ setOpen(true)
+ }
+
+ const classes = ['tag', 'click']
+ if (isActive) classes.push('accent')
+
+ return (
+ <>
+ <span ref={ref} className={classes.join(' ')} onClick={handleClick}>
+ {dim}: {value}
+ </span>
+ {open && anchor != null && (
+ <Popover
+ dim={dim}
+ current={value}
+ options={options}
+ anchor={anchor}
+ onPick={onPick}
+ onClose={() => setOpen(false)}
+ />
+ )}
+ </>
+ )
+}
+
+interface AddChipProps {
+ availableDims: string[]
+ onAdd: (dim: string) => void
+}
+
+function AddFilterChip({ availableDims, onAdd }: AddChipProps): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null)
+ const ref = useRef<HTMLSpanElement>(null)
+
+ if (availableDims.length === 0) return <></>
+
+ function handleClick() {
+ if (!ref.current) return
+ const r = ref.current.getBoundingClientRect()
+ const appEl = ref.current.closest('.app')
+ const parent = appEl ? appEl.getBoundingClientRect() : { top: 0, left: 0 }
+ setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left })
+ setOpen(true)
+ }
+
+ return (
+ <>
+ <span ref={ref} className="tag dashed" onClick={handleClick}>
+ + filter
+ </span>
+ {open && anchor != null && (
+ <div
+ className="popover"
+ style={{ top: anchor.top + 4, left: anchor.left }}
+ onMouseLeave={() => setOpen(false)}
+ >
+ <div className="ph">add filter</div>
+ {availableDims.map(d => (
+ <div key={d} className="pi" onClick={() => { onAdd(d); setOpen(false) }}>
+ <span className="mono">{d}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </>
+ )
+}
+
+export function FilterChips({ value, onChange }: FilterChipsProps): React.JSX.Element {
+ const since = value.since ?? '30d'
+ const tool = value.tool ?? ''
+ const host = value.host ?? ''
+
+ const [activeFilters, setActiveFilters] = useState<string[]>(() => {
+ const a = ['since']
+ if (value.tool) a.push('tool')
+ if (value.host) a.push('host')
+ return a
+ })
+
+ const availableDims = (['tool', 'host'] as const).filter(d => !activeFilters.includes(d))
+
+ function handleAdd(dim: string) {
+ setActiveFilters(fs => (fs.includes(dim) ? fs : [...fs, dim]))
+ }
+
+ return (
+ <>
+ {activeFilters.includes('since') && (
+ <FilterChip
+ dim="since"
+ value={since}
+ options={SINCE_OPTIONS as string[]}
+ isActive={since !== '30d'}
+ onPick={v => onChange({ ...value, since: v as HomeFilters['since'] })}
+ />
+ )}
+ {activeFilters.includes('tool') && (
+ <FilterChip
+ dim="tool"
+ value={tool || 'any'}
+ options={['any', ...TOOL_OPTIONS]}
+ isActive={!!tool}
+ onPick={v => onChange({ ...value, tool: v === 'any' ? undefined : v })}
+ />
+ )}
+ {activeFilters.includes('host') && (
+ <FilterChip
+ dim="host"
+ value={host || 'any'}
+ options={['any', ...HOST_OPTIONS]}
+ isActive={!!host}
+ onPick={v => onChange({ ...value, host: v === 'any' ? undefined : v })}
+ />
+ )}
+ <AddFilterChip availableDims={availableDims} onAdd={handleAdd} />
+ </>
+ )
+}
A web/src/features/home/SessionsTable.tsx => web/src/features/home/SessionsTable.tsx +82 -0
@@ 0,0 1,82 @@
+import React from 'react'
+import { EmptyState } from '../../primitives'
+import { ToolDot } from '../../primitives'
+import type { Session } from '../../api/adapters'
+
+interface SessionsTableProps {
+ sessions: Session[]
+ cursor: number
+ onCursor: (i: number) => void
+ onOpen: (s: Session) => void
+}
+
+const COLS = '120px 110px 70px 1fr 50px 60px 90px'
+
+// Format STARTED column: ≤24h → "14:22"; older → "Apr 25 11:08"
+function formatStarted(isoStr: string): string {
+ const d = new Date(isoStr)
+ const now = new Date()
+ const diffMs = now.getTime() - d.getTime()
+ const h24 = 24 * 60 * 60 * 1000
+
+ if (diffMs <= h24) {
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
+ }
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
+}
+
+// Format TOK: 12400 → "12.4k"; small numbers unchanged
+function formatTok(n: number): string {
+ if (n >= 1000) {
+ return (n / 1000).toFixed(1) + 'k'
+ }
+ return String(n)
+}
+
+export function SessionsTable({ sessions, cursor, onCursor, onOpen }: SessionsTableProps): React.JSX.Element {
+ if (sessions.length === 0) {
+ return (
+ <div className="body body-pad">
+ <EmptyState glyph="∅" copy="no sessions match these filters" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="home-table body">
+ <div className="home-thead home-cols" style={{ gridTemplateColumns: COLS }}>
+ <span>started</span>
+ <span>tool</span>
+ <span>host</span>
+ <span>summary</span>
+ <span className="right">turns</span>
+ <span className="right">tok</span>
+ <span>cwd</span>
+ </div>
+ {sessions.map((s, i) => {
+ const isCursor = i === cursor
+ return (
+ <div
+ key={s.id}
+ className={'home-row home-cols' + (isCursor ? ' cursor' : '')}
+ style={{ gridTemplateColumns: COLS }}
+ onClick={() => { onCursor(i); onOpen(s) }}
+ onMouseEnter={() => onCursor(i)}
+ >
+ <span className="mono muted">{formatStarted(s.started)}</span>
+ <span className="mono">
+ <ToolDot tool={s.tool} />
+ {' '}{s.tool}
+ </span>
+ <span className="mono">{s.host}</span>
+ <span className="truncate">{s.summary}</span>
+ <span className="right mono">{s.turns}</span>
+ <span className="right mono muted">{formatTok(s.tokensIn + s.tokensOut)}</span>
+ <span className="mono muted truncate">{s.cwd}</span>
+ </div>
+ )
+ })}
+ </div>
+ )
+}
A web/src/features/home/useHomeCursor.ts => web/src/features/home/useHomeCursor.ts +37 -0
@@ 0,0 1,37 @@
+import { useState, useCallback } from 'react'
+
+export interface HomeCursor {
+ cursor: number
+ move(d: 1 | -1): void
+ activate(): void
+ jumpTo(i: number): void
+}
+
+export function useHomeCursor(
+ sessionCount: number,
+ onActivate: (idx: number) => void,
+): HomeCursor {
+ const [cursor, setCursor] = useState(0)
+
+ const move = useCallback(
+ (d: 1 | -1) => {
+ if (sessionCount === 0) return
+ setCursor(c => Math.max(0, Math.min(sessionCount - 1, c + d)))
+ },
+ [sessionCount],
+ )
+
+ const activate = useCallback(() => {
+ onActivate(cursor)
+ }, [cursor, onActivate])
+
+ const jumpTo = useCallback(
+ (i: number) => {
+ if (sessionCount === 0) return
+ setCursor(Math.max(0, Math.min(sessionCount - 1, i)))
+ },
+ [sessionCount],
+ )
+
+ return { cursor, move, activate, jumpTo }
+}
A web/src/features/home/useSessions.ts => web/src/features/home/useSessions.ts +54 -0
@@ 0,0 1,54 @@
+import { useQuery } from '@tanstack/react-query'
+import type { UseQueryResult } from '@tanstack/react-query'
+import { apiFetch } from '../../api/client'
+import { adaptSession } from '../../api/adapters'
+import type { Session, SessionDTO, Tool, Host } from '../../api/adapters'
+
+export interface HomeFilters {
+ since?: '1d' | '7d' | '30d' | '90d' | 'all'
+ tool?: Tool
+ host?: Host
+}
+
+interface SessionsResponse {
+ sessions: SessionDTO[]
+ limit: number
+ offset: number
+}
+
+function sinceToEpoch(since: string): number {
+ const now = Math.floor(Date.now() / 1000)
+ switch (since) {
+ case '1d': return now - 1 * 86400
+ case '7d': return now - 7 * 86400
+ case '30d': return now - 30 * 86400
+ case '90d': return now - 90 * 86400
+ default: return 0
+ }
+}
+
+export function useSessions(filters: HomeFilters): UseQueryResult<Session[]> {
+ const since = filters.since ?? '30d'
+
+ return useQuery({
+ queryKey: ['sessions', filters],
+ queryFn: async () => {
+ const params = new URLSearchParams()
+
+ if (since !== 'all') {
+ params.set('since', String(sinceToEpoch(since)))
+ }
+ if (filters.tool) {
+ params.set('tool', filters.tool)
+ }
+ if (filters.host) {
+ params.set('host', filters.host)
+ }
+
+ const qs = params.toString()
+ const url = `/api/v1/sessions${qs ? `?${qs}` : ''}`
+ const data = await apiFetch<SessionsResponse>(url)
+ return data.sessions.map(adaptSession)
+ },
+ })
+}
M web/src/primitives/ToolDot.tsx => web/src/primitives/ToolDot.tsx +1 -1
@@ 9,7 9,7 @@ const TOOL_COLORS: Record<string, string> = {
}
interface ToolDotProps {
- tool: 'claude-code' | 'opencode' | 'crush' | 'pi' | 'kimi'
+ tool: string
}
export function ToolDot({ tool }: ToolDotProps): React.JSX.Element {
M web/src/routeTree.gen.ts => web/src/routeTree.gen.ts +35 -2
@@ 15,6 15,7 @@ import { Route as SearchRouteImport } from './routes/search'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as HealthRouteImport } from './routes/health'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as SessionToolHostIdRouteImport } from './routes/session.$tool.$host.$id'
const StatsRoute = StatsRouteImport.update({
id: '/stats',
@@ 46,6 47,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({
+ id: '/session/$tool/$host/$id',
+ path: '/session/$tool/$host/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ 54,6 60,7 @@ export interface FileRoutesByFullPath {
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stats': typeof StatsRoute
+ '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ 62,6 69,7 @@ export interface FileRoutesByTo {
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stats': typeof StatsRoute
+ '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ 71,12 79,27 @@ export interface FileRoutesById {
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/stats': typeof StatsRoute
+ '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/health' | '/projects' | '/search' | '/settings' | '/stats'
+ fullPaths:
+ | '/'
+ | '/health'
+ | '/projects'
+ | '/search'
+ | '/settings'
+ | '/stats'
+ | '/session/$tool/$host/$id'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/health' | '/projects' | '/search' | '/settings' | '/stats'
+ to:
+ | '/'
+ | '/health'
+ | '/projects'
+ | '/search'
+ | '/settings'
+ | '/stats'
+ | '/session/$tool/$host/$id'
id:
| '__root__'
| '/'
@@ 85,6 108,7 @@ export interface FileRouteTypes {
| '/search'
| '/settings'
| '/stats'
+ | '/session/$tool/$host/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ 94,6 118,7 @@ export interface RootRouteChildren {
SearchRoute: typeof SearchRoute
SettingsRoute: typeof SettingsRoute
StatsRoute: typeof StatsRoute
+ SessionToolHostIdRoute: typeof SessionToolHostIdRoute
}
declare module '@tanstack/react-router' {
@@ 140,6 165,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/session/$tool/$host/$id': {
+ id: '/session/$tool/$host/$id'
+ path: '/session/$tool/$host/$id'
+ fullPath: '/session/$tool/$host/$id'
+ preLoaderRoute: typeof SessionToolHostIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ 150,6 182,7 @@ const rootRouteChildren: RootRouteChildren = {
SearchRoute: SearchRoute,
SettingsRoute: SettingsRoute,
StatsRoute: StatsRoute,
+ SessionToolHostIdRoute: SessionToolHostIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
M web/src/routes/__root.tsx => web/src/routes/__root.tsx +32 -8
@@ 1,4 1,4 @@
-import React, { useState, useEffect, useRef } from 'react'
+import React, { useState, useEffect, useRef, createContext, useContext } from 'react'
import { createRootRoute, Outlet, useNavigate } from '@tanstack/react-router'
import { bootstrapTheme } from '../lib/theme'
import { createKeyboardController } from '../lib/keyboard'
@@ 10,6 10,25 @@ import '../styles/primitives.css'
import '../styles/shell.css'
import '../styles/palette.css'
+interface CursorHandle {
+ move(d: 1 | -1): void
+ activate(): void
+}
+
+const noopCursor: CursorHandle = {
+ move: (_d: 1 | -1) => { /* no-op */ },
+ activate: () => { /* no-op */ },
+}
+
+// Context that routes use to register/unregister their cursor handle
+export const KeyboardCursorContext = createContext<React.MutableRefObject<CursorHandle> | null>(null)
+
+export function useKeyboardCursor(): React.MutableRefObject<CursorHandle> {
+ const ctx = useContext(KeyboardCursorContext)
+ if (ctx === null) throw new Error('useKeyboardCursor must be used within RootComponent')
+ return ctx
+}
+
export const Route = createRootRoute({
component: RootComponent,
})
@@ 24,6 43,9 @@ function RootComponent(): React.JSX.Element {
paletteOpenRef.current = paletteOpen
}, [paletteOpen])
+ // Ref that routes swap in to wire their cursor into the global keyboard handler
+ const cursorRef = useRef<CursorHandle>(noopCursor)
+
useEffect(() => {
// Bootstrap theme once on mount
bootstrapTheme()
@@ 42,8 64,8 @@ function RootComponent(): React.JSX.Element {
closePalette: () => setPaletteOpen(false),
isPaletteOpen: () => paletteOpenRef.current,
cursor: {
- move: (_d: 1 | -1) => { /* reserved for Phase 5 row cursor */ },
- activate: () => { /* reserved for Phase 5 row cursor */ },
+ move: (d) => cursorRef.current.move(d),
+ activate: () => cursorRef.current.activate(),
},
})
@@ 56,10 78,12 @@ function RootComponent(): React.JSX.Element {
}, []) // navigate is stable; intentional empty-dep mount effect
return (
- <div className="app">
- <TopBar onPaletteOpen={() => setPaletteOpen(true)} />
- <Outlet />
- <Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
- </div>
+ <KeyboardCursorContext.Provider value={cursorRef}>
+ <div className="app">
+ <TopBar onPaletteOpen={() => setPaletteOpen(true)} />
+ <Outlet />
+ <Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
+ </div>
+ </KeyboardCursorContext.Provider>
)
}
M web/src/routes/index.tsx => web/src/routes/index.tsx +110 -8
@@ 1,22 1,124 @@
-import { createFileRoute } from '@tanstack/react-router'
-import React from 'react'
-import { EmptyState } from '../primitives'
+import React, { useEffect, useCallback } from 'react'
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
-import { Tag } from '../primitives'
+import { Sub } from '../primitives'
+import { FilterChips } from '../features/home/FilterChips'
+import { SessionsTable } from '../features/home/SessionsTable'
+import { useSessions } from '../features/home/useSessions'
+import { useHomeCursor } from '../features/home/useHomeCursor'
+import { useKeyboardCursor } from './__root'
+import { AuthError, APIError } from '../api/client'
+import type { HomeFilters } from '../features/home/useSessions'
+import type { Session } from '../api/adapters'
+import '../styles/home.css'
+
+type HomeSearch = {
+ since?: '1d' | '7d' | '30d' | '90d' | 'all'
+ tool?: string
+ host?: string
+}
+
+const VALID_SINCE = new Set<string>(['1d', '7d', '30d', '90d', 'all'])
export const Route = createFileRoute('/')({
+ validateSearch: (search: Record<string, unknown>): HomeSearch => {
+ const since = typeof search['since'] === 'string' && VALID_SINCE.has(search['since'])
+ ? (search['since'] as HomeSearch['since'])
+ : undefined
+ const tool = typeof search['tool'] === 'string' && search['tool'] !== '' ? search['tool'] : undefined
+ const host = typeof search['host'] === 'string' && search['host'] !== '' ? search['host'] : undefined
+ return { since, tool, host }
+ },
component: HomeRoute,
})
function HomeRoute(): React.JSX.Element {
+ const navigate = useNavigate()
+ const search = Route.useSearch()
+
+ const filters: HomeFilters = {
+ since: search.since,
+ tool: search.tool,
+ host: search.host,
+ }
+
+ const { data: sessions, isLoading, error } = useSessions(filters)
+
+ const handleOpen = useCallback((s: Session) => {
+ void navigate({ to: '/session/$tool/$host/$id', params: { tool: s.tool, host: s.host, id: s.id } })
+ }, [navigate])
+
+ const { cursor, move, activate, jumpTo } = useHomeCursor(
+ sessions?.length ?? 0,
+ useCallback((idx: number) => {
+ if (sessions && sessions[idx]) handleOpen(sessions[idx])
+ }, [sessions, handleOpen]),
+ )
+
+ // Wire our cursor into the global keyboard controller
+ const cursorRef = useKeyboardCursor()
+ useEffect(() => {
+ cursorRef.current = { move, activate }
+ return () => { cursorRef.current = { move: (_d: 1 | -1) => { /* no-op */ }, activate: () => { /* no-op */ } } }
+ }, [cursorRef, move, activate])
+
+ function handleFilterChange(next: HomeFilters) {
+ void navigate({
+ to: '/',
+ search: {
+ since: next.since,
+ tool: next.tool,
+ host: next.host,
+ },
+ })
+ }
+
+ if (isLoading) {
+ return (
+ <>
+ <SubBar>
+ <FilterChips value={filters} onChange={handleFilterChange} />
+ </SubBar>
+ <div className="body body-pad">
+ <Sub>loading…</Sub>
+ </div>
+ </>
+ )
+ }
+
+ if (error != null) {
+ if (error instanceof AuthError) {
+ return (
+ <div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
+ <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
+ <div className="uppercase-mono" style={{ marginBottom: 8 }}>not authenticated</div>
+ <div className="muted">Sign in to view your sessions.</div>
+ </div>
+ </div>
+ )
+ }
+ const detail = error instanceof APIError ? error.message : String(error)
+ return (
+ <div className="body body-pad" style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}>
+ <div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
+ <div className="uppercase-mono" style={{ marginBottom: 8 }}>error</div>
+ <div className="muted">{detail}</div>
+ </div>
+ </div>
+ )
+ }
+
return (
<>
<SubBar>
- <Tag kind="neutral">recent</Tag>
+ <FilterChips value={filters} onChange={handleFilterChange} />
</SubBar>
- <div className="body body-pad">
- <EmptyState glyph="∅" copy="coming in Phase 5" />
- </div>
+ <SessionsTable
+ sessions={sessions ?? []}
+ cursor={cursor}
+ onCursor={jumpTo}
+ onOpen={handleOpen}
+ />
</>
)
}
A web/src/routes/session.$tool.$host.$id.tsx => web/src/routes/session.$tool.$host.$id.tsx +25 -0
@@ 0,0 1,25 @@
+import { createFileRoute } from '@tanstack/react-router'
+import React from 'react'
+import { EmptyState } from '../primitives'
+import { SubBar } from '../shell/SubBar'
+import { Tag } from '../primitives'
+
+export const Route = createFileRoute('/session/$tool/$host/$id')({
+ component: SessionRoute,
+})
+
+function SessionRoute(): React.JSX.Element {
+ const { tool, host, id } = Route.useParams()
+ return (
+ <>
+ <SubBar>
+ <Tag kind="tool">{tool}</Tag>
+ <Tag kind="host">{host}</Tag>
+ </SubBar>
+ <div className="body body-pad">
+ <EmptyState glyph="∅" copy="coming in Phase 6" />
+ <div className="mono muted" style={{ marginTop: 8, fontSize: 11 }}>{id}</div>
+ </div>
+ </>
+ )
+}
A web/src/styles/home.css => web/src/styles/home.css +48 -0
@@ 0,0 1,48 @@
+/* Home route — sessions table */
+
+.home-table {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+
+.home-thead {
+ display: grid;
+ padding: 5px 14px;
+ background: var(--paper-3);
+ font-size: 10px;
+ color: var(--ink-3);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-family: var(--mono);
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--rule-2);
+ flex: none;
+}
+
+.home-row {
+ display: grid;
+ padding: 4px 14px;
+ border-bottom: 1px solid var(--rule-2);
+ align-items: center;
+ gap: 10px;
+ font-size: 11.5px;
+ cursor: pointer;
+ color: var(--ink);
+}
+
+.home-row:hover {
+ background: var(--paper-2);
+}
+
+.home-row.cursor {
+ background: var(--accent-soft);
+ border-left: 2px solid var(--accent);
+ padding-left: 12px;
+}
+
+/* Column grid: STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD */
+.home-cols {
+ grid-template-columns: 120px 110px 70px 1fr 50px 60px 90px;
+}