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 => +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;