~bigbes/lethe

346e6a81d673e2daf06604b9a838b08ba9dbdbef — Eugene Blikh a month ago e075b98
web: project detail route scoped via ?cwd= sessions filter
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-CS8MuTLY.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-agYuYU0K.css">
    <script type="module" crossorigin src="/assets/index-CEOCy6TW.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-DiBu9BC_.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>

M web/src/features/home/useSessions.ts => web/src/features/home/useSessions.ts +4 -0
@@ 8,6 8,7 @@ export interface HomeFilters {
  since?: '1d' | '7d' | '30d' | '90d' | 'all'
  tool?: Tool
  host?: Host
  cwd?: string
}

interface SessionsResponse {


@@ 44,6 45,9 @@ export function useSessions(filters: HomeFilters): UseQueryResult<Session[]> {
      if (filters.host) {
        params.set('host', filters.host)
      }
      if (filters.cwd) {
        params.set('cwd', filters.cwd)
      }

      const qs = params.toString()
      const url = `/api/v1/sessions${qs ? `?${qs}` : ''}`

A web/src/features/projects/ProjectHeader.tsx => web/src/features/projects/ProjectHeader.tsx +50 -0
@@ 0,0 1,50 @@
import React from 'react'
import { Tag, Spark } from '../../primitives'
import type { Project } from '../../api/adapters'

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

interface ProjectHeaderProps {
  cwd: string
  project: Project | undefined
  sessionCount: number
  hosts: string[]
}

export function ProjectHeader({ cwd, project, sessionCount, hosts }: ProjectHeaderProps): React.JSX.Element {
  // Split cwd at the last '/' to get parent path and last segment
  const lastSlash = cwd.lastIndexOf('/')
  const parent = lastSlash >= 0 ? cwd.slice(0, lastSlash + 1) : ''
  const lastSeg = lastSlash >= 0 ? cwd.slice(lastSlash + 1) : cwd

  const tokTotal = project != null ? project.tokensIn + project.tokensOut : 0

  // Build a minimal sparkline: normalise sessions relative to max sessions
  const sparkPoints = project != null ? [0, Math.min(project.sessions, 12)] : [0, 0]

  return (
    <div className="project-header">
      {parent !== '' && (
        <div className="mono muted" style={{ fontSize: 11 }}>{parent}</div>
      )}
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
        <span className="mono" style={{ fontSize: 18, fontWeight: 600 }}>{lastSeg || cwd}</span>
        <Tag kind="accent">{sessionCount} sessions</Tag>
        {project != null && (
          <Tag kind="neutral">{formatTok(tokTotal)} tok</Tag>
        )}
        {hosts.map(h => (
          <Tag key={h} kind="host">{h}</Tag>
        ))}
        <span style={{ flex: 1 }} />
        <Spark points={sparkPoints} w={120} h={18} accent />
      </div>
    </div>
  )
}

M web/src/features/projects/ProjectsTable.tsx => web/src/features/projects/ProjectsTable.tsx +1 -3
@@ 51,9 51,7 @@ export function ProjectsTable({ projects, cursor, onCursor, onOpen }: ProjectsTa

  function handleOpen(p: Project) {
    onOpen(p)
    // Route /project/$ is added in Phase 4; cast needed until routeTree is regenerated.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    void (navigate as any)({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } })
    void navigate({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } })
  }

  const maxSessions = Math.max(...projects.map(p => p.sessions), 1)

M web/src/routeTree.gen.ts => web/src/routeTree.gen.ts +21 -0
@@ 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 ProjectSplatRouteImport } from './routes/project.$'
import { Route as SessionToolHostIdRouteImport } from './routes/session.$tool.$host.$id'

const StatsRoute = StatsRouteImport.update({


@@ 47,6 48,11 @@ const IndexRoute = IndexRouteImport.update({
  path: '/',
  getParentRoute: () => rootRouteImport,
} as any)
const ProjectSplatRoute = ProjectSplatRouteImport.update({
  id: '/project/$',
  path: '/project/$',
  getParentRoute: () => rootRouteImport,
} as any)
const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({
  id: '/session/$tool/$host/$id',
  path: '/session/$tool/$host/$id',


@@ 60,6 66,7 @@ export interface FileRoutesByFullPath {
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRoutesByTo {


@@ 69,6 76,7 @@ export interface FileRoutesByTo {
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRoutesById {


@@ 79,6 87,7 @@ export interface FileRoutesById {
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
  '/project/$': typeof ProjectSplatRoute
  '/session/$tool/$host/$id': typeof SessionToolHostIdRoute
}
export interface FileRouteTypes {


@@ 90,6 99,7 @@ export interface FileRouteTypes {
    | '/search'
    | '/settings'
    | '/stats'
    | '/project/$'
    | '/session/$tool/$host/$id'
  fileRoutesByTo: FileRoutesByTo
  to:


@@ 99,6 109,7 @@ export interface FileRouteTypes {
    | '/search'
    | '/settings'
    | '/stats'
    | '/project/$'
    | '/session/$tool/$host/$id'
  id:
    | '__root__'


@@ 108,6 119,7 @@ export interface FileRouteTypes {
    | '/search'
    | '/settings'
    | '/stats'
    | '/project/$'
    | '/session/$tool/$host/$id'
  fileRoutesById: FileRoutesById
}


@@ 118,6 130,7 @@ export interface RootRouteChildren {
  SearchRoute: typeof SearchRoute
  SettingsRoute: typeof SettingsRoute
  StatsRoute: typeof StatsRoute
  ProjectSplatRoute: typeof ProjectSplatRoute
  SessionToolHostIdRoute: typeof SessionToolHostIdRoute
}



@@ 165,6 178,13 @@ declare module '@tanstack/react-router' {
      preLoaderRoute: typeof IndexRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/project/$': {
      id: '/project/$'
      path: '/project/$'
      fullPath: '/project/$'
      preLoaderRoute: typeof ProjectSplatRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/session/$tool/$host/$id': {
      id: '/session/$tool/$host/$id'
      path: '/session/$tool/$host/$id'


@@ 182,6 202,7 @@ const rootRouteChildren: RootRouteChildren = {
  SearchRoute: SearchRoute,
  SettingsRoute: SettingsRoute,
  StatsRoute: StatsRoute,
  ProjectSplatRoute: ProjectSplatRoute,
  SessionToolHostIdRoute: SessionToolHostIdRoute,
}
export const routeTree = rootRouteImport

A web/src/routes/project.$.tsx => web/src/routes/project.$.tsx +123 -0
@@ 0,0 1,123 @@
import React, { useEffect, useCallback } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
import { Sub, EmptyState } from '../primitives'
import { ProjectHeader } from '../features/projects/ProjectHeader'
import { SessionsTable } from '../features/home/SessionsTable'
import { useSessions } from '../features/home/useSessions'
import { useProjects } from '../features/projects/useProjects'
import { useHomeCursor } from '../features/home/useHomeCursor'
import { useKeyboardCursor } from './__root'
import { AuthError, APIError } from '../api/client'
import type { Session } from '../api/adapters'
import '../styles/projects.css'

export const Route = createFileRoute('/project/$')({
  component: ProjectRoute,
})

function ProjectRoute(): React.JSX.Element {
  const navigate = useNavigate()
  const { _splat } = Route.useParams()
  const cwd = decodeURIComponent(_splat ?? '')

  const { data: sessions, isLoading: sessionsLoading, error: sessionsError } = useSessions({ cwd, since: 'all' })
  const { data: projects, isLoading: projectsLoading } = useProjects({ since: 'all' })

  const project = projects?.find(p => p.cwd === cwd)

  const isLoading = sessionsLoading || projectsLoading
  const error = sessionsError

  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]),
  )

  const cursorRef = useKeyboardCursor()
  useEffect(() => {
    cursorRef.current = { move, activate }
    return () => {
      cursorRef.current = {
        move: (_d: 1 | -1) => { /* no-op */ },
        activate: () => { /* no-op */ },
      }
    }
  }, [cursorRef, move, activate])

  // Collect unique hosts from the loaded sessions
  const hosts = sessions != null
    ? Array.from(new Set(sessions.map(s => s.host))).sort()
    : (project?.hosts ?? [])

  const sessionCount = sessions?.length ?? project?.sessions ?? 0

  if (isLoading) {
    return (
      <>
        <SubBar>
          <span className="mono muted truncate">{cwd}</span>
        </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 this project.</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>
    )
  }

  // If the project was not found in the index and sessions came back empty,
  // render an EmptyState rather than a blank page.
  if (project === undefined && (sessions == null || sessions.length === 0)) {
    return (
      <>
        <ProjectHeader cwd={cwd} project={undefined} sessionCount={0} hosts={[]} />
        <div className="body body-pad">
          <EmptyState glyph="∅" copy="no sessions found for this project" />
        </div>
      </>
    )
  }

  return (
    <>
      <ProjectHeader cwd={cwd} project={project} sessionCount={sessionCount} hosts={hosts} />
      <SubBar>
        <span className="mono muted">{sessionCount} sessions</span>
      </SubBar>
      <SessionsTable
        sessions={sessions ?? []}
        cursor={cursor}
        onCursor={jumpTo}
        onOpen={handleOpen}
      />
    </>
  )
}

M web/src/routes/projects.tsx => web/src/routes/projects.tsx +1 -3
@@ 64,9 64,7 @@ function ProjectsRoute(): React.JSX.Element {

  const handleOpen = useCallback(
    (p: Project) => {
      // Route /project/$ is added in Phase 4; cast needed until routeTree is regenerated.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      void (navigate as any)({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } })
      void navigate({ to: '/project/$', params: { _splat: encodeURIComponent(p.cwd) } })
    },
    [navigate],
  )

M web/src/styles/projects.css => web/src/styles/projects.css +9 -0
@@ 47,6 47,15 @@
  grid-template-columns: 1fr 90px 90px 110px 110px 90px;
}

/* Project detail header card */

.project-header {
  padding: 10px 14px;
  border-bottom: 1px solid var(--rule-2);
  background: var(--paper-3);
  flex: none;
}

/* Since filter button group in the SubBar */
.since-btn-group {
  display: flex;