Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ObservePromptVersion,
ObserveSessionRow,
} from '../src/observe/types';
import { stubObserve } from './observeStub';
import { stubBigHubEntry, stubLongQueueReview, stubObserve } from './observeStub';
import { SEED_ERROR, SEED_NEWEST_FAILURE, seedObserve } from './fixtures/seedObserve';

/**
Expand Down Expand Up @@ -435,6 +435,30 @@ test('prompts: a prompt with labels and a diff', async ({ page }) => {
});

// Review-only shots of the Prompts and Context Hub screens (x- prefix: not in docs/agent-forge.md).
// The sticky side panels when their content is taller than the screen: each fits under the header, scrolling inside.
test('x-sticky-queue: a 6-row rubric card beside a long trace, scrolled down', async ({ page }) => {
await stubLongQueueReview(page);
await open(page, '/evals/queues/e2e-queue');
await expect(page.getByRole('form', { name: 'Scores' }).locator('.qu-score')).toHaveCount(6);
await expect(page.locator('.qu-messages')).toBeVisible();
await page.locator('.screen-main').evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
await expect(page.locator('.qu-submit')).toBeInViewport();
await shoot(page, 'x-sticky-queue');
});

test('x-sticky-hub: a long entry being edited, at 1280x640', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 640 });
await stubBigHubEntry(page);
await open(page, '/hub/e2e-namespace?agent=e2e-agent');
await page.getByRole('main').getByRole('button', { name: /big-entry/ }).click();
const entry = page.getByRole('region', { name: 'Entry big-entry' });
await entry.getByRole('button', { name: 'Edit' }).click();
await page.locator('.screen-main').evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
await entry.getByRole('button', { name: 'Save' }).scrollIntoViewIfNeeded();
await expect(entry.getByRole('button', { name: 'Save' })).toBeInViewport();
await shoot(page, 'x-sticky-hub');
});

test('x-prompts-list: the prompt registry', async ({ page }) => {
await open(page, '/prompts');
await expect(rows(page)).toHaveCount(docPrompts.length);
Expand Down Expand Up @@ -952,6 +976,38 @@ test('agents x: the Nodes palette in the left rail', async ({ page }) => {
await shoot(page, 'x-agents-rail-nodes');
});

test('agents x-rail-status: the unsaved default agent, and an errored agent with an 80-character name', async ({ page }) => {
// No saved agents: the rail shows the unsaved default as its only row.
await page.route(/\/agents(\?|$)/, (route) =>
route.request().method() === 'GET' ? route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) : route.continue(),
);
await open(page, '/agents');
await expect(page.locator('.agent-card.selected')).toBeVisible();
await shoot(page, 'x-agents-rail-unsaved');
await page.unroute(/\/agents(\?|$)/);

const sockets: WebSocketRoute[] = [];
const heardFromServer = new Set<WebSocketRoute>();
await page.routeWebSocket(/\/stream/, (ws) => {
const server = ws.connectToServer();
// Forward the server's frames as usual, noting each socket's on-connect status.
server.onMessage((message) => {
ws.send(message);
if (typeof message === 'string' && message.includes('"type":"status"')) heardFromServer.add(ws);
});
sockets.push(ws);
});
const name = `errored-${Date.now()}-${'x'.repeat(80)}`.slice(0, 80);
await newAgent(page, name, 'support-bot');
const own = () => sockets.filter((s) => s.url().includes(`/agents/${name}/stream`));
await expect.poll(() => own().length).toBeGreaterThan(0);
// The server's own on-connect status must land first, so ours is the last word.
await expect.poll(() => own().every((ws) => heardFromServer.has(ws))).toBe(true);
own().forEach((ws) => ws.send(JSON.stringify({ type: 'status', payload: { agentId: name, status: 'error' } })));
await expect(page.locator('.agent-card.selected .tone-error, .agent-card.selected .pill-error').first()).toBeVisible();
await shoot(page, 'x-agents-rail-error');
});

test('agents x: the builder toolbar at 860 and 375 wide', async ({ page }) => {
await newAgent(page, `toolbar-${Date.now()}`, 'support-bot');
for (const width of [860, 375]) {
Expand Down
34 changes: 34 additions & 0 deletions apps/agent-forge/e2e/observeStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,40 @@ const onlineScores: ObserveScoreRow[] = [
{ ...score, score_id: 'e2e-online-2', name: 'answered', source: 'online', value: 0, comment: null, obs_id: null, created_at: T0 - HOUR },
];

/**
* The overflowing queue review: `e2e-queue` with 6 described rubric rows and its
* trace's root input a 40-message transcript, so the score card is taller than the
* screen. Call after `stubObserve` (a later `page.route` wins).
*/
export async function stubLongQueueReview(page: Page): Promise<void> {
const rubric = Array.from({ length: 6 }, (_, i) => ({
name: `criterion-${i + 1}`,
description: 'Did the reply get this part right, fully and politely, with the facts checked?',
min: 0,
max: 5,
}));
const messages = Array.from({ length: 40 }, (_, i) => ({
role: i % 2 ? 'assistant' : 'user',
content: `Message ${i + 1}: where is order ord_${i}? It was due last week and the tracking page has not moved since.`,
}));
const [root, ...rest] = traceDetail.observations;
const longTrace = { ...traceDetail, observations: [{ ...root, input: JSON.stringify(messages) }, ...rest] };
await page.route('**/observe/api/v1/queues/e2e-queue', (route) => json(route, { queue: { ...queue, rubric } }));
await page.route('**/observe/api/v1/traces/e2e-trace', (route) => json(route, longTrace));
}

