From 9c38c31923166e9e6cc4d656019c57c0ebc5e61d Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Fri, 19 Jun 2026 07:52:55 +0200 Subject: [PATCH 1/2] feat(dashboard): add project from dashboard --- apps/cli/src/commands/results/serve.ts | 125 +++++++ apps/cli/test/commands/results/serve.test.ts | 97 ++++++ .../src/components/AddProjectModal.tsx | 316 ++++++++++++++++++ apps/dashboard/src/lib/api.test.ts | 72 ++++ apps/dashboard/src/lib/api.ts | 42 ++- apps/dashboard/src/lib/types.ts | 34 ++ apps/dashboard/src/routes/index.tsx | 66 ++-- .../src/content/docs/docs/tools/dashboard.mdx | 5 +- 8 files changed, 711 insertions(+), 46 deletions(-) create mode 100644 apps/dashboard/src/components/AddProjectModal.tsx create mode 100644 apps/dashboard/src/lib/api.test.ts diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 2511d6cf1..81a0608de 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -35,6 +35,7 @@ */ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { command, flag, number, option, optional, positional, string } from 'cmd-ts'; @@ -1282,6 +1283,120 @@ function handleFeedbackRead(c: C, { searchDir }: DataContext) { return c.json(readFeedback(existsSync(resultsDir) ? resultsDir : searchDir)); } +function expandHomePath(inputPath: string): string { + if (inputPath === '~') return homedir(); + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(homedir(), inputPath.slice(2)); + } + return inputPath; +} + +function resolveBrowsePath(inputPath: string | undefined, cwd: string): string { + const trimmed = inputPath?.trim() ?? ''; + const expanded = trimmed.length > 0 ? expandHomePath(trimmed) : cwd; + return path.resolve(cwd, expanded); +} + +function hasAgentvDir(dirPath: string): boolean { + try { + return statSync(path.join(dirPath, '.agentv')).isDirectory(); + } catch { + return false; + } +} + +interface DirectoryBrowseEntry { + name: string; + path: string; + hasAgentv: boolean; +} + +interface DirectoryBrowseResult { + path: string; + parentPath?: string; + current: DirectoryBrowseEntry; + entries: DirectoryBrowseEntry[]; +} + +function directoryBrowseEntry(dirPath: string): DirectoryBrowseEntry { + return { + name: path.basename(dirPath) || dirPath, + path: dirPath, + hasAgentv: hasAgentvDir(dirPath), + }; +} + +function browseFilesystemDirectories( + inputPath: string | undefined, + cwd: string, +): DirectoryBrowseResult { + const browsePath = resolveBrowsePath(inputPath, cwd); + + if (!existsSync(browsePath)) { + throw new Error(`Directory not found: ${browsePath}`); + } + + let stats: ReturnType; + try { + stats = statSync(browsePath); + } catch (err) { + throw new Error(`Unable to read directory: ${(err as Error).message}`); + } + + if (!stats.isDirectory()) { + throw new Error(`Not a directory: ${browsePath}`); + } + + let entries: DirectoryBrowseEntry[]; + try { + entries = readdirSync(browsePath, { withFileTypes: true }) + .map((entry) => { + const entryPath = path.join(browsePath, entry.name); + if (entry.isDirectory()) return directoryBrowseEntry(entryPath); + if (entry.isSymbolicLink()) { + try { + return statSync(entryPath).isDirectory() ? directoryBrowseEntry(entryPath) : null; + } catch { + return null; + } + } + return null; + }) + .filter((entry): entry is ReturnType => entry !== null) + .sort((a, b) => { + if (a.hasAgentv !== b.hasAgentv) return a.hasAgentv ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } catch (err) { + throw new Error(`Unable to read directory: ${(err as Error).message}`); + } + + const parentPath = path.dirname(browsePath); + return { + path: browsePath, + parentPath: parentPath !== browsePath ? parentPath : undefined, + current: directoryBrowseEntry(browsePath), + entries, + }; +} + +function directoryBrowseEntryToWire(entry: DirectoryBrowseEntry) { + return { + name: entry.name, + path: entry.path, + has_agentv: entry.hasAgentv, + }; +} + +function directoryBrowseResultToWire(result: DirectoryBrowseResult) { + return { + path: result.path, + ...(result.parentPath !== undefined && { parent_path: result.parentPath }), + current: directoryBrowseEntryToWire(result.current), + entries: result.entries.map(directoryBrowseEntryToWire), + }; +} + async function handleRunTagsPut(c: C, { searchDir, projectId }: DataContext) { const filename = c.req.param('filename') ?? ''; const meta = await findRunById(searchDir, filename, projectId); @@ -1603,6 +1718,16 @@ export function createApp( return c.json({ projects }); }); + app.get('/api/filesystem/browse', (c) => { + try { + return c.json( + directoryBrowseResultToWire(browseFilesystemDirectories(c.req.query('path'), searchDir)), + ); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } + }); + app.post('/api/projects', async (c) => { if (readOnly) { return c.json({ error: 'Dashboard is running in read-only mode' }, 403); diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index a3a5b2ae3..cfd1c78b0 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -495,6 +495,103 @@ describe('serve app', () => { }); }); + // ── GET /api/filesystem/browse ──────────────────────────────────────── + + describe('GET /api/filesystem/browse', () => { + it('lists child directories and marks AgentV project folders', async () => { + const projectDir = path.join(tempDir, 'project-with-agentv'); + const bareDir = path.join(tempDir, 'plain-folder'); + mkdirSync(path.join(projectDir, '.agentv'), { recursive: true }); + mkdirSync(bareDir, { recursive: true }); + writeFileSync(path.join(tempDir, 'not-a-folder.txt'), 'ignored'); + + const app = makeApp(); + const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(tempDir)}`); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + path: string; + parent_path?: string; + current: { name: string; path: string; has_agentv: boolean }; + entries: Array<{ name: string; path: string; has_agentv: boolean }>; + }; + expect(data.path).toBe(tempDir); + expect(data.parent_path).toBe(path.dirname(tempDir)); + expect(data.current).toMatchObject({ + name: path.basename(tempDir), + path: tempDir, + has_agentv: false, + }); + expect(data.entries).toEqual([ + { name: 'project-with-agentv', path: projectDir, has_agentv: true }, + { name: 'plain-folder', path: bareDir, has_agentv: false }, + { name: 'studio-dist', path: studioDir, has_agentv: false }, + ]); + }); + + it('returns an understandable error for non-directory paths', async () => { + const filePath = path.join(tempDir, 'not-a-folder.txt'); + writeFileSync(filePath, 'not a directory'); + + const app = makeApp(); + const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(filePath)}`); + + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain('Not a directory'); + expect(data.error).toContain(filePath); + }); + }); + + // ── POST /api/projects ──────────────────────────────────────────────── + + describe('POST /api/projects', () => { + it('registers a selected AgentV project directory', async () => { + const previousHome = process.env.AGENTV_HOME; + process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-register'); + + try { + const projectDir = path.join(tempDir, 'project-to-register'); + mkdirSync(path.join(projectDir, '.agentv'), { recursive: true }); + + const app = makeApp(); + const create = await app.request('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: projectDir }), + }); + + expect(create.status).toBe(201); + const created = (await create.json()) as { + id: string; + name: string; + path: string; + added_at: string; + last_opened_at: string; + }; + expect(created).toMatchObject({ + id: 'project-to-register', + name: 'project-to-register', + path: projectDir, + }); + expect(created.added_at).toBeTruthy(); + expect(created.last_opened_at).toBeTruthy(); + + const list = await app.request('/api/projects'); + const data = (await list.json()) as { projects: Array<{ id: string; path: string }> }; + expect(data.projects).toEqual([ + expect.objectContaining({ id: 'project-to-register', path: projectDir }), + ]); + } finally { + if (previousHome === undefined) { + process.env.AGENTV_HOME = undefined; + } else { + process.env.AGENTV_HOME = previousHome; + } + } + }); + }); + // ── GET /api/feedback ────────────────────────────────────────────────── describe('GET /api/feedback', () => { diff --git a/apps/dashboard/src/components/AddProjectModal.tsx b/apps/dashboard/src/components/AddProjectModal.tsx new file mode 100644 index 000000000..93746ec1b --- /dev/null +++ b/apps/dashboard/src/components/AddProjectModal.tsx @@ -0,0 +1,316 @@ +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { addProjectApi, browseFilesystemApi } from '~/lib/api'; +import type { FilesystemBrowseEntry, FilesystemBrowseResponse, ProjectEntry } from '~/lib/types'; + +interface AddProjectModalProps { + open: boolean; + onClose: () => void; + onAdded: (project: ProjectEntry) => void; +} + +interface DirectoryRowProps { + entry: FilesystemBrowseEntry; + selected: boolean; + current?: boolean; + onSelect: () => void; + onOpen: () => void; +} + +function DirectoryRow({ entry, selected, current = false, onSelect, onOpen }: DirectoryRowProps) { + return ( +
+ + +
+ ); +} + +export function AddProjectModal({ open, onClose, onAdded }: AddProjectModalProps) { + const dialogRef = useRef(null); + const [browseData, setBrowseData] = useState(null); + const [pathInput, setPathInput] = useState(''); + const [selectedPath, setSelectedPath] = useState(''); + const [browseError, setBrowseError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const loadPath = useCallback( + async (nextPath?: string, options: { selectCurrent?: boolean } = {}) => { + setLoading(true); + setBrowseError(null); + setSubmitError(null); + try { + const data = await browseFilesystemApi(nextPath); + setBrowseData(data); + setPathInput(data.path); + setSelectedPath(options.selectCurrent && data.current.hasAgentv ? data.current.path : ''); + } catch (err) { + setBrowseError((err as Error).message); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + if (!open) return; + setBrowseData(null); + setPathInput(''); + setSelectedPath(''); + setBrowseError(null); + setSubmitError(null); + setSubmitting(false); + dialogRef.current?.focus(); + void loadPath(undefined, { selectCurrent: true }); + }, [loadPath, open]); + + useEffect(() => { + if (!open) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose, open]); + + const selectedEntry = useMemo(() => { + if (!browseData || !selectedPath) return null; + if (selectedPath === browseData.current.path) return browseData.current; + return browseData.entries.find((entry) => entry.path === selectedPath) ?? null; + }, [browseData, selectedPath]); + + const canSubmit = Boolean(selectedEntry?.hasAgentv) && !submitting; + + function handleBrowseSubmit(event: FormEvent) { + event.preventDefault(); + void loadPath(pathInput, { selectCurrent: true }); + } + + async function handleAddProject() { + if (!selectedPath || !canSubmit) return; + setSubmitting(true); + setSubmitError(null); + try { + const project = await addProjectApi(selectedPath); + onAdded(project); + onClose(); + } catch (err) { + setSubmitError((err as Error).message); + } finally { + setSubmitting(false); + } + } + + if (!open) return null; + + const selectionMessage = selectedPath + ? selectedEntry?.hasAgentv + ? `Ready to add ${selectedEntry.name}.` + : 'Selected folder does not contain an .agentv/ directory.' + : 'Select a folder containing an .agentv/ directory.'; + + return ( + +
+
+

Add project

+ +
+ +
+
+ + setPathInput(event.target.value)} + placeholder="Folder path" + className="min-w-0 flex-1 rounded-md border border-gray-700 bg-gray-950 px-3 py-1.5 text-sm text-gray-100 placeholder:text-gray-600 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500 disabled:opacity-50" + disabled={loading || submitting} + /> +
+ + + +
+
+ + {browseError ? ( +
+ {browseError} +
+ ) : null} + +
+
+

+ Current folder +

+

+ {browseData?.path ?? (loading ? 'Loading folders...' : 'No folder loaded')} +

+
+
+ {browseData ? ( + <> + setSelectedPath(browseData.current.path)} + onOpen={() => void loadPath(browseData.current.path, { selectCurrent: true })} + /> + {browseData.entries.map((entry) => ( + setSelectedPath(entry.path)} + onOpen={() => void loadPath(entry.path, { selectCurrent: true })} + /> + ))} + {browseData.entries.length === 0 ? ( +

+ No subfolders in this location. +

+ ) : null} + + ) : ( +

+ {loading ? 'Loading folders...' : 'Enter a folder path to browse.'} +

+ )} +
+
+ +
+
+
+

+ Selected +

+

{selectedPath || 'None'}

+
+

+ {selectionMessage} +

+
+
+ + {submitError ? ( +
+ {submitError} +
+ ) : null} +
+ +
+

+ {browseData ? `${browseData.entries.length} folders` : '0 folders'} +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts new file mode 100644 index 000000000..81b8b9996 --- /dev/null +++ b/apps/dashboard/src/lib/api.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { addProjectApi, browseFilesystemApi } from './api'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function stubJsonResponse(body: unknown, status = 200) { + const calls: Array<{ url: string | URL | Request; init?: RequestInit }> = []; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }) as typeof fetch; + return calls; +} + +describe('dashboard API boundary mapping', () => { + it('maps filesystem browse wire keys to camelCase UI data', async () => { + const calls = stubJsonResponse({ + path: '/tmp', + parent_path: '/', + current: { name: 'tmp', path: '/tmp', has_agentv: false }, + entries: [{ name: 'demo', path: '/tmp/demo', has_agentv: true }], + }); + + const result = await browseFilesystemApi('/tmp'); + + expect(calls[0].url).toBe('/api/filesystem/browse?path=%2Ftmp'); + expect(result).toEqual({ + path: '/tmp', + parentPath: '/', + current: { name: 'tmp', path: '/tmp', hasAgentv: false }, + entries: [{ name: 'demo', path: '/tmp/demo', hasAgentv: true }], + }); + }); + + it('maps add-project wire keys to camelCase UI data', async () => { + const calls = stubJsonResponse({ + id: 'demo', + name: 'demo', + path: '/tmp/demo', + added_at: '2026-06-19T05:00:00.000Z', + last_opened_at: '2026-06-19T05:00:00.000Z', + }); + + const result = await addProjectApi('/tmp/demo'); + + expect(calls[0]).toEqual({ + url: '/api/projects', + init: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: '/tmp/demo' }), + }, + }); + expect(result).toEqual({ + id: 'demo', + name: 'demo', + path: '/tmp/demo', + addedAt: '2026-06-19T05:00:00.000Z', + lastOpenedAt: '2026-06-19T05:00:00.000Z', + }); + }); +}); diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 0d2dc5129..ac527a6ed 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -29,8 +29,11 @@ import type { FeedbackData, FileContentResponse, FileTreeResponse, + FilesystemBrowseResponse, + FilesystemBrowseResponseWire, IndexResponse, ProjectEntry, + ProjectEntryWire, ProjectListResponse, RemoteStatusResponse, RunDetailResponse, @@ -351,6 +354,36 @@ export function useProjectList() { return useQuery(projectListOptions); } +export async function browseFilesystemApi(browsePath?: string): Promise { + const params = new URLSearchParams(); + if (browsePath?.trim()) { + params.set('path', browsePath.trim()); + } + const query = params.toString(); + const res = await fetch(`/api/filesystem/browse${query ? `?${query}` : ''}`); + if (!res.ok) { + const err = (await res.json().catch(() => ({ error: res.statusText }))) as { + error?: string; + }; + throw new Error(err.error ?? `Failed to browse folders: ${res.status}`); + } + const body = (await res.json()) as FilesystemBrowseResponseWire; + return { + path: body.path, + parentPath: body.parent_path, + current: { + name: body.current.name, + path: body.current.path, + hasAgentv: body.current.has_agentv, + }, + entries: body.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + hasAgentv: entry.has_agentv, + })), + }; +} + export const allProjectRunsOptions = queryOptions({ queryKey: ['projects', 'all-runs'], queryFn: () => fetchJson('/api/projects/all-runs'), @@ -371,7 +404,14 @@ export async function addProjectApi(projectPath: string): Promise const err = (await res.json()) as { error: string }; throw new Error(err.error || `Failed to add project: ${res.status}`); } - return res.json() as Promise; + const body = (await res.json()) as ProjectEntryWire; + return { + id: body.id, + name: body.name, + path: body.path, + addedAt: body.added_at, + lastOpenedAt: body.last_opened_at, + }; } export async function removeProjectApi(projectId: string): Promise { diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index a017a47a9..897198bbd 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -402,6 +402,14 @@ export interface ProjectListResponse { } export interface ProjectEntry { + id: string; + name: string; + path: string; + addedAt: string; + lastOpenedAt: string; +} + +export interface ProjectEntryWire { id: string; name: string; path: string; @@ -409,6 +417,32 @@ export interface ProjectEntry { last_opened_at: string; } +export interface FilesystemBrowseEntryWire { + name: string; + path: string; + has_agentv: boolean; +} + +export interface FilesystemBrowseResponseWire { + path: string; + parent_path?: string; + current: FilesystemBrowseEntryWire; + entries: FilesystemBrowseEntryWire[]; +} + +export interface FilesystemBrowseEntry { + name: string; + path: string; + hasAgentv: boolean; +} + +export interface FilesystemBrowseResponse { + path: string; + parentPath?: string; + current: FilesystemBrowseEntry; + entries: FilesystemBrowseEntry[]; +} + // ── Eval runner types ──────────────────────────────────────────────────── export interface DiscoveredEvalFile { diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index 583ef5942..5619e94c9 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -10,6 +10,7 @@ import { Link, createFileRoute, useNavigate, useRouterState } from '@tanstack/re import { useEffect, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { AddProjectModal } from '~/components/AddProjectModal'; import { AnalyticsTab } from '~/components/AnalyticsTab'; import { ExperimentsTab } from '~/components/ExperimentsTab'; import { ProjectCard } from '~/components/ProjectCard'; @@ -18,7 +19,6 @@ import { RunList } from '~/components/RunList'; import { type RunSourceFilter, RunSourceToolbar } from '~/components/RunSourceToolbar'; import { TargetsTab } from '~/components/TargetsTab'; import { - addProjectApi, remoteStatusOptions, syncRemoteResultsApi, useCompare, @@ -123,28 +123,13 @@ function ProjectsDashboard() { const { data } = useProjectList(); const { data: config } = useStudioConfig(); const queryClient = useQueryClient(); - const [addPath, setAddPath] = useState(''); - const [error, setError] = useState(null); - const [showAddForm, setShowAddForm] = useState(false); + const [showAddProject, setShowAddProject] = useState(false); const [showRunEval, setShowRunEval] = useState(false); + const [addProjectMessage, setAddProjectMessage] = useState(null); const projects = data?.projects ?? []; const isReadOnly = config?.read_only === true; - async function handleAddProject(e: React.FormEvent) { - e.preventDefault(); - if (!addPath.trim()) return; - setError(null); - try { - await addProjectApi(addPath.trim()); - setAddPath(''); - setShowAddForm(false); - queryClient.invalidateQueries({ queryKey: ['projects'] }); - } catch (err) { - setError((err as Error).message); - } - } - return (
@@ -161,39 +146,22 @@ function ProjectsDashboard() { )}
- {error && ( -
- {error} -
- )} - - {!isReadOnly && showAddForm && ( -
-
- setAddPath(e.target.value)} - placeholder="Project path (e.g., /home/user/projects/my-evals)" - className="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500 focus:border-cyan-600 focus:outline-none" - /> - -
+ {addProjectMessage && ( +
+ {addProjectMessage}
)} @@ -213,6 +181,16 @@ function ProjectsDashboard() { )} {!isReadOnly && setShowRunEval(false)} />} + {!isReadOnly && ( + setShowAddProject(false)} + onAdded={(project) => { + setAddProjectMessage(`Project registered: ${project.name}`); + void queryClient.invalidateQueries({ queryKey: ['projects'] }); + }} + /> + )}
); } diff --git a/apps/web/src/content/docs/docs/tools/dashboard.mdx b/apps/web/src/content/docs/docs/tools/dashboard.mdx index 94f1f2278..77b5b188f 100644 --- a/apps/web/src/content/docs/docs/tools/dashboard.mdx +++ b/apps/web/src/content/docs/docs/tools/dashboard.mdx @@ -187,6 +187,9 @@ agentv dashboard --add /path/to/my-evals agentv dashboard --add /path/to/other-evals ``` +You can also click **Add Project** on the Projects dashboard, browse or enter a +folder path, and select a directory that contains `.agentv/`. + Each path must contain a `.agentv/` directory. Registered projects are stored under `projects:` in `$AGENTV_HOME/config.yaml`, or `~/.agentv/config.yaml` when `AGENTV_HOME` is unset. To register a remote repo and keep it synced automatically, add `repo_url` and `ref` to the entry in `$AGENTV_HOME/config.yaml`. `repo_url` is the Git remote URL AgentV passes to `git clone`, so it can be HTTPS or SSH: @@ -206,7 +209,7 @@ On each Dashboard startup, AgentV clones the repo if the path is empty (`git clo `$AGENTV_HOME/config.yaml` is the single source of truth for registered projects. Dashboard re-reads it on every `/api/projects` request (which the UI polls every ~10 s), so any of these changes appear live without restarting `agentv serve`: -- Adding via the UI's **Add Project** form or `POST /api/projects`. +- Adding via the UI's **Add Project** folder picker or `POST /api/projects`. - Removing via the UI's **Remove** button or `DELETE /api/projects/:id`. - Editing the `projects:` block in `$AGENTV_HOME/config.yaml` directly. - Mounting the file via a Kubernetes ConfigMap — GitOps the ConfigMap and Dashboard reflects it within the next poll. From 70c9d506011d8f7e8739276bc060e7bec283b630 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Fri, 19 Jun 2026 08:44:28 +0200 Subject: [PATCH 2/2] feat(dashboard): remove project from card menu --- apps/cli/test/commands/results/serve.test.ts | 35 +++ apps/dashboard/src/components/ProjectCard.tsx | 258 +++++++++++++++--- apps/dashboard/src/lib/api.test.ts | 21 +- apps/dashboard/src/lib/api.ts | 5 +- apps/dashboard/src/routes/index.tsx | 33 ++- 5 files changed, 300 insertions(+), 52 deletions(-) diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index cfd1c78b0..2c672016f 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -592,6 +592,41 @@ describe('serve app', () => { }); }); + // ── DELETE /api/projects/:projectId ─────────────────────────────────── + + describe('DELETE /api/projects/:projectId', () => { + it('unregisters a project without deleting its directory', async () => { + const previousHome = process.env.AGENTV_HOME; + process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-remove'); + + try { + const projectDir = path.join(tempDir, 'project-to-remove'); + mkdirSync(path.join(projectDir, '.agentv'), { recursive: true }); + const entry = addProject(projectDir); + + const app = makeApp(); + const remove = await app.request(`/api/projects/${encodeURIComponent(entry.id)}`, { + method: 'DELETE', + }); + + expect(remove.status).toBe(200); + expect(await remove.json()).toEqual({ ok: true }); + expect(existsSync(projectDir)).toBe(true); + expect(existsSync(path.join(projectDir, '.agentv'))).toBe(true); + + const list = await app.request('/api/projects'); + const data = (await list.json()) as { projects: Array<{ id: string }> }; + expect(data.projects).toEqual([]); + } finally { + if (previousHome === undefined) { + process.env.AGENTV_HOME = undefined; + } else { + process.env.AGENTV_HOME = previousHome; + } + } + }); + }); + // ── GET /api/feedback ────────────────────────────────────────────────── describe('GET /api/feedback', () => { diff --git a/apps/dashboard/src/components/ProjectCard.tsx b/apps/dashboard/src/components/ProjectCard.tsx index 553e9caaa..05e5e102f 100644 --- a/apps/dashboard/src/components/ProjectCard.tsx +++ b/apps/dashboard/src/components/ProjectCard.tsx @@ -6,6 +6,7 @@ */ import { Link } from '@tanstack/react-router'; +import { useEffect, useId, useRef, useState } from 'react'; import { executionErrorCount } from '~/lib/result-summary'; import type { ProjectSummary } from '~/lib/types'; @@ -24,57 +25,228 @@ function formatTimeAgo(timestamp: string | null): string { return `${days}d ago`; } -export function ProjectCard({ project }: { project: ProjectSummary }) { +interface ProjectCardProps { + project: ProjectSummary; + canRemove?: boolean; + onRemove?: (project: ProjectSummary) => Promise; +} + +export function ProjectCard({ project, canRemove = false, onRemove }: ProjectCardProps) { const passPercent = Math.round(project.pass_rate * 100); const errors = executionErrorCount(project); + const [menuOpen, setMenuOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [removing, setRemoving] = useState(false); + const [removeError, setRemoveError] = useState(null); + const menuId = useId(); + const confirmTitleId = useId(); + const menuButtonRef = useRef(null); + const menuRef = useRef(null); + const dialogRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + + function handlePointerDown(event: PointerEvent) { + const target = event.target as Node; + if (menuButtonRef.current?.contains(target) || menuRef.current?.contains(target)) { + return; + } + setMenuOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setMenuOpen(false); + menuButtonRef.current?.focus(); + } + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [menuOpen]); + + useEffect(() => { + if (confirmOpen) { + dialogRef.current?.focus(); + } + }, [confirmOpen]); + + function openRemoveConfirmation() { + setMenuOpen(false); + setRemoveError(null); + setConfirmOpen(true); + } + + async function confirmRemoveProject() { + if (!onRemove || removing) return; + setRemoving(true); + setRemoveError(null); + try { + await onRemove(project); + } catch (err) { + setRemoveError((err as Error).message); + setRemoving(false); + } + } + + function closeConfirmation() { + if (removing) return; + setConfirmOpen(false); + setRemoveError(null); + menuButtonRef.current?.focus(); + } return ( - -
-
-

