From 346e6a81d673e2daf06604b9a838b08ba9dbdbef Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:39:23 +0300 Subject: [PATCH] web: project detail route scoped via ?cwd= sessions filter --- internal/server/web/dist/index.html | 4 +- web/src/features/home/useSessions.ts | 4 + web/src/features/projects/ProjectHeader.tsx | 50 ++++++++ web/src/features/projects/ProjectsTable.tsx | 4 +- web/src/routeTree.gen.ts | 21 ++++ web/src/routes/project.$.tsx | 123 ++++++++++++++++++++ web/src/routes/projects.tsx | 4 +- web/src/styles/projects.css | 9 ++ 8 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 web/src/features/projects/ProjectHeader.tsx create mode 100644 web/src/routes/project.$.tsx diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index 29249f12b2cf6264081f86de4a153a75fe03cb50..9a01f2ae8745dc0653da5876256dd516ef94af07 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/features/home/useSessions.ts b/web/src/features/home/useSessions.ts index a676c58a9737cc671f71144241c5a74501403b9c..2bb3e7d88ba1c1480cdcb267bab62474436822e8 100644 --- a/web/src/features/home/useSessions.ts +++ b/web/src/features/home/useSessions.ts @@ -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 { 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}` : ''}` diff --git a/web/src/features/projects/ProjectHeader.tsx b/web/src/features/projects/ProjectHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d190f5ff0eb628e277b5635954f0d32b455666aa --- /dev/null +++ b/web/src/features/projects/ProjectHeader.tsx @@ -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 ( +
+ {parent !== '' && ( +
{parent}
+ )} +
+ {lastSeg || cwd} + {sessionCount} sessions + {project != null && ( + {formatTok(tokTotal)} tok + )} + {hosts.map(h => ( + {h} + ))} + + +
+
+ ) +} diff --git a/web/src/features/projects/ProjectsTable.tsx b/web/src/features/projects/ProjectsTable.tsx index 47b36e16d710502368922e3eb8bb54034657a883..01551aa9860cd82eda2c918f0393677ecfcb6edd 100644 --- a/web/src/features/projects/ProjectsTable.tsx +++ b/web/src/features/projects/ProjectsTable.tsx @@ -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) diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 22bbf6d023f9f3b0168d6e4365e0bde2fcb767d6..ee5b119177693888188feb82954bf5e35fdb8e21 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 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 diff --git a/web/src/routes/project.$.tsx b/web/src/routes/project.$.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d9e100846efaa7fea6c29a08240c688cd4e03e2 --- /dev/null +++ b/web/src/routes/project.$.tsx @@ -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 ( + <> + + {cwd} + +
+ loading… +
+ + ) + } + + if (error != null) { + if (error instanceof AuthError) { + return ( +
+
+
not authenticated
+
Sign in to view this project.
+
+
+ ) + } + const detail = error instanceof APIError ? error.message : String(error) + return ( +
+
+
error
+
{detail}
+
+
+ ) + } + + // 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 ( + <> + +
+ +
+ + ) + } + + return ( + <> + + + {sessionCount} sessions + + + + ) +} diff --git a/web/src/routes/projects.tsx b/web/src/routes/projects.tsx index 57a7d77b5bdd32fb5bd7ffa3578e095df9628cb3..091624c16a478e730228196c62424e30b07bcd13 100644 --- a/web/src/routes/projects.tsx +++ b/web/src/routes/projects.tsx @@ -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], ) diff --git a/web/src/styles/projects.css b/web/src/styles/projects.css index 7e1948584a0391707cd54bf9d3fd8c47b9f80de7..5bd0fab4fa102e4476feca7f1e81509131c317af 100644 --- a/web/src/styles/projects.css +++ b/web/src/styles/projects.css @@ -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;