/**
* The `e2e-namespace` hub with a `big-entry` whose value is 80 pretty-printed JSON
* fields (its value view and editor both at their caps). Call after `stubObserve`.
*/
export async function stubBigHubEntry(page: Page): Promise<void> {
const value = JSON.stringify(Object.fromEntries(Array.from({ length: 80 }, (_, i) => [`field_${i}`, `value number ${i}`])), null, 2);
const entries = [hubEntries[0], hubEntry('big-entry', value, 'fleet', null)];
await page.route(/\/observe\/api\/v1\/hub(\?|$)/, (route) =>
route.request().method() === 'GET' ? json(route, { entries }) : route.fallback(),
);
}

const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });

Expand Down
56 changes: 55 additions & 1 deletion apps/agent-forge/e2e/studioUx.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, type Page, type WebSocketRoute } from '@playwright/test';
import { stubObserve } from './observeStub';
import { stubBigHubEntry, stubLongQueueReview, stubObserve } from './observeStub';

/**
* Status, copy and naming match the docs - a finished run says
Expand Down Expand Up @@ -232,3 +232,57 @@ test('the docked nav collapses to an icon rail that survives a reload, every lin
await nav.getByRole('button', { name: 'Expand navigation' }).click();
await expect(nav.getByRole('button', { name: 'Collapse navigation' })).toHaveAttribute('aria-expanded', 'true');
});

/** Scroll the screen's scroll container (`.screen-main`) to its end, as a reviewer reading down the page would. */
async function scrollScreenToEnd(page: Page) {
await page.locator('.screen-main').evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
}

/** Whether `inner`'s box lies inside `.screen-main`'s visible box (and so inside the viewport). */
async function insideScreen(page: Page, selector: string) {
const main = (await page.locator('.screen-main').boundingBox())!;
const box = (await page.locator(selector).first().boundingBox())!;
return box.y >= main.y - 0.5 && box.y + box.height <= main.y + main.height + 0.5 && main.y + main.height <= page.viewportSize()!.height;
}

