diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index 82206105b..ab9ef2287 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -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'; /** @@ -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); @@ -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(); + 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]) { diff --git a/apps/agent-forge/e2e/observeStub.ts b/apps/agent-forge/e2e/observeStub.ts index 67bd0ed6d..c1e2ea802 100644 --- a/apps/agent-forge/e2e/observeStub.ts +++ b/apps/agent-forge/e2e/observeStub.ts @@ -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 { + 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 { + 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) }); diff --git a/apps/agent-forge/e2e/studioUx.spec.ts b/apps/agent-forge/e2e/studioUx.spec.ts index 0df298767..2e6feac12 100644 --- a/apps/agent-forge/e2e/studioUx.spec.ts +++ b/apps/agent-forge/e2e/studioUx.spec.ts @@ -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 @@ -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); + }); +}); diff --git a/apps/agent-forge/src/__tests__/polishCss.test.ts b/apps/agent-forge/src/__tests__/polishCss.test.ts new file mode 100644 index 000000000..ec3f2a5dd --- /dev/null +++ b/apps/agent-forge/src/__tests__/polishCss.test.ts @@ -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/); + }); +}); diff --git a/apps/agent-forge/src/canvas/__tests__/canvasResets.test.tsx b/apps/agent-forge/src/canvas/__tests__/canvasResets.test.tsx new file mode 100644 index 000000000..89586d8df --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/canvasResets.test.tsx @@ -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(); + expect(fit).toHaveBeenCalledTimes(1); + expect(fitting).toBe(false); + rerender(); + expect(fit).toHaveBeenCalledTimes(1); + rerender(); + expect(fit).toHaveBeenCalledTimes(2); + }); + + it('refits when the graph is emptied and then filled again (a template applied)', () => { + render(); + expect(fit).toHaveBeenCalledTimes(1); + rerender(); + expect(fit).toHaveBeenCalledTimes(1); + rerender(); + expect(fit).toHaveBeenCalledTimes(2); + expect(fitting).toBe(false); + }); + + it('never fits an empty graph', () => { + render(); + expect(fit).not.toHaveBeenCalled(); + expect(fitting).toBe(false); + }); +}); + +describe('useAgentEdgeSelection', () => { + let state: ReturnType = [undefined, () => undefined]; + function Probe({ agentId }: { agentId: string }) { + state = useAgentEdgeSelection(agentId); + return null; + } + + it('keeps the selected edge while the agent stays open', () => { + render(); + act(() => state[1]('e1')); + expect(state[0]).toBe('e1'); + rerender(); + expect(state[0]).toBe('e1'); + act(() => state[1](undefined)); + expect(state[0]).toBeUndefined(); + }); + + it('clears the selected edge when another agent opens', () => { + render(); + act(() => state[1]('e1')); + rerender(); + expect(state[0]).toBeUndefined(); + // and switching back does not bring the old selection back + rerender(); + expect(state[0]).toBeUndefined(); + }); +}); diff --git a/apps/agent-forge/src/canvas/canvasResets.ts b/apps/agent-forge/src/canvas/canvasResets.ts new file mode 100644 index 000000000..77b593dd1 --- /dev/null +++ b/apps/agent-forge/src/canvas/canvasResets.ts @@ -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(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]; +} diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index a731e9de7..9fba4506e 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -41,6 +41,7 @@ import { nodeRunStatuses, type NodeRunStatus } from '../canvas/nodeStatus'; import { isEdgeTypeAllowed } from '../graph/connectionRules'; import { PALETTE_DRAG_MIME, HOOK_DRAG_MIME } from '../canvas/dnd'; import { pickHookTemplate } from '../canvas/paletteActions'; +import { useAgentEdgeSelection, useFitOnLoad } from '../canvas/canvasResets'; import type { AgentGraphNode, AgentGraphNodeType, AgentGraphSpec, AgentNodeHookPhase } from '../graph/types'; type SetGraph = ReturnType['setGraph']; @@ -395,27 +396,11 @@ function useFitViewOnResize() { }, [fitLegibly]); } -/** - * The one owner of the first fit: when an agent is opened and when an empty - * canvas is filled (a template applied, or the first node dropped). Waits - * until every node has been measured, 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. - */ +/** The first fit when an agent opens or an empty canvas is filled (canvas/canvasResets.ts), with legible sizing. */ function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes): boolean { const fitLegibly = useLegibleFit(); - const loadKey = `${agentId}|${graph.nodes.length > 0 ? 'filled' : 'empty'}`; - const [fittedKey, setFittedKey] = useState(null); const allMeasured = graph.nodes.length > 0 && graph.nodes.every((n) => measured[n.id]); - useEffect(() => { - if (fittedKey === loadKey || !allMeasured) return; - const frame = window.requestAnimationFrame(() => { - fitLegibly(); - setFittedKey(loadKey); - }); - return () => window.cancelAnimationFrame(frame); - }, [loadKey, fittedKey, allMeasured, fitLegibly]); - return graph.nodes.length > 0 && fittedKey !== loadKey; + return useFitOnLoad(agentId, graph.nodes.length, allMeasured, fitLegibly); } /** A screen-space box: left/top edge and size. */ @@ -639,7 +624,7 @@ function useCanvasElements() { () => toRfNodes(graph, { selectedNodeId, onRename, highlightedNodeId, breakpoints, statuses }, measured), [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey, statuses, measured] ); - const [selectedEdgeId, setSelectedEdgeId] = useState(undefined); + const [selectedEdgeId, setSelectedEdgeId] = useAgentEdgeSelection(agentId); const edges = useMemo(() => toRfEdges(graph, statuses, selectedEdgeId), [graph, statuses, selectedEdgeId]); return { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, nodes, edges, measured, setMeasured, setSelectedEdgeId }; diff --git a/apps/agent-forge/src/components/LeftRail.tsx b/apps/agent-forge/src/components/LeftRail.tsx index 2c9e2b5e6..b78518bb4 100644 --- a/apps/agent-forge/src/components/LeftRail.tsx +++ b/apps/agent-forge/src/components/LeftRail.tsx @@ -8,7 +8,7 @@ import { AGENT_TEMPLATES, type TemplateId } from '../canvas/templates'; import { addHookToNode, addNode } from '../canvas/graphMutations'; import { pickHookTemplate, viewportCentrePosition } from '../canvas/paletteActions'; import { NODE_KINDS, type KindTone } from '../canvas/nodeKinds'; -import { statusTone } from '../ui/StatusPill'; +import { StatusPill, statusTone } from '../ui/StatusPill'; import { navigate } from '../router/router'; import { errorMessage } from './errorMessage'; import { tabIds, tabListKeyDown } from './tabs'; @@ -69,8 +69,16 @@ export function agentInitials(id: string): string { return letters.toUpperCase(); } -/** A row's run status as a small dot in its tone; the status word is its tooltip and screen-reader text. */ -function StatusDot({ status }: { status: string }) { +/** The statuses a row spells out in a pill as well as colouring its dot, so they never rely on colour alone. */ +const WORDED_STATUSES = new Set(['unsaved', 'error']); + +/** + * A row's run status: a small dot in its tone, its word the tooltip and + * screen-reader text - or, for `unsaved` and `error`, the status pill + * (dot plus "Unsaved" / "Error"), which never shrinks; the name truncates instead. + */ +export function AgentStatusMark({ status }: { status: string }) { + if (WORDED_STATUSES.has(status)) return ; return ( {status} @@ -78,7 +86,7 @@ function StatusDot({ status }: { status: string }) { ); } -/** The row body shared by saved and unsaved agents: initials tile, name over model, status dot. */ +/** The row body shared by saved and unsaved agents: initials tile, name over model, status mark. */ function AgentRowBody({ name, meta, status }: { name: string; meta: string; status: string }) { return ( <> @@ -93,7 +101,7 @@ function AgentRowBody({ name, meta, status }: { name: string; meta: string; stat {meta} - + ); } diff --git a/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx b/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx index eb68352bc..b6cf74fe3 100644 --- a/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx +++ b/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx @@ -22,7 +22,7 @@ vi.mock('../../state/AppState', () => ({ useAppState: () => appState })); // The drawer's panels are covered elsewhere; here only its tab strip matters. vi.mock('../ChatPanel', () => ({ ChatPanel: () => null })); -import { agentInitials } from '../LeftRail'; +import { AgentStatusMark, agentInitials } from '../LeftRail'; import { ApprovalCard } from '../ApprovalCard'; import { ChatComposer } from '../ChatComposer'; import { BottomDrawer } from '../BottomDrawer'; @@ -47,6 +47,30 @@ describe('agentInitials', () => { }); }); +describe('AgentStatusMark', () => { + it('unsaved and error rows show a text pill as well as the dot, so status is not colour alone', () => { + for (const [status, tone, text] of [['unsaved', 'warning', 'unsaved'], ['error', 'error', 'error']] as const) { + const container = render(); + const pill = container.querySelector(`.status-pill.pill-${tone}`); + expect(pill, status).not.toBeNull(); + expect(pill!.querySelector('.dot')).not.toBeNull(); + // shown as "Unsaved" / "Error" (the pill capitalises its first letter) + expect(pill!.querySelector('.status-pill-label')?.textContent).toBe(text); + expect(container.querySelector('.agent-dot')).toBeNull(); + } + }); + + it('every other status stays a bare dot, its word the tooltip and screen-reader text', () => { + for (const status of ['idle', 'running', 'done', 'paused']) { + const container = render(); + const dot = container.querySelector('.agent-dot'); + expect(dot?.getAttribute('title'), status).toBe(status); + expect(dot?.querySelector('.visually-hidden')?.textContent).toBe(status); + expect(container.querySelector('.status-pill')).toBeNull(); + } + }); +}); + describe('ApprovalCard', () => { it('the card shows the tool, its arguments as a JSON tree, and Approve/Reject', () => { const container = render(); diff --git a/apps/agent-forge/src/components/layout.css b/apps/agent-forge/src/components/layout.css index 98ff1e42f..5dbbf6351 100644 --- a/apps/agent-forge/src/components/layout.css +++ b/apps/agent-forge/src/components/layout.css @@ -18,6 +18,10 @@ display: flex; flex-direction: column; overflow: auto; + /* Sticky side panels (queue rubric card, hub entry) cap themselves at 100cqh, this scroll container's + visible height under the screen header. Its size comes from the .app grid row, never its content, + so size containment changes nothing here; no fixed-position UI renders inside it. */ + container-type: size; } .screen-main > .observe, .screen-main > .screen-settings { diff --git a/apps/agent-forge/src/screens/hub/hub.css b/apps/agent-forge/src/screens/hub/hub.css index e9e921d12..e56f90a01 100644 --- a/apps/agent-forge/src/screens/hub/hub.css +++ b/apps/agent-forge/src/screens/hub/hub.css @@ -113,6 +113,12 @@ position: sticky; top: var(--space-4); } + /* never taller than the scroll container (.screen-main) below its sticky top; a long entry scrolls inside, + so its editor's Save / Cancel stay reachable */ + .hub-split > .hub-entry { + max-height: calc(100cqh - 2 * var(--space-4)); + overflow: auto; + } } @media (max-width: 1179px) { .hub-detail-empty { diff --git a/apps/agent-forge/src/screens/queues/NewQueueForm.tsx b/apps/agent-forge/src/screens/queues/NewQueueForm.tsx index 15b48c031..262d750a2 100644 --- a/apps/agent-forge/src/screens/queues/NewQueueForm.tsx +++ b/apps/agent-forge/src/screens/queues/NewQueueForm.tsx @@ -183,7 +183,7 @@ export function NewQueueForm({ onCreated, onCancel }: { onCreated: () => void; o {error && ( diff --git a/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx b/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx index 1853f798d..7239cbdb2 100644 --- a/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx +++ b/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx @@ -209,7 +209,7 @@ describe('QueuesScreen list', () => { type(form.querySelector('input[name="queue-name"]') as HTMLInputElement, 'support-review'); type(form.querySelector('input[name="rubric-name-0"]') as HTMLInputElement, 'accuracy'); expect(buttonNamed(container, 'Remove rubric row 1')!.disabled).toBe(true); - await act(async () => buttonNamed(container, 'Add rubric entry')!.click()); + await act(async () => buttonNamed(container, 'Add rubric row')!.click()); type(form.querySelector('input[name="rubric-name-1"]') as HTMLInputElement, 'tone'); type(form.querySelector('input[name="rubric-description-1"]') as HTMLInputElement, 'Polite?'); type(form.querySelector('input[name="rubric-min-1"]') as HTMLInputElement, '1'); diff --git a/apps/agent-forge/src/screens/queues/queues.css b/apps/agent-forge/src/screens/queues/queues.css index 148a89db6..aee21c5e0 100644 --- a/apps/agent-forge/src/screens/queues/queues.css +++ b/apps/agent-forge/src/screens/queues/queues.css @@ -226,9 +226,17 @@ align-items: start; min-width: 0; } +/* Never taller than the scroll container (.screen-main) below its sticky top: the rubric fields + scroll inside the card, and Skip / Submit & next stay pinned at its foot. */ .qu-review > .qu-rubric-card { position: sticky; top: var(--space-4); + max-height: calc(100cqh - 2 * var(--space-4)); + overflow: hidden; +} +.qu-review > .qu-rubric-card > .qu-rubric-body { + min-height: 0; + overflow: auto; } @media (max-width: 1100px) { .qu-review { @@ -236,6 +244,11 @@ } .qu-review > .qu-rubric-card { position: static; + max-height: none; + overflow: visible; + } + .qu-review > .qu-rubric-card > .qu-rubric-body { + overflow: visible; } } .qu-item { diff --git a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx index 07eba700e..4ede45be1 100644 --- a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx @@ -246,7 +246,7 @@ export function ThreadScreen({ sessionId }: { sessionId: string }) { actions={ } /> diff --git a/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx b/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx index 225f283d5..de30c1a9c 100644 --- a/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx +++ b/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx @@ -216,7 +216,7 @@ describe('ThreadsScreen thread', () => { expect(links).toEqual(['#/traces/trace-1', '#/traces/trace-2']); }); - it('list -> thread -> trace: All threads keeps the list query', async () => { + it('list -> thread -> trace: Back to threads keeps the list query', async () => { await mountAt('#/threads?status=active'); window.history.replaceState(null, '', `/#/threads/${SESSION.session_id}`); const container = render( @@ -225,7 +225,9 @@ describe('ThreadsScreen thread', () => { , ); await flush(); - const back = [...container.querySelectorAll('a')].find((a) => a.textContent?.includes('All threads')); + // The header's way back is named "Back to threads", like the empty state's. + const back = container.querySelector('.thread-back'); + expect(back?.textContent).toBe('Back to threads'); expect(back?.getAttribute('href')).toBe('#/threads?status=active'); }); diff --git a/apps/agent-forge/src/shell/shell.css b/apps/agent-forge/src/shell/shell.css index 06a1bd0a2..797b4d304 100644 --- a/apps/agent-forge/src/shell/shell.css +++ b/apps/agent-forge/src/shell/shell.css @@ -284,7 +284,7 @@ position: absolute; inset: 0; z-index: 35; - background: rgba(10, 12, 18, 0.35); + background: var(--scrim); } @media (prefers-reduced-motion: reduce) { diff --git a/apps/agent-forge/src/theme.css b/apps/agent-forge/src/theme.css index 78f8cb659..349b0b007 100644 --- a/apps/agent-forge/src/theme.css +++ b/apps/agent-forge/src/theme.css @@ -100,6 +100,8 @@ --shadow-lg: 0 24px 48px -12px light-dark(rgb(16 16 24 / 0.18), transparent), 0 24px 64px -12px light-dark(transparent, rgb(0 0 0 / 0.75)); + /* the dimming layer behind the narrow nav drawer (not text on a surface, so not in contrast.test.ts) */ + --scrim: light-dark(rgb(10 12 18 / 0.35), rgb(0 0 0 / 0.55)); /* the subtle top highlight on filled (primary) buttons */ --highlight-inset: inset 0 1px 0 light-dark(rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.12)); /* focus ring for inputs (paired with border-color: var(--accent)) */