- {project.name} -

-

{project.path}

+
+ +
+
+

+ {project.name} +

+

{project.path}

+
-
-
-
-

Runs

-

{project.run_count}

+
+
+

Runs

+

{project.run_count}

+
+
+

Pass Rate

+

= 80 + ? 'text-emerald-400' + : passPercent >= 50 + ? 'text-yellow-400' + : 'text-red-400' + }`} + > + {project.run_count > 0 ? `${passPercent}%` : '--'} +

+
+
+

Errors

+

0 ? 'text-amber-300' : 'text-gray-500'}`} + > + {errors} +

+
+
+

Last Run

+

{formatTimeAgo(project.last_run)}

+
-
-

Pass Rate

-

= 80 - ? 'text-emerald-400' - : passPercent >= 50 - ? 'text-yellow-400' - : 'text-red-400' - }`} + + + {canRemove && onRemove ? ( +

+ + {menuOpen ? ( + + ) : null}
-
-

Errors

-

0 ? 'text-amber-300' : 'text-gray-500'}`}> - {errors} -

-
-
-

Last Run

-

{formatTimeAgo(project.last_run)}

-
-
- + ) : null} + + {confirmOpen ? ( + { + if (event.key === 'Escape') { + event.preventDefault(); + closeConfirmation(); + } + }} + className="fixed inset-0 z-50 flex h-full max-h-none w-full max-w-none items-center justify-center bg-black/70 p-4 text-left text-gray-100 backdrop:bg-black/70" + > +
+
+

+ Remove project? +

+
+
+

+ Remove {project.name} from this + Dashboard registry. +

+

+ The project folder and files stay on disk at {project.path}. +

+ {removeError ? ( +
+ {removeError} +
+ ) : null} +
+
+ + +
+
+
+ ) : null} + ); } diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts index 81b8b9996..03ccacd08 100644 --- a/apps/dashboard/src/lib/api.test.ts +++ b/apps/dashboard/src/lib/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { addProjectApi, browseFilesystemApi } from './api'; +import { addProjectApi, browseFilesystemApi, removeProjectApi } from './api'; const originalFetch = globalThis.fetch; @@ -69,4 +69,23 @@ describe('dashboard API boundary mapping', () => { lastOpenedAt: '2026-06-19T05:00:00.000Z', }); }); + + it('calls the project removal endpoint', async () => { + const calls = stubJsonResponse({ ok: true }); + + await removeProjectApi('demo project'); + + expect(calls[0]).toEqual({ + url: '/api/projects/demo%20project', + init: { + method: 'DELETE', + }, + }); + }); + + it('surfaces project removal API errors', async () => { + stubJsonResponse({ error: 'Project not found' }, 404); + + await expect(removeProjectApi('missing')).rejects.toThrow('Project not found'); + }); }); diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index ac527a6ed..a4fb308a0 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -419,7 +419,10 @@ export async function removeProjectApi(projectId: string): Promise { method: 'DELETE', }); if (!res.ok) { - throw new Error(`Failed to remove project: ${res.status}`); + const err = (await res.json().catch(() => ({ error: res.statusText }))) as { + error?: string; + }; + throw new Error(err.error ?? `Failed to remove project: ${res.status}`); } } diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index 5619e94c9..c2a71aa3f 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -20,6 +20,7 @@ import { type RunSourceFilter, RunSourceToolbar } from '~/components/RunSourceTo import { TargetsTab } from '~/components/TargetsTab'; import { remoteStatusOptions, + removeProjectApi, syncRemoteResultsApi, useCompare, useEvalRuns, @@ -36,7 +37,7 @@ import { } from '~/lib/navigation'; import { buildProjectSyncErrorFeedback, buildProjectSyncFeedback } from '~/lib/project-sync-status'; import { dedupeSyncedRuns } from '~/lib/run-dedupe'; -import type { RunMeta } from '~/lib/types'; +import type { ProjectListResponse, ProjectSummary, RunMeta } from '~/lib/types'; type TabId = StudioTabId; const tabs: { id: TabId; label: string }[] = [ @@ -125,11 +126,24 @@ function ProjectsDashboard() { const queryClient = useQueryClient(); const [showAddProject, setShowAddProject] = useState(false); const [showRunEval, setShowRunEval] = useState(false); - const [addProjectMessage, setAddProjectMessage] = useState(null); + const [projectMessage, setProjectMessage] = useState(null); const projects = data?.projects ?? []; const isReadOnly = config?.read_only === true; + async function handleRemoveProject(project: ProjectSummary) { + await removeProjectApi(project.id); + queryClient.setQueryData(['projects'], (current) => + current + ? { + projects: current.projects.filter((entry) => entry.id !== project.id), + } + : current, + ); + setProjectMessage(`Project removed from Dashboard: ${project.name}. Files were left on disk.`); + void queryClient.invalidateQueries({ queryKey: ['projects'] }); + } + return (
@@ -147,7 +161,7 @@ function ProjectsDashboard() {
- {addProjectMessage && ( + {projectMessage && (
- {addProjectMessage} + {projectMessage}
)} @@ -175,7 +189,12 @@ function ProjectsDashboard() { ) : (
{projects.map((project) => ( - + ))}
)} @@ -186,7 +205,7 @@ function ProjectsDashboard() { open={showAddProject} onClose={() => setShowAddProject(false)} onAdded={(project) => { - setAddProjectMessage(`Project registered: ${project.name}`); + setProjectMessage(`Project registered: ${project.name}`); void queryClient.invalidateQueries({ queryKey: ['projects'] }); }} />