test.describe('sticky side panels at 1280x800', () => {
test.use({ viewport: { width: 1280, height: 800 } });

test('queue review: with 6 rubric rows and a long trace, "Submit & next" stays on screen', async ({ page }) => {
await stubObserve(page);
await stubLongQueueReview(page);
await page.goto(`/?token=${TOKEN}#/evals/queues/e2e-queue`);
const card = page.getByRole('form', { name: 'Scores' });
await expect(card.locator('.qu-score')).toHaveCount(6);
await expect(page.locator('.qu-messages')).toBeVisible();
// The case under test: the card is taller than the room it has, so it scrolls inside.
await scrollScreenToEnd(page);
await expect.poll(() => card.locator('.qu-rubric-body').evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
await expect.poll(() => insideScreen(page, '.qu-review > .qu-rubric-card')).toBe(true);
await expect.poll(() => insideScreen(page, '.qu-submit')).toBe(true);
await expect.poll(() => insideScreen(page, '.qu-skip')).toBe(true);
await expect(card.getByRole('button', { name: /Submit & next/ })).toBeInViewport();
});

test('hub: a long entry being edited stays inside the screen, its Save button reachable (1280x640)', async ({ page }) => {
// At 800 tall an entry (its value capped at 480px, its editor at 16 rows) just fits; 640 makes it overflow.
await page.setViewportSize({ width: 1280, height: 640 });
await stubObserve(page);
await stubBigHubEntry(page);
await page.goto(`/?token=${TOKEN}#/hub/e2e-namespace?agent=e2e-agent`);
await page.getByRole('main').getByRole('button', { name: /big-entry/ }).click();
const entry = page.getByRole('region', { name: 'Entry big-entry' });
await entry.getByRole('button', { name: 'Edit' }).click();
const save = entry.getByRole('button', { name: 'Save' });
await expect(save).toBeAttached();
// The case under test: the entry's content is taller than the room below its sticky top.
const room = await page.locator('.screen-main').evaluate((el) => el.clientHeight - 32);
expect(await entry.evaluate((el) => el.scrollHeight)).toBeGreaterThan(room);
await scrollScreenToEnd(page);
await expect.poll(() => insideScreen(page, '.hub-split > .hub-entry')).toBe(true);
// Save is reached by scrolling the entry itself; the entry stays put.
await save.scrollIntoViewIfNeeded();
await expect(save).toBeInViewport();
await expect.poll(() => insideScreen(page, '.hub-split > .hub-entry')).toBe(true);
});
});
57 changes: 57 additions & 0 deletions apps/agent-forge/src/__tests__/polishCss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

/** A stylesheet under src/, as text without its comments. */
const sheet = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');

/** The declarations of every rule whose selector list includes `selector` (exactly), joined. */
function rulesFor(css: string, selector: string): string {
const out: string[] = [];
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectors = m[1].split(',').map((s) => s.trim());
if (selectors.includes(selector)) out.push(m[2]);
}
return out.join('\n');
}

describe('the nav drawer scrim', () => {
it('is a --scrim light-dark() token, not a raw colour in shell.css', () => {
expect(sheet('theme.css')).toMatch(/--scrim:\s*light-dark\(\s*rgb\(10 12 18 \/ 0\.35\),\s*rgb\(0 0 0 \/ 0\.55\)\s*\)/);
const scrim = rulesFor(sheet('shell/shell.css'), '.app-shell-scrim');
expect(scrim).toMatch(/background:\s*var\(--scrim\)/);
expect(sheet('shell/shell.css')).not.toMatch(/rgba?\(/);
});
});

describe('sticky side panels', () => {
// `.screen-main` is the scroll container under the screen header; the panels measure their cap against it.
it('.screen-main is a size container', () => {
expect(rulesFor(sheet('components/layout.css'), '.screen-main')).toMatch(/container-type:\s*size/);
});

// Each panel sticks at `top: var(--space-4)`; capped to the scroll container's height less that top and a bottom gap.
const cap = /max-height:\s*calc\(100cqh - 2 \* var\(--space-4\)\)/;
it.each([
['screens/queues/queues.css', '.qu-review > .qu-rubric-card'],
['screens/hub/hub.css', '.hub-split > .hub-entry'],
])('%s: %s fits the scroll container', (file, selector) => {
const rules = rulesFor(sheet(file), selector);
expect(rules).toMatch(/position:\s*sticky/);
expect(rules).toMatch(/top:\s*var\(--space-4\)/);
expect(rules).toMatch(cap);
expect(rules).not.toMatch(/100dvh/);
});

it('the rubric card scrolls its fields, so Skip / Submit & next stay pinned at its foot', () => {
const css = sheet('screens/queues/queues.css');
expect(rulesFor(css, '.qu-review > .qu-rubric-card')).toMatch(/overflow:\s*hidden/);
const body = rulesFor(css, '.qu-review > .qu-rubric-card > .qu-rubric-body');
expect(body).toMatch(/overflow:\s*auto/);
expect(body).toMatch(/min-height:\s*0/);
});

it('the hub entry scrolls inside itself', () => {
expect(rulesFor(sheet('screens/hub/hub.css'), '.hub-split > .hub-entry')).toMatch(/overflow:\s*auto/);
});
});
79 changes: 79 additions & 0 deletions apps/agent-forge/src/canvas/__tests__/canvasResets.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, rerender } from '../../ui/__tests__/render';
import { useAgentEdgeSelection, useFitOnLoad } from '../canvasResets';

