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-BerkcgAa.js"></script>
- <link rel="stylesheet" crossorigin href="/assets/index-D5vXNu5Y.css">
+ <script type="module" crossorigin src="/assets/index-CS8MuTLY.js"></script>
+ <link rel="stylesheet" crossorigin href="/assets/index-agYuYU0K.css">
</head>
<body class="density-compact">
<div id="root"></div>
M web/src/api/adapters.test.ts => web/src/api/adapters.test.ts +53 -2
@@ 1,6 1,6 @@
import { describe, it, expect } from 'vitest'
-import { adaptSession } from './adapters'
-import type { SessionDTO } from './adapters'
+import { adaptSession, adaptProject } from './adapters'
+import type { SessionDTO, ProjectDTO } from './adapters'
function makeDTO(overrides: Partial<SessionDTO> = {}): SessionDTO {
return {
@@ 57,3 57,54 @@ describe('adaptSession', () => {
expect(s.ended).toBe(new Date(endedAt * 1000).toISOString())
})
})
+
+function makeProjectDTO(overrides: Partial<ProjectDTO> = {}): ProjectDTO {
+ return {
+ cwd: '/home/user/code/project',
+ sessions: 3,
+ turn_count: 12,
+ tokens_in_total: 5000,
+ tokens_out_total: 8000,
+ last_active: 1_700_000_000,
+ hosts: ['laptop', 'workpc'],
+ tools: ['claude-code', 'opencode'],
+ top_tool: 'claude-code',
+ ...overrides,
+ }
+}
+
+describe('adaptProject', () => {
+ it('passes through empty arrays unchanged', () => {
+ const p = adaptProject(makeProjectDTO({ hosts: [], tools: [] }))
+ expect(p.hosts).toEqual([])
+ expect(p.tools).toEqual([])
+ })
+
+ it('last_active=0 → lastActive === ""', () => {
+ const p = adaptProject(makeProjectDTO({ last_active: 0 }))
+ expect(p.lastActive).toBe('')
+ })
+
+ it('last_active=1700000000 → ISO string "2023-11-14T22:13:20.000Z"', () => {
+ const p = adaptProject(makeProjectDTO({ last_active: 1_700_000_000 }))
+ expect(p.lastActive).toBe(new Date(1_700_000_000 * 1000).toISOString())
+ // Verify the concrete value for determinism
+ expect(p.lastActive).toBe('2023-11-14T22:13:20.000Z')
+ })
+
+ it('maps snake_case fields to camelCase', () => {
+ const p = adaptProject(makeProjectDTO())
+ expect(p.turns).toBe(12)
+ expect(p.tokensIn).toBe(5000)
+ expect(p.tokensOut).toBe(8000)
+ expect(p.topTool).toBe('claude-code')
+ expect(p.sessions).toBe(3)
+ expect(p.cwd).toBe('/home/user/code/project')
+ })
+
+ it('passes through hosts and tools arrays', () => {
+ const p = adaptProject(makeProjectDTO({ hosts: ['laptop'], tools: ['claude-code', 'opencode'] }))
+ expect(p.hosts).toEqual(['laptop'])
+ expect(p.tools).toEqual(['claude-code', 'opencode'])
+ })
+})
M web/src/api/adapters.ts => web/src/api/adapters.ts +40 -0
@@ 49,3 49,43 @@ export function adaptSession(d: SessionDTO): Session {
hasError: false,
}
}
+
+// ── Projects ─────────────────────────────────────────────────────────────────
+
+export interface ProjectDTO {
+ cwd: string
+ sessions: number
+ turn_count: number
+ tokens_in_total: number
+ tokens_out_total: number
+ last_active: number // unix epoch seconds; 0 = never
+ hosts: string[]
+ tools: string[]
+ top_tool: string
+}
+
+export interface Project {
+ cwd: string
+ sessions: number
+ turns: number
+ tokensIn: number
+ tokensOut: number
+ lastActive: string // ISO 8601, or '' when last_active === 0
+ hosts: string[]
+ tools: string[]
+ topTool: string
+}
+
+export function adaptProject(d: ProjectDTO): Project {
+ return {
+ cwd: d.cwd,
+ sessions: d.sessions,
+ turns: d.turn_count,
+ tokensIn: d.tokens_in_total,
+ tokensOut: d.tokens_out_total,
+ lastActive: d.last_active === 0 ? '' : new Date(d.last_active * 1000).toISOString(),
+ hosts: d.hosts,
+ tools: d.tools,
+ topTool: d.top_tool,
+ }
+}
A web/src/features/projects/ProjectsTable.tsx => web/src/features/projects/ProjectsTable.tsx +105 -0
@@ 0,0 1,105 @@
+import React from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { EmptyState, ToolDot, Spark } from '../../primitives'
+import type { Project } from '../../api/adapters'
+
+interface ProjectsTableProps {
+ projects: Project[]
+ cursor: number
+ onCursor: (i: number) => void
+ onOpen: (p: Project) => void
+}
+
+const COLS = '1fr 90px 90px 110px 110px 90px'
+
+// 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)
+}
+
+// Format lastActive ISO string: recent = "Apr 25 11:08"; empty = "—"
+function formatLastActive(iso: string): string {
+ if (!iso) return '—'
+ const d = new Date(iso)
+ 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 })
+ )
+}
+
+export function ProjectsTable({ projects, cursor, onCursor, onOpen }: ProjectsTableProps): React.JSX.Element {
+ const navigate = useNavigate()
+
+ if (projects.length === 0) {
+ return (
+ <div className="body body-pad">
+ <EmptyState glyph="∅" copy="no projects match these filters" />
+ </div>
+ )
+ }
+
+ 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) } })
+ }
+
+ const maxSessions = Math.max(...projects.map(p => p.sessions), 1)
+
+ return (
+ <div className="projects-table body">
+ <div className="projects-thead projects-cols" style={{ gridTemplateColumns: COLS }}>
+ <span>cwd</span>
+ <span className="right">sessions</span>
+ <span className="right">tok</span>
+ <span className="right">last</span>
+ <span>top tool</span>
+ <span className="right">activity</span>
+ </div>
+ {projects.map((p, i) => {
+ const isCursor = i === cursor
+ // Build a minimal sparkline: normalise sessions relative to max, scaled to 0–12px height
+ const sparkPoints = [0, (p.sessions / maxSessions) * 12]
+ return (
+ <div
+ key={p.cwd}
+ className={'projects-row projects-cols' + (isCursor ? ' cursor' : '')}
+ style={{ gridTemplateColumns: COLS }}
+ onClick={() => { onCursor(i); handleOpen(p) }}
+ onMouseEnter={() => onCursor(i)}
+ >
+ <span className="mono truncate">{p.cwd}</span>
+ <span className="right mono">{p.sessions}</span>
+ <span className="right mono muted">{formatTok(p.tokensIn + p.tokensOut)}</span>
+ <span className="right mono muted">{formatLastActive(p.lastActive)}</span>
+ <span className="mono">
+ {p.topTool ? (
+ <>
+ <ToolDot tool={p.topTool} />
+ {' '}{p.topTool}
+ </>
+ ) : (
+ <span className="muted">—</span>
+ )}
+ </span>
+ <span className="right">
+ <Spark points={sparkPoints} w={70} h={12} accent={i === 0} />
+ </span>
+ </div>
+ )
+ })}
+ </div>
+ )
+}
A web/src/features/projects/useProjects.ts => web/src/features/projects/useProjects.ts +45 -0
@@ 0,0 1,45 @@
+import { useQuery } from '@tanstack/react-query'
+import type { UseQueryResult } from '@tanstack/react-query'
+import { apiFetch } from '../../api/client'
+import { adaptProject } from '../../api/adapters'
+import type { Project, ProjectDTO } from '../../api/adapters'
+
+export interface ProjectFilters {
+ since?: '7d' | '30d' | '90d' | 'all'
+}
+
+interface ProjectsResponse {
+ projects: ProjectDTO[]
+ limit: number
+ offset: number
+}
+
+function sinceToEpoch(since: string): number {
+ const now = Math.floor(Date.now() / 1000)
+ switch (since) {
+ case '7d': return now - 7 * 86400
+ case '30d': return now - 30 * 86400
+ case '90d': return now - 90 * 86400
+ default: return 0
+ }
+}
+
+export function useProjects(filters: ProjectFilters): UseQueryResult<Project[]> {
+ const since = filters.since ?? '30d'
+
+ return useQuery({
+ queryKey: ['projects', filters],
+ queryFn: async () => {
+ const params = new URLSearchParams()
+
+ if (since !== 'all') {
+ params.set('since', String(sinceToEpoch(since)))
+ }
+
+ const qs = params.toString()
+ const url = `/api/v1/projects${qs ? `?${qs}` : ''}`
+ const data = await apiFetch<ProjectsResponse>(url)
+ return data.projects.map(adaptProject)
+ },
+ })
+}
A web/src/features/projects/useProjectsCursor.ts => web/src/features/projects/useProjectsCursor.ts +37 -0
@@ 0,0 1,37 @@
+import { useState, useCallback } from 'react'
+
+export interface ProjectsCursor {
+ cursor: number
+ move(d: 1 | -1): void
+ activate(): void
+ jumpTo(i: number): void
+}
+
+export function useProjectsCursor(
+ projectCount: number,
+ onActivate: (idx: number) => void,
+): ProjectsCursor {
+ const [cursor, setCursor] = useState(0)
+
+ const move = useCallback(
+ (d: 1 | -1) => {
+ if (projectCount === 0) return
+ setCursor(c => Math.max(0, Math.min(projectCount - 1, c + d)))
+ },
+ [projectCount],
+ )
+
+ const activate = useCallback(() => {
+ onActivate(cursor)
+ }, [cursor, onActivate])
+
+ const jumpTo = useCallback(
+ (i: number) => {
+ if (projectCount === 0) return
+ setCursor(Math.max(0, Math.min(projectCount - 1, i)))
+ },
+ [projectCount],
+ )
+
+ return { cursor, move, activate, jumpTo }
+}
M web/src/routes/projects.tsx => web/src/routes/projects.tsx +141 -9
@@ 1,21 1,153 @@
-import { createFileRoute } from '@tanstack/react-router'
-import React from 'react'
-import { EmptyState, Tag } from '../primitives'
+import React, { useEffect, useCallback } from 'react'
+import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SubBar } from '../shell/SubBar'
+import { Sub } from '../primitives'
+import { ProjectsTable } from '../features/projects/ProjectsTable'
+import { useProjects } from '../features/projects/useProjects'
+import { useProjectsCursor } from '../features/projects/useProjectsCursor'
+import { useKeyboardCursor } from './__root'
+import { AuthError, APIError } from '../api/client'
+import type { ProjectFilters } from '../features/projects/useProjects'
+import type { Project } from '../api/adapters'
+import '../styles/projects.css'
+
+type ProjectsSearch = {
+ since?: '7d' | '30d' | '90d' | 'all'
+}
+
+const VALID_SINCE = new Set<string>(['7d', '30d', '90d', 'all'])
export const Route = createFileRoute('/projects')({
+ validateSearch: (search: Record<string, unknown>): ProjectsSearch => {
+ const since =
+ typeof search['since'] === 'string' && VALID_SINCE.has(search['since'])
+ ? (search['since'] as ProjectsSearch['since'])
+ : undefined
+ return { since }
+ },
component: ProjectsRoute,
})
+function SinceButtonGroup({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (v: ProjectsSearch['since']) => void
+}): React.JSX.Element {
+ const options = ['7d', '30d', '90d', 'all'] as const
+ return (
+ <div className="since-btn-group">
+ {options.map(o => (
+ <button
+ key={o}
+ className={'since-btn' + (value === o ? ' active' : '')}
+ onClick={() => onChange(o)}
+ type="button"
+ >
+ {o}
+ </button>
+ ))}
+ </div>
+ )
+}
+
function ProjectsRoute(): React.JSX.Element {
+ const navigate = useNavigate()
+ const search = Route.useSearch()
+
+ const since = search.since ?? '30d'
+
+ const filters: ProjectFilters = { since }
+
+ 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) } })
+ },
+ [navigate],
+ )
+
+ const { cursor, move, activate, jumpTo } = useProjectsCursor(
+ projects?.length ?? 0,
+ useCallback(
+ (idx: number) => {
+ if (projects && projects[idx]) handleOpen(projects[idx])
+ },
+ [projects, handleOpen],
+ ),
+ )
+
+ const cursorRef = useKeyboardCursor()
+ useEffect(() => {
+ cursorRef.current = { move, activate }
+ return () => {
+ cursorRef.current = {
+ move: (_d: 1 | -1) => { /* no-op */ },
+ activate: () => { /* no-op */ },
+ }
+ }
+ }, [cursorRef, move, activate])
+
+ function handleSinceChange(next: ProjectsSearch['since']) {
+ void navigate({ to: '/projects', search: { since: next } })
+ }
+
+ const subBar = (
+ <SubBar>
+ <span className="mono muted">
+ {projects != null ? `${projects.length} projects` : '…'} · ranked by recent activity
+ </span>
+ <span style={{ flex: 1 }} />
+ <SinceButtonGroup value={since} onChange={handleSinceChange} />
+ </SubBar>
+ )
+
+ if (isLoading) {
+ return (
+ <>
+ {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 projects.</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">projects</Tag>
- </SubBar>
- <div className="body body-pad">
- <EmptyState glyph="∅" copy="coming in a later task" />
- </div>
+ {subBar}
+ <ProjectsTable
+ projects={projects ?? []}
+ cursor={cursor}
+ onCursor={jumpTo}
+ onOpen={handleOpen}
+ />
</>
)
}
A web/src/styles/projects.css => web/src/styles/projects.css +77 -0
@@ 0,0 1,77 @@
+/* Projects route — projects table */
+
+.projects-table {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+
+.projects-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;
+}
+
+.projects-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);
+}
+
+.projects-row:hover {
+ background: var(--paper-2);
+}
+
+.projects-row.cursor {
+ background: var(--accent-soft);
+ border-left: 2px solid var(--accent);
+ padding-left: 12px;
+}
+
+/* Column grid: CWD · SESSIONS · TOK · LAST · TOP TOOL · ACTIVITY */
+.projects-cols {
+ grid-template-columns: 1fr 90px 90px 110px 110px 90px;
+}
+
+/* Since filter button group in the SubBar */
+.since-btn-group {
+ display: flex;
+ gap: 2px;
+}
+
+.since-btn {
+ padding: 2px 8px;
+ font-size: 10.5px;
+ font-family: var(--mono);
+ border: 1px solid var(--rule);
+ background: transparent;
+ color: var(--ink-3);
+ cursor: pointer;
+ border-radius: 3px;
+ line-height: 1.6;
+}
+
+.since-btn:hover {
+ background: var(--paper-2);
+ color: var(--ink);
+}
+
+.since-btn.active {
+ background: var(--accent-soft);
+ border-color: var(--accent);
+ color: var(--accent-ink);
+}