~bigbes/lethe

b100feee542aaacd159d90f8c35b2c68a26e8893 — Eugene Blikh a month ago 0b51b8e
web: home route with real session list, filters, keyboard cursor

- 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)
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;
}