beforeEach(() => {
// Run each frame at once, so a fit lands inside the render's act().
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
return 1;
});
});
afterEach(() => {
vi.restoreAllMocks();
});

describe('useFitOnLoad', () => {
const fit = vi.fn();
let fitting = false;
function Probe({ agentId, nodes }: { agentId: string; nodes: number }) {
fitting = useFitOnLoad(agentId, nodes, nodes > 0, fit);
return null;
}
beforeEach(() => fit.mockClear());

it('fits once when an agent opens, and again when another agent opens', () => {
render(<Probe agentId="a" nodes={3} />);
expect(fit).toHaveBeenCalledTimes(1);
expect(fitting).toBe(false);
rerender(<Probe agentId="a" nodes={4} />);
expect(fit).toHaveBeenCalledTimes(1);
rerender(<Probe agentId="b" nodes={2} />);
expect(fit).toHaveBeenCalledTimes(2);
});

it('refits when the graph is emptied and then filled again (a template applied)', () => {
render(<Probe agentId="a" nodes={3} />);
expect(fit).toHaveBeenCalledTimes(1);
rerender(<Probe agentId="a" nodes={0} />);
expect(fit).toHaveBeenCalledTimes(1);
rerender(<Probe agentId="a" nodes={4} />);
expect(fit).toHaveBeenCalledTimes(2);
expect(fitting).toBe(false);
});

it('never fits an empty graph', () => {
render(<Probe agentId="a" nodes={0} />);
expect(fit).not.toHaveBeenCalled();
expect(fitting).toBe(false);
});
});

describe('useAgentEdgeSelection', () => {
let state: ReturnType<typeof useAgentEdgeSelection> = [undefined, () => undefined];
function Probe({ agentId }: { agentId: string }) {
state = useAgentEdgeSelection(agentId);
return null;
}

it('keeps the selected edge while the agent stays open', () => {
render(<Probe agentId="a" />);
act(() => state[1]('e1'));
expect(state[0]).toBe('e1');
rerender(<Probe agentId="a" />);
expect(state[0]).toBe('e1');
act(() => state[1](undefined));
expect(state[0]).toBeUndefined();
});

it('clears the selected edge when another agent opens', () => {
render(<Probe agentId="a" />);
act(() => state[1]('e1'));
rerender(<Probe agentId="b" />);
expect(state[0]).toBeUndefined();
// and switching back does not bring the old selection back
rerender(<Probe agentId="a" />);
expect(state[0]).toBeUndefined();
});
});
36 changes: 36 additions & 0 deletions apps/agent-forge/src/canvas/canvasResets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState } from 'react';

/**
* The one owner of the canvas's first fit: when an agent is opened and when an
* empty canvas is filled (a template applied, or the first node dropped). Calls
* `fit` once every node has been measured (`allMeasured`), so the fit uses real
* card sizes, and returns `true` until that fit has landed - the canvas keeps
* its viewport hidden meanwhile, so the graph never shows unfitted and then jumps.
*/
export function useFitOnLoad(agentId: string, nodeCount: number, allMeasured: boolean, fit: () => void): boolean {
const filled = nodeCount > 0;
const loadKey = `${agentId}|${filled ? 'filled' : 'empty'}`;
const [fittedKey, setFittedKey] = useState<string | null>(null);
// An emptied graph forgets its fit, so filling it again (a template applied) refits.
if (!filled && fittedKey !== null) setFittedKey(null);
useEffect(() => {
if (fittedKey === loadKey || !filled || !allMeasured) return;
const frame = window.requestAnimationFrame(() => {
fit();
setFittedKey(loadKey);
});
return () => window.cancelAnimationFrame(frame);
}, [loadKey, fittedKey, filled, allMeasured, fit]);
return filled && fittedKey !== loadKey;
}

/**
* The canvas's selected edge id (drawn in the accent colour) and its setter.
* A selection belongs to the agent it was made in: opening another agent clears it.
*/
export function useAgentEdgeSelection(agentId: string): [string | undefined, (id: string | undefined) => void] {
const [selection, setSelection] = useState<{ agentId: string; edgeId: string | undefined }>({ agentId, edgeId: undefined });
if (selection.agentId !== agentId) setSelection({ agentId, edgeId: undefined });
const select = useCallback((edgeId: string | undefined) => setSelection({ agentId, edgeId }), [agentId]);
return [selection.agentId === agentId ? selection.edgeId : undefined, select];
}
Loading
Loading