From f348a930c7afdc6d92b8e75a08b7ec999f6e3d29 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 01:05:12 +0300 Subject: [PATCH 1/2] feat(studio): datasets a New dataset form (a 409 shows inline as a taken name); input/expected, links the source trace and deletes an example after a confirm. The trace header gains "Add to dataset", posting { from_trace_id } (creating a new dataset first when named). A shared Evaluations tab bar sits atop Scores, Datasets and the evals placeholder. The observe proxy allowlists POST /datasets, POST /datasets/:name/examples and DELETE /datasets/:name/examples/:id; a DELETE forwards no body and its 204 passes back empty. observeClient gains typed dataset methods and the useDatasets/useDataset hooks. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/a11y.spec.ts | 13 +- apps/agent-forge/e2e/observeStub.ts | 36 ++- .../server/__tests__/observeProxy.test.ts | 57 +++- apps/agent-forge/server/observeProxy.ts | 21 +- .../observe/__tests__/observeClient.test.ts | 32 +++ apps/agent-forge/src/observe/observeClient.ts | 54 +++- apps/agent-forge/src/observe/types.ts | 36 +++ apps/agent-forge/src/observe/useObserve.ts | 14 +- apps/agent-forge/src/screens/Placeholder.tsx | 7 +- .../src/screens/__tests__/routes.test.tsx | 22 +- .../src/screens/datasets/DatasetDetail.tsx | 157 +++++++++++ .../src/screens/datasets/DatasetsScreen.tsx | 147 ++++++++++ .../__tests__/DatasetsScreen.test.tsx | 250 ++++++++++++++++++ .../src/screens/datasets/datasets.css | 100 +++++++ .../src/screens/datasets/datasetsUrl.ts | 33 +++ .../src/screens/evals/EvalsTabs.tsx | 34 +++ apps/agent-forge/src/screens/evals/evals.css | 26 ++ apps/agent-forge/src/screens/routeTable.ts | 7 +- apps/agent-forge/src/screens/routes.tsx | 7 +- .../src/screens/scores/ScoresScreen.tsx | 3 + .../src/screens/traces/AddToDataset.tsx | 131 +++++++++ .../src/screens/traces/TraceHeader.tsx | 7 +- .../traces/__tests__/AddToDataset.test.tsx | 134 ++++++++++ 23 files changed, 1305 insertions(+), 23 deletions(-) create mode 100644 apps/agent-forge/src/screens/datasets/DatasetDetail.tsx create mode 100644 apps/agent-forge/src/screens/datasets/DatasetsScreen.tsx create mode 100644 apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx create mode 100644 apps/agent-forge/src/screens/datasets/datasets.css create mode 100644 apps/agent-forge/src/screens/datasets/datasetsUrl.ts create mode 100644 apps/agent-forge/src/screens/evals/EvalsTabs.tsx create mode 100644 apps/agent-forge/src/screens/evals/evals.css create mode 100644 apps/agent-forge/src/screens/traces/AddToDataset.tsx create mode 100644 apps/agent-forge/src/screens/traces/__tests__/AddToDataset.test.tsx diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index b406ad51d..70cc0a362 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -128,7 +128,7 @@ async function expectNoSeriousAxeViolations(page: Page, label: string) { // Every route in the table (new screens are covered automatically): one
, one h1, // the banner and the primary nav, and an axe scan of the populated (stubbed daemon) state. // Plus the deep-link states that render a different view on the same route. -// Placeholder routes (/evals/:section, /evals/:section/:id) scan the Placeholder today; when their real +// The datasets routes scan the stub's datasets fixtures. Placeholder routes (/evals/:section, /evals/:section/:id) scan the Placeholder today; when their real // screens land, add their endpoints to e2e/observeStub.ts or they get scanned in a 404/empty state. const SCANS = [ ...ROUTES.map(({ pattern, example }) => ({ label: pattern, path: example })), @@ -171,6 +171,17 @@ test('a #/traces/:id deep link with no daemon shows the down state and keeps the expect(new URL(page.url()).hash).toBe('#/traces/e2e-trace?obs=e2e-obs'); }); +test('#/evals/datasets/:name with no daemon shows the down state, keeps the URL and the Evaluations tabs', async ({ page }) => { + await page.route('**/observe/**', (route) => + route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"down"}' }), + ); + await page.goto('/?token=e2e-studio-token#/evals/datasets/golden?offset=50'); + await expect(page.getByRole('main').getByText('lousho observe start')).toBeVisible(); + const tabs = page.getByRole('navigation', { name: 'Evaluations' }); + await expect(tabs.locator('[aria-current="page"]')).toHaveText('Datasets'); + expect(new URL(page.url()).hash).toBe('#/evals/datasets/golden?offset=50'); +}); + test('#/monitoring with no daemon shows the down state and keeps window/agent in the URL', async ({ page }) => { await page.route('**/observe/**', (route) => route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"down"}' }), diff --git a/apps/agent-forge/e2e/observeStub.ts b/apps/agent-forge/e2e/observeStub.ts index 1581b6f39..c7edd7e96 100644 --- a/apps/agent-forge/e2e/observeStub.ts +++ b/apps/agent-forge/e2e/observeStub.ts @@ -1,6 +1,8 @@ import type { Page, Route } from '@playwright/test'; import type { ObserveAgentRow, + ObserveDataset, + ObserveExample, ObserveHubEntry, ObserveInsights, ObservePromptVersion, @@ -14,8 +16,8 @@ import type { * A fake observe daemon behind the studio's `/observe` proxy, so the e2e specs * scan the screens' populated states rather than "daemon down". One agent, one * thread with one trace (an LLM call and a tool call), one prompt with two - * versions, an eval's scores, and a Context Hub namespace with a fleet and a - * private entry. + * versions, an eval's scores, a Context Hub namespace with a fleet and a + * private entry, and a dataset with two examples (one copied from the trace). */ const NOW = Date.now(); const T0 = NOW - 60_000; @@ -174,6 +176,32 @@ const hubEntry = (key: string, value: string, scope: ObserveHubEntry['scope'], t }); const hubEntries = [hubEntry('order-policy', '{"returns_days":30}', 'fleet', null), hubEntry('last-customer', 'cust-42', 'private', 24 * HOUR)]; +const dataset: ObserveDataset = { + dataset_id: 'e2e-dataset-id', + name: 'e2e-dataset', + description: 'Support cases the agent must get right', + created_at: T0 - HOUR, + example_count: 2, +}; +const examples: ObserveExample[] = [ + { + example_id: 'e2e-example-1', + dataset_id: dataset.dataset_id, + input: 'Where is my order?', + expected: 'It ships tomorrow.', + source_trace_id: 'e2e-trace', + created_at: T0, + }, + { + example_id: 'e2e-example-2', + dataset_id: dataset.dataset_id, + input: [{ role: 'user', content: 'Can I return it?' }], + expected: null, + source_trace_id: null, + created_at: T0 + 1_000, + }, +]; + const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); @@ -201,6 +229,10 @@ export async function stubObserve(page: Page): Promise { const scope = q.get('scope'); return json(route, { entries: hubEntries.filter((e) => (!ns || e.namespace === ns) && (!scope || e.scope === scope)) }); } + if (path === '/api/v1/datasets' && route.request().method() === 'GET') + return json(route, { datasets: [dataset], total: 1, limit: Number(q.get('limit') ?? 100), offset: 0 }); + if (path === '/api/v1/datasets/e2e-dataset') + return json(route, { dataset, examples, total: examples.length, limit: Number(q.get('limit') ?? 1000), offset: 0 }); if (path === '/api/v1/scores') { if (q.get('aggregate') === 'eval') return json(route, { evals: [{ name: 'e2e-eval', count: 4, passed: 3, pass_rate: 0.75, avg_value: 0.75, last_ts: T0 + 2_000 }] }); diff --git a/apps/agent-forge/server/__tests__/observeProxy.test.ts b/apps/agent-forge/server/__tests__/observeProxy.test.ts index 273011a41..7d20c360f 100644 --- a/apps/agent-forge/server/__tests__/observeProxy.test.ts +++ b/apps/agent-forge/server/__tests__/observeProxy.test.ts @@ -3,7 +3,8 @@ * panels' GETs same-origin, because the daemon itself serves no CORS * headers. A dead daemon answers 502 with the `lousho observe start` hint. * Writes go through only when OBSERVE_WRITE_ALLOWLIST names the method and - * path (feedback scores, a prompt label move, a hub publish); every other non-GET stays 405. + * path (feedback scores, a prompt label move, a hub publish, dataset and + * example writes); every other non-GET stays 405. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; @@ -24,6 +25,7 @@ describe('observe proxy', () => { let app: ReturnType; let daemon: http.Server | undefined; let daemonUrl: string; + let lastDelete: { url: string | undefined; contentType: string | null; body: string } | undefined; const prevEnv = process.env.LOUSHO_OBSERVE_URL; beforeEach(async () => { @@ -43,6 +45,12 @@ describe('observe proxy', () => { let body = ''; req.on('data', (chunk: Buffer) => (body += chunk.toString())); req.on('end', () => { + if (req.method === 'DELETE') { + lastDelete = { url: req.url, contentType: req.headers['content-type'] ?? null, body }; + res.writeHead(204); + res.end(); + return; + } res.writeHead(req.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ @@ -117,6 +125,49 @@ describe('observe proxy', () => { expect(res.body.body).toEqual({ version: 2 }); }); + it('forwards POST /api/v1/datasets (a new dataset) with its JSON body', async () => { + const res = await request(app).post('/observe/api/v1/datasets').send({ name: 'golden', description: 'd' }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ + echoed: '/api/v1/datasets', + method: 'POST', + contentType: 'application/json', + body: { name: 'golden', description: 'd' }, + }); + }); + + it('forwards POST /api/v1/datasets/:name/examples (add from a trace) with its JSON body', async () => { + const res = await request(app).post('/observe/api/v1/datasets/golden/examples').send({ from_trace_id: 't1' }); + expect(res.status).toBe(201); + expect(res.body.echoed).toBe('/api/v1/datasets/golden/examples'); + expect(res.body.body).toEqual({ from_trace_id: 't1' }); + }); + + it('forwards DELETE /api/v1/datasets/:name/examples/:id with no body and passes the 204 back', async () => { + lastDelete = undefined; + const res = await request(app).delete('/observe/api/v1/datasets/golden/examples/e1'); + expect(res.status).toBe(204); + expect(res.text).toBe(''); + expect(lastDelete).toEqual({ url: '/api/v1/datasets/golden/examples/e1', contentType: null, body: '' }); + }); + + it.each([ + ['delete', '/observe/api/v1/datasets/golden'], + ['delete', '/observe/api/v1/datasets/golden/examples'], + ['put', '/observe/api/v1/datasets/golden/examples/e1'], + ['post', '/observe/api/v1/datasets/golden/examples/e1'], + ['post', '/observe/api/v1/datasets/golden'], + ['delete', '/observe/api/v1/traces/t1'], + ] as const)('keeps the dataset-adjacent write %s %s at 405', async (method, url) => { + const res = await request(app)[method](url).send({}); + expect(res.status).toBe(405); + }); + + it('keeps a DELETE whose example segment is a dot or an encoded slash at 405', async () => { + expect((await request(app).delete('/observe/api/v1/datasets/golden/examples/%2e%2e')).status).toBe(405); + expect((await request(app).delete('/observe/api/v1/datasets/a%2Fb/examples/e1')).status).toBe(405); + }); + it.each([ ['post', '/observe/api/v1/prompts'], ['put', '/observe/api/v1/prompts/mira'], @@ -150,8 +201,8 @@ describe('observe proxy', () => { expect(res.status).toBe(413); }); - it('exports one write allowlist of method + path pairs: feedback scores, the label move and hub publishes', () => { - expect(OBSERVE_WRITE_ALLOWLIST.map((entry) => entry.method)).toEqual(['POST', 'PUT', 'POST']); + it('exports one write allowlist of method + path pairs: feedback scores, the label move, hub publishes and dataset writes', () => { + expect(OBSERVE_WRITE_ALLOWLIST.map((entry) => entry.method)).toEqual(['POST', 'PUT', 'POST', 'POST', 'POST', 'DELETE']); expect(OBSERVE_WRITE_ALLOWLIST.every((entry) => entry.path instanceof RegExp)).toBe(true); }); }); diff --git a/apps/agent-forge/server/observeProxy.ts b/apps/agent-forge/server/observeProxy.ts index d6da6a111..4fd0cded2 100644 --- a/apps/agent-forge/server/observeProxy.ts +++ b/apps/agent-forge/server/observeProxy.ts @@ -11,8 +11,9 @@ * GET is forwarded for every path. Writes are forwarded only when * {@link OBSERVE_WRITE_ALLOWLIST} names the method and path - a short list of * Studio actions (human feedback scores, moving a prompt label, editing a - * Context Hub fleet entry) - with the JSON body app.ts's `express.json` - * already parsed, re-serialised and capped at 1 MiB. Every other non-GET - the ingest endpoint above all - answers 405 + * Context Hub fleet entry, dataset and example writes) - with the JSON body + * app.ts's `express.json` already parsed, re-serialised and capped at 1 MiB + * (a DELETE forwards no body, and its 204 passes back empty). Every other non-GET - the ingest endpoint above all - answers 405 * and stays reporter-only. `/observe` sits in app.ts's API_PREFIXES, so it * carries the same per-launch token requirement as the rest of the studio API. */ @@ -25,7 +26,7 @@ const MAX_WRITE_BODY_BYTES = 1024 * 1024; /** A daemon write the proxy forwards: the method plus a regex over the path after `/observe` (no query). */ export interface ObserveWrite { - method: 'POST' | 'PUT'; + method: 'POST' | 'PUT' | 'DELETE'; path: RegExp; } @@ -34,6 +35,9 @@ export const OBSERVE_WRITE_ALLOWLIST: readonly ObserveWrite[] = [ { method: 'POST', path: /^\/api\/v1\/scores$/ }, { method: 'PUT', path: /^\/api\/v1\/prompts\/[^/]+\/labels\/[^/]+$/ }, { method: 'POST', path: /^\/api\/v1\/hub$/ }, + { method: 'POST', path: /^\/api\/v1\/datasets$/ }, + { method: 'POST', path: /^\/api\/v1\/datasets\/[^/]+\/examples$/ }, + { method: 'DELETE', path: /^\/api\/v1\/datasets\/[^/]+\/examples\/[^/]+$/ }, ]; /** @@ -73,8 +77,9 @@ export function registerObserveProxy(app: Express): void { res.status(405).json({ error: 'The observe proxy only forwards GET reads and allowlisted Studio writes.' }); return; } - const body = isWrite ? writeBody(req) : undefined; - if (isWrite && body === undefined) { + const hasBody = isWrite && req.method !== 'DELETE'; + const body = hasBody ? writeBody(req) : undefined; + if (hasBody && body === undefined) { res.status(413).json({ error: 'Observe write bodies are capped at 1 MiB.' }); return; } @@ -88,7 +93,7 @@ export function registerObserveProxy(app: Express): void { try { response = await fetch(upstream, { method: req.method, - headers: isWrite ? { accept: 'application/json', 'content-type': 'application/json' } : { accept: 'application/json' }, + headers: hasBody ? { accept: 'application/json', 'content-type': 'application/json' } : { accept: 'application/json' }, body, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -99,6 +104,10 @@ export function registerObserveProxy(app: Express): void { return; } res.status(response.status); + if (response.status === 204) { + res.end(); + return; + } const contentType = response.headers.get('content-type'); if (contentType) res.setHeader('content-type', contentType); res.send(Buffer.from(await response.arrayBuffer())); diff --git a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts index 213aed661..cd7991c9d 100644 --- a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts +++ b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts @@ -193,4 +193,36 @@ describe('ObserveClient', () => { expect(init.method).toBe('POST'); expect(JSON.parse(String(init.body))).toEqual(body); }); + it('reads datasets: the paged list, one dataset with its examples page, undefined on 404', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('/datasets/nope') + ? jsonResponse(404, { error: "No dataset 'nope'.", code: 'LOUSHO_REQUEST_INVALID' }) + : jsonResponse(200, { datasets: [], total: 0, limit: 50, offset: 0 }) + ); + const client = clientWith(fetchFn as unknown as typeof fetch); + await client.listDatasets({ limit: 50, offset: 100 }); + expect(fetchFn).toHaveBeenLastCalledWith('/observe/api/v1/datasets?limit=50&offset=100', expect.anything()); + await client.dataset('golden.v1', { limit: 50, offset: 0 }); + expect(fetchFn).toHaveBeenLastCalledWith('/observe/api/v1/datasets/golden.v1?limit=50&offset=0', expect.anything()); + expect(await client.dataset('nope')).toBeUndefined(); + }); + + it('writes datasets: create and add-example POST JSON, delete-example sends a bodiless DELETE and takes the 204', async () => { + const fetchFn = vi.fn(async (_url: string, init?: RequestInit) => + init?.method === 'DELETE' ? new Response(null, { status: 204 }) : jsonResponse(201, { dataset: {}, example: {} }) + ); + const client = clientWith(fetchFn as unknown as typeof fetch); + await client.createDataset({ name: 'golden' }); + expect(fetchFn).toHaveBeenLastCalledWith('/observe/api/v1/datasets', expect.objectContaining({ method: 'POST', body: '{"name":"golden"}' })); + await client.addExample('golden', { from_trace_id: 't1' }); + expect(fetchFn).toHaveBeenLastCalledWith( + '/observe/api/v1/datasets/golden/examples', + expect.objectContaining({ method: 'POST', body: '{"from_trace_id":"t1"}' }) + ); + await expect(client.deleteExample('golden', 'e/1')).resolves.toBeUndefined(); + const [url, init] = fetchFn.mock.calls.at(-1)!; + expect(url).toBe('/observe/api/v1/datasets/golden/examples/e%2F1'); + expect(init?.method).toBe('DELETE'); + expect(init?.body).toBeUndefined(); + }); }); diff --git a/apps/agent-forge/src/observe/observeClient.ts b/apps/agent-forge/src/observe/observeClient.ts index faaa880cf..da447ce37 100644 --- a/apps/agent-forge/src/observe/observeClient.ts +++ b/apps/agent-forge/src/observe/observeClient.ts @@ -13,6 +13,10 @@ import { STUDIO_TOKEN_HEADER, loadStudioToken } from '../runtime/studioToken'; import type { ObserveAgentRow, NewHubEntry, + ObserveDataset, + ObserveDatasetDetail, + ObserveDatasetList, + ObserveExample, ObserveHealth, ObserveHubEntry, ObserveHubScope, @@ -138,6 +142,15 @@ export interface HubEntryFilter { limit?: number; } +/** A page request (`limit`/`offset`) for the dataset list and a dataset's examples. */ +export interface PageQuery { + limit?: number; + offset?: number; +} + +/** The body of {@link ObserveClient.addExample}: a literal example, or one copied from a trace's root input/output. */ +export type NewExample = { input: unknown; expected?: unknown } | { from_trace_id: string }; + export interface InsightsFilter { days?: number; since?: number; @@ -169,7 +182,7 @@ export class ObserveClient { private async request( path: string, - opts: { query?: Record; method?: 'GET' | 'POST' | 'PUT'; body?: unknown } = {} + opts: { query?: Record; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown } = {} ): Promise { const headers = new Headers({ accept: 'application/json' }); // The studio token only travels same-origin (to the studio server's @@ -195,6 +208,8 @@ export class ObserveClient { const body = (await res.json().catch(() => undefined)) as { error?: string } | undefined; throw new ObserveApiError(body?.error || `Request to ${path} failed with ${res.status}`, res.status); } + // 204 No Content (an example delete) has no body to parse. + if (res.status === 204) return undefined as T; return (await res.json()) as T; } @@ -315,6 +330,43 @@ export class ObserveClient { return this.request('/api/v1/scores', { method: 'POST', body: score }); } + /** `GET /api/v1/datasets` - a page of datasets (with example counts), newest first. */ + listDatasets(page: PageQuery = {}): Promise { + return this.request('/api/v1/datasets', { query: { ...page } }); + } + + /** `GET /api/v1/datasets/:name` - the dataset plus a page of its examples, oldest first; `undefined` on the daemon's 404. */ + async dataset(name: string, page: PageQuery = {}): Promise { + try { + return await this.request(`/api/v1/datasets/${encodeURIComponent(name)}`, { query: { ...page } }); + } catch (error) { + if (error instanceof ObserveApiError && error.status === 404) return undefined; + throw error; + } + } + + /** `POST /api/v1/datasets` - a new, empty dataset; a taken name is the daemon's 409 (`ObserveApiError`). */ + async createDataset(input: { name: string; description?: string }): Promise { + const body = await this.request<{ dataset: ObserveDataset }>('/api/v1/datasets', { method: 'POST', body: input }); + return body.dataset; + } + + /** `POST /api/v1/datasets/:name/examples` - one example, literal or `{ from_trace_id }` (the trace root's input/output). */ + async addExample(name: string, example: NewExample): Promise { + const body = await this.request<{ example: ObserveExample }>(`/api/v1/datasets/${encodeURIComponent(name)}/examples`, { + method: 'POST', + body: example, + }); + return body.example; + } + + /** `DELETE /api/v1/datasets/:name/examples/:id` - removes one example (204). */ + async deleteExample(name: string, exampleId: string): Promise { + await this.request(`/api/v1/datasets/${encodeURIComponent(name)}/examples/${encodeURIComponent(exampleId)}`, { + method: 'DELETE', + }); + } + /** `GET /api/v1/prompts/:name/:ref` (ref = `latest`, a version number or a label); `undefined` on the daemon's 404. */ async getPrompt(name: string, ref = 'latest'): Promise { try { diff --git a/apps/agent-forge/src/observe/types.ts b/apps/agent-forge/src/observe/types.ts index 01c5856f9..315bd3083 100644 --- a/apps/agent-forge/src/observe/types.ts +++ b/apps/agent-forge/src/observe/types.ts @@ -252,3 +252,39 @@ export interface NewHubEntry { scope?: ObserveHubScope; ttl?: number; } + +/** One row of `GET /api/v1/datasets` (and the `dataset` of the detail/create answers). Datasets are addressed by `name`. */ +export interface ObserveDataset { + dataset_id: string; + name: string; + description: string | null; + created_at: number; + example_count: number; +} + +/** One dataset example: `input`/`expected` are parsed JSON values; `source_trace_id` is set when it was copied from a trace. */ +export interface ObserveExample { + example_id: string; + dataset_id: string; + input: unknown; + expected: unknown; + source_trace_id: string | null; + created_at: number; +} + +/** `GET /api/v1/datasets`: a page of datasets, newest first. */ +export interface ObserveDatasetList { + datasets: ObserveDataset[]; + total: number; + limit: number; + offset: number; +} + +/** `GET /api/v1/datasets/:name`: the dataset and a page of its examples, oldest first. */ +export interface ObserveDatasetDetail { + dataset: ObserveDataset; + examples: ObserveExample[]; + total: number; + limit: number; + offset: number; +} diff --git a/apps/agent-forge/src/observe/useObserve.ts b/apps/agent-forge/src/observe/useObserve.ts index 4248a14a0..973603998 100644 --- a/apps/agent-forge/src/observe/useObserve.ts +++ b/apps/agent-forge/src/observe/useObserve.ts @@ -6,9 +6,11 @@ * duplicating effect logic. */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { observeClient, ObserveDaemonDownError, type HubEntryFilter, type InsightsFilter, type ScoreListFilter, type SessionListFilter, type TraceListFilter } from './observeClient'; +import { observeClient, ObserveDaemonDownError, type HubEntryFilter, type InsightsFilter, type PageQuery, type ScoreListFilter, type SessionListFilter, type TraceListFilter } from './observeClient'; import type { ObserveAgentRow, + ObserveDatasetDetail, + ObserveDatasetList, ObserveHealth, ObserveHubEntry, ObserveInsights, @@ -181,3 +183,13 @@ export function useHubEntries(filter: HubEntryFilter | undefined, refreshKey?: u [JSON.stringify(filter), refreshKey] ); } + +/** `GET /api/v1/datasets` - one page of datasets. */ +export function useDatasets(page: PageQuery, refreshKey?: unknown): ObserveQuery { + return useObserveQuery(() => observeClient.listDatasets(page), [JSON.stringify(page), refreshKey]); +} + +/** `GET /api/v1/datasets/:name` - the dataset and one page of its examples; `undefined` data when it does not exist. */ +export function useDataset(name: string, page: PageQuery, refreshKey?: unknown): ObserveQuery { + return useObserveQuery(() => observeClient.dataset(name, page), [name, JSON.stringify(page), refreshKey]); +} diff --git a/apps/agent-forge/src/screens/Placeholder.tsx b/apps/agent-forge/src/screens/Placeholder.tsx index 4f1ddf224..abed0cd1b 100644 --- a/apps/agent-forge/src/screens/Placeholder.tsx +++ b/apps/agent-forge/src/screens/Placeholder.tsx @@ -1,7 +1,10 @@ -/** Stand-in for a screen that has a nav item but no content yet: what it is, what's coming, and a way back. */ -export function Placeholder({ title, blurb }: { title: string; blurb: string }) { +import type { ReactNode } from 'react'; + +/** Stand-in for a screen that has a nav item but no content yet: what it is, what's coming, and a way back; `nav` is an optional sub-navigation above it. */ +export function Placeholder({ title, blurb, nav }: { title: string; blurb: string; nav?: ReactNode }) { return (
+ {nav}

{title}

{blurb}

Back to Agents diff --git a/apps/agent-forge/src/screens/__tests__/routes.test.tsx b/apps/agent-forge/src/screens/__tests__/routes.test.tsx index e93da83cf..49699dd6a 100644 --- a/apps/agent-forge/src/screens/__tests__/routes.test.tsx +++ b/apps/agent-forge/src/screens/__tests__/routes.test.tsx @@ -22,6 +22,7 @@ vi.mock('../../state/AppState', () => ({ useAppState: () => appState })); import { screenFor, useScreen } from '../routes'; import { ROUTES } from '../routeTable'; import { Placeholder } from '../Placeholder'; +import { EvalsTabs } from '../evals/EvalsTabs'; import { Topbar } from '../../components/Topbar'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -63,7 +64,10 @@ describe('screenFor', () => { expect(screenFor('#/traces/abc?obs=1')).toBe('trace'); expect(screenFor('#/monitoring?window=24h')).toBe('monitoring'); expect(screenFor('#/evals/scores')).toBe('scores'); - expect(screenFor('#/evals/datasets')).toBe('evals'); + expect(screenFor('#/evals/datasets')).toBe('datasets'); + expect(screenFor('#/evals/datasets/golden.v1?offset=50')).toBe('datasets'); + expect(screenFor('#/evals/evaluators')).toBe('evals'); + expect(screenFor('#/evals/queues/q1')).toBe('evals'); expect(screenFor('#/playground')).toBe('playground'); expect(screenFor('#/threads/s1')).toBe('threads'); expect(screenFor('#/prompts/greeting@v2')).toBe('prompts'); @@ -174,3 +178,19 @@ describe('Placeholder', () => { expect(container.querySelector('a[href="#/agents"]')).not.toBeNull(); }); }); + +describe('EvalsTabs', () => { + it('links every Evaluations screen and marks the current one', () => { + window.history.replaceState(null, '', '/?token=secret#/evals/datasets/golden'); + render(); + const nav = container.querySelector('nav[aria-label="Evaluations"]')!; + const links = [...nav.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]); + expect(links).toEqual([ + ['Scores', '#/evals/scores'], + ['Datasets', '#/evals/datasets'], + ['Evaluators', '#/evals/evaluators'], + ['Annotation queues', '#/evals/queues'], + ]); + expect([...nav.querySelectorAll('[aria-current="page"]')].map((a) => a.textContent)).toEqual(['Datasets']); + }); +}); diff --git a/apps/agent-forge/src/screens/datasets/DatasetDetail.tsx b/apps/agent-forge/src/screens/datasets/DatasetDetail.tsx new file mode 100644 index 000000000..1b3868737 --- /dev/null +++ b/apps/agent-forge/src/screens/datasets/DatasetDetail.tsx @@ -0,0 +1,157 @@ +/** + * `#/evals/datasets/:name[?offset=]` - one dataset's examples, oldest first, + * paged server-side (`GET /api/v1/datasets/:name?limit=&offset=`). Opening a + * row shows its input and expected value in full; Delete asks first, then + * `DELETE /api/v1/datasets/:name/examples/:id` and a refetch. + */ +import { useEffect, useState } from 'react'; +import { navigate } from '../../router/router'; +import { useRoute } from '../../router/useRoute'; +import { useDaemonDown, useDataset } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; +import { observeClient } from '../../observe/observeClient'; +import type { ObserveExample } from '../../observe/types'; +import { fmtDateTime } from '../../observe/format'; +import { DataTable, type Column } from '../../ui/DataTable'; +import { EmptyState } from '../../ui/EmptyState'; +import { MessageView } from '../../ui/MessageView'; +import { useToast } from '../../ui/Toast'; +import { Clip } from '../listFilters'; +import { EXAMPLES_PAGE_SIZE, datasetPath, parseOffset, valuePreview } from './datasetsUrl'; + +const BackLink = () => Back to datasets; + +const SourceTrace = ({ traceId }: { traceId: string | null }) => + traceId ? ( + + {traceId.slice(0, 12)} + + ) : ( + <>- + ); + +function columns(onDelete: (example: ObserveExample) => void, busy: boolean): Column[] { + return [ + { key: 'input', header: 'Input', width: 300, render: (e) => }, + { key: 'expected', header: 'Expected', width: 240, render: (e) => }, + { key: 'source', header: 'Source trace', width: 140, hideBelow: 720, render: (e) => }, + { key: 'created', header: 'Created', width: 150, hideBelow: 900, render: (e) => fmtDateTime(e.created_at) }, + { + key: 'actions', + header: Actions, + label: 'Actions', + width: 90, + render: (e) => ( + + ), + }, + ]; +} + +/** The opened example in full: input and expected through MessageView (chat messages render as such). */ +function ExamplePanel({ example }: { example: ObserveExample }) { + return ( +
+ + {example.expected === null ? ( +

No expected value.

+ ) : ( + + )} +
+ ); +} + +/** Delete one example after a confirm; the toast reports the outcome. */ +function useDeleteExample(name: string, onDeleted: () => void) { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const remove = async (example: ObserveExample) => { + if (!window.confirm(`Delete this example from ${name}? This cannot be undone.`)) return; + setBusy(true); + try { + await observeClient.deleteExample(name, example.example_id); + toast.show({ tone: 'success', message: 'Example deleted.' }); + onDeleted(); + } catch (error) { + toast.show({ tone: 'error', message: `Could not delete the example: ${(error as Error).message}` }); + } finally { + setBusy(false); + } + }; + return { busy, remove }; +} + +/** `#/evals/datasets/:name`; an unknown dataset shows an empty state with the way back. */ +export function DatasetDetail({ name }: { name: string }) { + const route = useRoute(); + const offset = parseOffset(route.query); + const [refreshKey, setRefreshKey] = useState(0); + const [openId, setOpenId] = useState(); + const query = useDataset(name, { limit: EXAMPLES_PAGE_SIZE, offset }, refreshKey); + const { busy, remove } = useDeleteExample(name, () => setRefreshKey((k) => k + 1)); + const down = useDaemonDown(false, [query]); + const examples = query.data?.examples ?? []; + const total = query.data?.total ?? 0; + + // Deleting the last example of a later page leaves it empty: step back a page. + useEffect(() => { + if (query.data && examples.length === 0 && offset > 0 && offset >= total) { + navigate(datasetPath(name, Math.max(0, offset - EXAMPLES_PAGE_SIZE)), { replace: true }); + } + }, [query.data, examples.length, offset, total, name]); + + if (down) return ; + const opened = examples.find((e) => e.example_id === openId); + let body; + if (query.loading && !query.data) body =

Loading dataset...

; + else if (query.error) body = ; + else if (!query.data) body = } />; + else + body = ( + <> + {query.data.dataset.description &&

{query.data.dataset.description}

} + void remove(e), busy)} + rows={examples} + rowKey={(e) => e.example_id} + selectedKey={opened?.example_id} + onRowOpen={(e) => setOpenId(e.example_id === openId ? undefined : e.example_id)} + page={{ + offset, + limit: EXAMPLES_PAGE_SIZE, + total, + onChange: (next) => navigate(datasetPath(name, next), { replace: true }), + }} + empty={ + + } + /> + {opened && } + + ); + + return ( + <> + +

+ Dataset {name} +

+ {body} + + ); +} diff --git a/apps/agent-forge/src/screens/datasets/DatasetsScreen.tsx b/apps/agent-forge/src/screens/datasets/DatasetsScreen.tsx new file mode 100644 index 000000000..321c928c7 --- /dev/null +++ b/apps/agent-forge/src/screens/datasets/DatasetsScreen.tsx @@ -0,0 +1,147 @@ +/** + * `#/evals/datasets` - the dataset list (`GET /api/v1/datasets`, paged + * server-side, `?offset=` in the URL) with a "New dataset" form - and + * `#/evals/datasets/:name`, one dataset's examples (see `DatasetDetail.tsx`). + * Both sit under the Evaluations tabs and behind the daemon-down state, which + * keeps the URL and swaps the screen back in by itself once the daemon is back. + */ +import './datasets.css'; +import { useState, type FormEvent } from 'react'; +import { matchRoute, navigate } from '../../router/router'; +import { useRoute } from '../../router/useRoute'; +import { useDaemonDown, useDaemonHealth, useDatasets } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; +import { ObserveApiError, observeClient } from '../../observe/observeClient'; +import type { ObserveDataset } from '../../observe/types'; +import { fmtDateTime, fmtInt } from '../../observe/format'; +import { DataTable, type Column } from '../../ui/DataTable'; +import { EmptyState } from '../../ui/EmptyState'; +import { useToast } from '../../ui/Toast'; +import { Clip } from '../listFilters'; +import { EvalsTabs } from '../evals/EvalsTabs'; +import { DatasetDetail } from './DatasetDetail'; +import { DATASETS_PAGE_SIZE, datasetPath, datasetsPath, parseOffset } from './datasetsUrl'; + +const COLUMNS: Column[] = [ + { key: 'name', header: 'Name', width: 220, render: (d) => }, + { key: 'description', header: 'Description', width: 320, render: (d) => }, + { key: 'examples', header: 'Examples', align: 'right', width: 100, render: (d) => fmtInt(d.example_count) }, + { key: 'created', header: 'Created', width: 150, hideBelow: 720, render: (d) => fmtDateTime(d.created_at) }, +]; + +/** The create failure shown inline: a 409 is a taken name, anything else its message. */ +const createError = (error: unknown): string => + error instanceof ObserveApiError && error.status === 409 + ? 'Could not create: a dataset with that name exists.' + : `Could not create the dataset: ${(error as Error).message}`; + +/** "New dataset": a name and an optional description; success refreshes the list. */ +function NewDatasetForm({ onCreated }: { onCreated: () => void }) { + const toast = useToast(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + const target = name.trim(); + if (!target) return; + setSaving(true); + setError(undefined); + try { + const text = description.trim(); + await observeClient.createDataset(text ? { name: target, description: text } : { name: target }); + toast.show({ tone: 'success', message: `Created dataset ${target}.` }); + setName(''); + setDescription(''); + onCreated(); + } catch (failure) { + setError(createError(failure)); + } finally { + setSaving(false); + } + }; + + return ( +
void submit(e)}> +

New dataset

+
+ + + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +function DatasetList() { + const route = useRoute(); + const offset = parseOffset(route.query); + const [refreshKey, setRefreshKey] = useState(0); + const list = useDatasets({ limit: DATASETS_PAGE_SIZE, offset }, refreshKey); + if (useDaemonDown(false, [list])) return ; + return ( + <> +

Datasets

+ setRefreshKey((k) => k + 1)} /> + + d.name} + onRowOpen={(d) => navigate(datasetPath(d.name))} + page={{ + offset, + limit: DATASETS_PAGE_SIZE, + total: list.data?.total ?? 0, + onChange: (next) => navigate(datasetsPath(next), { replace: true }), + }} + loading={list.loading && !list.data} + empty={ + + } + /> + + ); +} + +/** `#/evals/datasets` and `#/evals/datasets/:name`, under the Evaluations tabs and behind the daemon-down state. */ +export function DatasetsScreen() { + const { path } = useRoute(); + const health = useDaemonHealth(); + const detail = matchRoute(`#${path}`, ['/evals/datasets/:name']); + let body; + if (health.down) body = ; + else if (detail) body = ; + else body = ; + return ( +
+ + {body} +
+ ); +} diff --git a/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx b/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx new file mode 100644 index 000000000..3d8a67d40 --- /dev/null +++ b/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx @@ -0,0 +1,250 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DatasetsScreen } from '../DatasetsScreen'; +import { datasetPath } from '../datasetsUrl'; +import { render } from '../../../ui/__tests__/render'; +import { ToastProvider } from '../../../ui/Toast'; +import type { ObserveDataset, ObserveExample } from '../../../observe/types'; + +const T0 = new Date(2026, 9, 20, 9, 0, 0).getTime(); + +const GOLDEN: ObserveDataset = { + dataset_id: 'd1', + name: 'golden', + description: 'Hand-picked support cases', + created_at: T0, + example_count: 120, +}; + +const example = (i: number): ObserveExample => ({ + example_id: `e${i}`, + dataset_id: 'd1', + input: { question: `q${i}` }, + expected: i % 2 === 0 ? { answer: `a${i}` } : null, + source_trace_id: i === 1 ? 'trace-1' : null, + created_at: T0 + i, +}); + +type Call = { url: URL; method: string; body: unknown }; + +let datasets: ObserveDataset[]; +let calls: Call[]; +let down: boolean; + +/** Daemon stub behind `/observe`: `golden` holds 120 examples; POST /datasets 409s on a taken name. */ +function stubDaemon() { + datasets = [GOLDEN]; + calls = []; + down = false; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (down) throw new TypeError('fetch failed'); + const url = new URL(String(input), 'http://x'); + const method = init?.method ?? 'GET'; + calls.push({ url, method, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)) }); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + const p = url.pathname.replace(/^\/observe/, ''); + if (p === '/health') return json({ service: 'lousho-observe', ok: true }); + if (p === '/__target') return json({ url: 'http://127.0.0.1:7777' }); + if (p === '/api/v1/datasets' && method === 'POST') { + const { name } = JSON.parse(String(init!.body)) as { name: string }; + if (datasets.some((d) => d.name === name)) { + return json({ error: `A dataset named '${name}' already exists.`, code: 'LOUSHO_REQUEST_INVALID' }, 409); + } + const created = { ...GOLDEN, dataset_id: 'd2', name, description: null, example_count: 0 }; + datasets = [created, ...datasets]; + return json({ dataset: created }, 201); + } + if (p === '/api/v1/datasets') { + return json({ datasets, total: datasets.length, limit: Number(url.searchParams.get('limit')), offset: 0 }); + } + if (/^\/api\/v1\/datasets\/golden\/examples\/[^/]+$/.test(p) && method === 'DELETE') return new Response(null, { status: 204 }); + if (p === '/api/v1/datasets/golden') { + const offset = Number(url.searchParams.get('offset') ?? 0); + const limit = Number(url.searchParams.get('limit') ?? 1000); + const examples = Array.from({ length: Math.max(0, Math.min(limit, 120 - offset)) }, (_, i) => example(offset + i + 1)); + return json({ dataset: GOLDEN, examples, total: 120, limit, offset }); + } + if (p.startsWith('/api/v1/datasets/')) return json({ error: 'No dataset.', code: 'LOUSHO_REQUEST_INVALID' }, 404); + return json({ error: 'unexpected' }, 500); + }), + ); +} + +async function flush() { + for (let i = 0; i < 4; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function mountAt(hash: string) { + window.history.replaceState(null, '', `/${hash}`); + const container = render( + + + , + ); + await flush(); + return container; +} + +async function goto(hash: string) { + await act(async () => { + window.history.replaceState(null, '', `/${hash}`); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + await flush(); +} + +const buttonNamed = (container: HTMLElement, name: string) => + [...container.querySelectorAll('button')].find((b) => b.textContent?.trim() === name || b.getAttribute('aria-label') === name); + +function type(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +beforeEach(stubDaemon); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.history.replaceState(null, '', '/'); +}); + +describe('datasetPath', () => { + it('encodes the name and leaves the default offset out', () => { + expect(datasetPath('golden.v1')).toBe('/evals/datasets/golden.v1'); + expect(datasetPath('a b', 0)).toBe('/evals/datasets/a%20b'); + expect(datasetPath('golden', 50)).toBe('/evals/datasets/golden?offset=50'); + }); +}); + +describe('DatasetsScreen list', () => { + it('renders one row per dataset with its description, example count and the Evaluations tabs', async () => { + const container = await mountAt('#/evals/datasets'); + const row = container.querySelector('tbody tr')!; + expect(row.textContent).toContain('golden'); + expect(row.textContent).toContain('Hand-picked support cases'); + expect(row.textContent).toContain('120'); + const current = container.querySelector('nav[aria-label="Evaluations"] [aria-current="page"]'); + expect(current?.textContent).toBe('Datasets'); + const list = calls.find((c) => c.url.pathname === '/observe/api/v1/datasets')!; + expect(list.url.searchParams.get('limit')).toBe('50'); + expect(list.url.searchParams.get('offset')).toBe('0'); + }); + + it('shows the empty state when there are no datasets', async () => { + datasets = []; + const container = await mountAt('#/evals/datasets'); + expect(container.textContent).toContain('No datasets yet'); + }); + + it('opens a dataset on row open', async () => { + const container = await mountAt('#/evals/datasets'); + act(() => (container.querySelector('tbody tr') as HTMLElement).click()); + expect(window.location.hash).toBe('#/evals/datasets/golden'); + }); + + it('creates a dataset and shows a 409 inline as a taken name', async () => { + const container = await mountAt('#/evals/datasets'); + const name = container.querySelector('input[name="dataset-name"]') as HTMLInputElement; + type(name, 'golden'); + await act(async () => buttonNamed(container, 'Create dataset')!.click()); + await flush(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('a dataset with that name exists'); + + type(name, 'fresh'); + await act(async () => buttonNamed(container, 'Create dataset')!.click()); + await flush(); + const post = calls.filter((c) => c.method === 'POST').at(-1)!; + expect(post.body).toEqual({ name: 'fresh' }); + expect(container.querySelector('tbody')!.textContent).toContain('fresh'); + }); + + it('shows the daemon-down state with the URL kept and recovers in place', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + down = true; + const container = await mountAt('#/evals/datasets/golden?offset=50'); + expect(container.textContent).toContain('lousho observe start'); + expect(window.location.hash).toBe('#/evals/datasets/golden?offset=50'); + down = false; + await act(async () => { + vi.advanceTimersByTime(5100); + }); + await flush(); + expect(container.textContent).toContain('Dataset'); + expect(container.querySelector('tbody tr')?.textContent).toContain('q51'); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('DatasetsScreen detail', () => { + it('pages the examples server-side and writes the offset to the URL (default unwritten)', async () => { + const container = await mountAt('#/evals/datasets/golden'); + const first = calls.find((c) => c.url.pathname === '/observe/api/v1/datasets/golden')!; + expect(first.url.searchParams.get('limit')).toBe('50'); + expect(first.url.searchParams.get('offset')).toBe('0'); + expect(container.querySelectorAll('tbody tr')).toHaveLength(50); + expect(container.querySelector('a[href="#/traces/trace-1"]')).not.toBeNull(); + + await act(async () => buttonNamed(container, 'Next')!.click()); + expect(window.location.hash).toBe('#/evals/datasets/golden?offset=50'); + await flush(); + expect(calls.at(-1)!.url.searchParams.get('offset')).toBe('50'); + expect(container.querySelector('tbody tr')!.textContent).toContain('q51'); + + await act(async () => buttonNamed(container, 'Previous')!.click()); + expect(window.location.hash).toBe('#/evals/datasets/golden'); + }); + + it('expands a row to show the input and expected values', async () => { + const container = await mountAt('#/evals/datasets/golden'); + act(() => (container.querySelectorAll('tbody tr')[1] as HTMLElement).click()); + const panel = container.querySelector('[aria-label="Example e2"]')!; + expect(panel.textContent).toContain('q2'); + expect(panel.textContent).toContain('a2'); + }); + + it('deletes an example after confirm, then refreshes', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + const container = await mountAt('#/evals/datasets/golden'); + const before = calls.filter((c) => c.url.pathname === '/observe/api/v1/datasets/golden').length; + await act(async () => buttonNamed(container, 'Delete example e1')!.click()); + await flush(); + expect(confirm).toHaveBeenCalledOnce(); + const del = calls.find((c) => c.method === 'DELETE')!; + expect(del.url.pathname).toBe('/observe/api/v1/datasets/golden/examples/e1'); + expect(del.body).toBeUndefined(); + expect(calls.filter((c) => c.url.pathname === '/observe/api/v1/datasets/golden').length).toBe(before + 1); + }); + + it('does not delete when the confirm is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const container = await mountAt('#/evals/datasets/golden'); + await act(async () => buttonNamed(container, 'Delete example e1')!.click()); + await flush(); + expect(calls.some((c) => c.method === 'DELETE')).toBe(false); + }); + + it('shows not found for an unknown dataset, with the way back', async () => { + const container = await mountAt('#/evals/datasets/nope'); + expect(container.textContent).toContain('Dataset not found'); + expect(container.querySelector('a[href="#/evals/datasets"]')).not.toBeNull(); + }); + + it('follows a route change to another page', async () => { + const container = await mountAt('#/evals/datasets/golden'); + await goto('#/evals/datasets/golden?offset=100'); + expect(container.querySelectorAll('tbody tr')).toHaveLength(20); + }); +}); diff --git a/apps/agent-forge/src/screens/datasets/datasets.css b/apps/agent-forge/src/screens/datasets/datasets.css new file mode 100644 index 000000000..104bcd227 --- /dev/null +++ b/apps/agent-forge/src/screens/datasets/datasets.css @@ -0,0 +1,100 @@ +/* Datasets (#/evals/datasets, #/evals/datasets/:name) and the trace header's "Add to dataset". Tokens only. */ +.datasets-screen { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 12px 16px; + color: var(--text); +} +.datasets-title { + margin: 0; + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; +} +.datasets-title code { + font-family: var(--font-mono); + font-size: 13px; +} +.datasets-section-title { + margin: 0; + font-size: 13px; + font-weight: 600; +} +.datasets-note { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} +.datasets-new, +.dataset-example { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +.datasets-new-fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} +.datasets-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-size: 12px; + color: var(--text-muted); +} +.datasets-field-wide { + flex: 1 1 240px; +} +.datasets-field input, +.datasets-field select { + min-width: 0; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; +} +.datasets-form-error { + margin: 0; + font-size: 13px; + color: var(--danger); +} + +/* "Add to dataset" on the trace header: a small non-modal dialog under its button. */ +.add-to-dataset { + position: relative; +} +.add-to-dataset-dialog { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + width: min(320px, calc(100vw - 32px)); + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} +.add-to-dataset-dialog form { + display: flex; + flex-direction: column; + gap: 8px; +} +.add-to-dataset-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/apps/agent-forge/src/screens/datasets/datasetsUrl.ts b/apps/agent-forge/src/screens/datasets/datasetsUrl.ts new file mode 100644 index 000000000..fe82e6130 --- /dev/null +++ b/apps/agent-forge/src/screens/datasets/datasetsUrl.ts @@ -0,0 +1,33 @@ +/** + * The Datasets screen's URL state. + * + * - `#/evals/datasets[?offset=]` - the dataset list. + * - `#/evals/datasets/:name[?offset=]` - one dataset's examples; `:name` is + * the URL-encoded dataset name (the daemon addresses datasets by name). + * + * `offset` pages server-side; the default `0` is never written. + */ +import { toInt } from '../urlState'; + +/** Datasets per list page, requested from the daemon (`limit`). */ +export const DATASETS_PAGE_SIZE = 50; +/** Examples per detail page, requested from the daemon (`limit`). */ +export const EXAMPLES_PAGE_SIZE = 50; + +/** The `?offset=` page start, `0` when missing or malformed. */ +export const parseOffset = (query: URLSearchParams): number => toInt(query.get('offset')) ?? 0; + +const withOffset = (path: string, offset: number | undefined): string => (offset ? `${path}?offset=${offset}` : path); + +/** The hash path of the dataset list at `offset`. */ +export const datasetsPath = (offset?: number): string => withOffset('/evals/datasets', offset); + +/** The hash path of one dataset's examples at `offset`. */ +export const datasetPath = (name: string, offset?: number): string => + withOffset(`/evals/datasets/${encodeURIComponent(name)}`, offset); + +/** A one-line preview of an example's `input`/`expected` JSON value (strings verbatim). */ +export function valuePreview(value: unknown): string | null { + if (value === null || value === undefined) return null; + return typeof value === 'string' ? value : JSON.stringify(value); +} diff --git a/apps/agent-forge/src/screens/evals/EvalsTabs.tsx b/apps/agent-forge/src/screens/evals/EvalsTabs.tsx new file mode 100644 index 000000000..d1285011b --- /dev/null +++ b/apps/agent-forge/src/screens/evals/EvalsTabs.tsx @@ -0,0 +1,34 @@ +/** + * The Evaluations sub-navigation shown atop every `#/evals/*` screen, so + * Scores, Datasets, Evaluators and Annotation queues reach each other. The + * tab owning the current route's second segment gets `aria-current="page"`. + */ +import './evals.css'; +import { useRoute } from '../../router/useRoute'; + +const TABS = [ + { section: 'scores', label: 'Scores' }, + { section: 'datasets', label: 'Datasets' }, + { section: 'evaluators', label: 'Evaluators' }, + { section: 'queues', label: 'Annotation queues' }, +] as const; + +/** `