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 @@
-
-
+
+
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 {
@@ -43,6 +44,9 @@ params.set('tool', filters.tool)
}
if (filters.host) {
params.set('host', filters.host)
+ }
+ if (filters.cwd) {
+ params.set('cwd', filters.cwd)
}
const qs = params.toString()
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 @@ }
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({
@@ -45,6 +46,11 @@ } as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ProjectSplatRoute = ProjectSplatRouteImport.update({
+ id: '/project/$',
+ path: '/project/$',
getParentRoute: () => rootRouteImport,
} as any)
const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({
@@ -60,6 +66,7 @@ '/projects': typeof ProjectsRoute
'/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 @@ '/projects': typeof ProjectsRoute
'/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 @@ '/projects': typeof ProjectsRoute
'/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 @@ | '/projects'
| '/search'
| '/settings'
| '/stats'
+ | '/project/$'
| '/session/$tool/$host/$id'
fileRoutesByTo: FileRoutesByTo
to:
@@ -99,6 +109,7 @@ | '/projects'
| '/search'
| '/settings'
| '/stats'
+ | '/project/$'
| '/session/$tool/$host/$id'
id:
| '__root__'
@@ -108,6 +119,7 @@ | '/projects'
| '/search'
| '/settings'
| '/stats'
+ | '/project/$'
| '/session/$tool/$host/$id'
fileRoutesById: FileRoutesById
}
@@ -118,6 +130,7 @@ ProjectsRoute: typeof ProjectsRoute
SearchRoute: typeof SearchRoute
SettingsRoute: typeof SettingsRoute
StatsRoute: typeof StatsRoute
+ ProjectSplatRoute: typeof ProjectSplatRoute
SessionToolHostIdRoute: typeof SessionToolHostIdRoute
}
@@ -165,6 +178,13 @@ fullPath: '/'
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 @@ ProjectsRoute: ProjectsRoute,
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 (
+
+ )
+ }
+
+ // 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 @@ const { data: projects, isLoading, error } = useProjects(filters)
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 @@ .projects-cols {
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;