From b100feee542aaacd159d90f8c35b2c68a26e8893 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 07:04:18 +0300 Subject: [PATCH] web: home route with real session list, filters, keyboard cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/client.ts: apiFetch with AuthError/APIError, 401 and problem+json handling - api/adapters.ts: SessionDTO→Session adapter with composite id and epoch conversion - api/adapters.test.ts: 6 TDD tests covering all specified edge cases - features/home/useSessions.ts: TanStack Query hook with since/tool/host params - features/home/FilterChips.tsx: chip-bar with popovers, Esc/outside-click dismiss - features/home/SessionsTable.tsx: grid table with cursor row highlight, formatStarted/formatTok - features/home/useHomeCursor.ts: cursor hook with move/activate/jumpTo - routes/index.tsx: Home route wired to real data, URL-driven filters, keyboard cursor - routes/__root.tsx: cursorRef + KeyboardCursorContext for route-local cursor registration - routes/session.$tool.$host.$id.tsx: stub for Phase 6 - styles/home.css: .home-table/.home-thead/.home-row/.home-row.cursor grid rules - primitives/ToolDot.tsx: widened tool prop to string (Tool type is open-ended) --- internal/server/web/dist/index.html | 4 +- web/src/api/adapters.test.ts | 59 ++++++ web/src/api/adapters.ts | 51 +++++ web/src/api/client.ts | 45 +++++ web/src/features/home/FilterChips.tsx | 209 +++++++++++++++++++++ web/src/features/home/SessionsTable.tsx | 82 ++++++++ web/src/features/home/useHomeCursor.ts | 37 ++++ web/src/features/home/useSessions.ts | 54 ++++++ web/src/primitives/ToolDot.tsx | 2 +- web/src/routeTree.gen.ts | 37 +++- web/src/routes/__root.tsx | 40 +++- web/src/routes/index.tsx | 118 +++++++++++- web/src/routes/session.$tool.$host.$id.tsx | 25 +++ web/src/styles/home.css | 48 +++++ 14 files changed, 790 insertions(+), 21 deletions(-) create mode 100644 web/src/api/adapters.test.ts create mode 100644 web/src/api/adapters.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/features/home/FilterChips.tsx create mode 100644 web/src/features/home/SessionsTable.tsx create mode 100644 web/src/features/home/useHomeCursor.ts create mode 100644 web/src/features/home/useSessions.ts create mode 100644 web/src/routes/session.$tool.$host.$id.tsx create mode 100644 web/src/styles/home.css diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index a3dffa21c5e8d0f550e5531f916732e20cd0bc2e..a6fbf1cf0b4ec84b5ac8d430fcb63fba3b02901a 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -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" /> - - + +
diff --git a/web/src/api/adapters.test.ts b/web/src/api/adapters.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..574eb741c4cb2398d5dcd6d3826acde1038a6047 --- /dev/null +++ b/web/src/api/adapters.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { adaptSession } from './adapters' +import type { SessionDTO } from './adapters' + +function makeDTO(overrides: Partial = {}): 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()) + }) +}) diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a73c1c0ce6fd5ede16ed4a6ee06e14ece854f0b --- /dev/null +++ b/web/src/api/adapters.ts @@ -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, + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..878d7adb581860a4a3b5bb30c43aef327510c6b8 --- /dev/null +++ b/web/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 +} diff --git a/web/src/features/home/FilterChips.tsx b/web/src/features/home/FilterChips.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d54db70687d08cfe97d79a08fdbc117790c7baf --- /dev/null +++ b/web/src/features/home/FilterChips.tsx @@ -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 = ['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(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 ( +
+
{dim}
+ {options.map(o => ( +
{ onPick(o); onClose() }} + > + + {o} +
+ ))} +
+ ) +} + +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(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 ( + <> + + {dim}: {value} + + {open && anchor != null && ( + 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(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 ( + <> + + + filter + + {open && anchor != null && ( +
setOpen(false)} + > +
add filter
+ {availableDims.map(d => ( +
{ onAdd(d); setOpen(false) }}> + {d} +
+ ))} +
+ )} + + ) +} + +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(() => { + 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') && ( + onChange({ ...value, since: v as HomeFilters['since'] })} + /> + )} + {activeFilters.includes('tool') && ( + onChange({ ...value, tool: v === 'any' ? undefined : v })} + /> + )} + {activeFilters.includes('host') && ( + onChange({ ...value, host: v === 'any' ? undefined : v })} + /> + )} + + + ) +} diff --git a/web/src/features/home/SessionsTable.tsx b/web/src/features/home/SessionsTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7f0d4248947832ee77c25d87da5e1c009bdefe5 --- /dev/null +++ b/web/src/features/home/SessionsTable.tsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+
+ started + tool + host + summary + turns + tok + cwd +
+ {sessions.map((s, i) => { + const isCursor = i === cursor + return ( +
{ onCursor(i); onOpen(s) }} + onMouseEnter={() => onCursor(i)} + > + {formatStarted(s.started)} + + + {' '}{s.tool} + + {s.host} + {s.summary} + {s.turns} + {formatTok(s.tokensIn + s.tokensOut)} + {s.cwd} +
+ ) + })} +
+ ) +} diff --git a/web/src/features/home/useHomeCursor.ts b/web/src/features/home/useHomeCursor.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4bcbaad2465b19f4908082c6ce699beec1675c6 --- /dev/null +++ b/web/src/features/home/useHomeCursor.ts @@ -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 } +} diff --git a/web/src/features/home/useSessions.ts b/web/src/features/home/useSessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..a676c58a9737cc671f71144241c5a74501403b9c --- /dev/null +++ b/web/src/features/home/useSessions.ts @@ -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 { + 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(url) + return data.sessions.map(adaptSession) + }, + }) +} diff --git a/web/src/primitives/ToolDot.tsx b/web/src/primitives/ToolDot.tsx index 9688664d110b0779bb7f286832862232ced40bf6..6ca2df8eacfaab9224055f178feb4b51a152cd9b 100644 --- a/web/src/primitives/ToolDot.tsx +++ b/web/src/primitives/ToolDot.tsx @@ -9,7 +9,7 @@ const TOOL_COLORS: Record = { } interface ToolDotProps { - tool: 'claude-code' | 'opencode' | 'crush' | 'pi' | 'kimi' + tool: string } export function ToolDot({ tool }: ToolDotProps): React.JSX.Element { diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index c39a760ff843f5e2248064b52fa8466b362f0b98..22bbf6d023f9f3b0168d6e4365e0bde2fcb767d6 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -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) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 6ac5edd718f1613fe40caaef515e72bb1d7c3a3c..0033910c33f6d0e3261d77b8b582a86a2d45641e 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -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 | null>(null) + +export function useKeyboardCursor(): React.MutableRefObject { + 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(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 ( -
- setPaletteOpen(true)} /> - - setPaletteOpen(false)} /> -
+ +
+ setPaletteOpen(true)} /> + + setPaletteOpen(false)} /> +
+
) } diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index f7aeeab4adc34ac919d7dc02e4b98c0968eef253..a231fbef1d5f2b50ba0adfcc3b48c48af3e766eb 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -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(['1d', '7d', '30d', '90d', 'all']) export const Route = createFileRoute('/')({ + validateSearch: (search: Record): 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 ( + <> + + + +
+ loading… +
+ + ) + } + + if (error != null) { + if (error instanceof AuthError) { + return ( +
+
+
not authenticated
+
Sign in to view your sessions.
+
+
+ ) + } + const detail = error instanceof APIError ? error.message : String(error) + return ( +
+
+
error
+
{detail}
+
+
+ ) + } + return ( <> - recent + -
- -
+ ) } diff --git a/web/src/routes/session.$tool.$host.$id.tsx b/web/src/routes/session.$tool.$host.$id.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27d2fc60dcc2072c6878f3d6186c809e2d1ff3fe --- /dev/null +++ b/web/src/routes/session.$tool.$host.$id.tsx @@ -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 ( + <> + + {tool} + {host} + +
+ +
{id}
+
+ + ) +} diff --git a/web/src/styles/home.css b/web/src/styles/home.css new file mode 100644 index 0000000000000000000000000000000000000000..b9814f43faf322a9be905878389e8fb3a2414ba1 --- /dev/null +++ b/web/src/styles/home.css @@ -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; +}