diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index e2397d4a4..82206105b 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -713,6 +713,45 @@ test('agents x: a run in progress', async ({ page }) => { await shoot(page, 'x-agents-running'); }); +/* + * Review-only shots of the live run status off Agents: a mock run finishes on + * Agents, then Prompts, Monitoring and a prompt (its label chips first; 1280, + * 860 and 375 wide) show its status pill at the end of the header's title row. + */ +test('x-live-status: a finished run on Prompts and Monitoring', async ({ page }) => { + await newAgent(page, `live-${Date.now()}`); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect(page.locator('.topbar [role="status"]')).toContainText('done', { timeout: 15_000 }); + const nav = page.getByRole('navigation', { name: 'Primary' }); + await nav.getByRole('link', { name: 'Prompts' }).click(); + await expect(rows(page)).toHaveCount(docPrompts.length); + await shoot(page, 'x-live-status-prompts'); + await nav.getByRole('link', { name: 'Monitoring' }).click(); + await monitoringReady(page); + await shoot(page, 'x-live-status-monitoring'); + // A prompt's label chips come first, the run status last. + await nav.getByRole('link', { name: 'Prompts' }).click(); + await rows(page).filter({ hasText: 'support-system' }).click(); + await expect(page.getByRole('region', { name: 'Version history' })).toContainText('v1'); + await shoot(page, 'x-live-status-prompt-detail'); + for (const [width, height] of [[860, 800], [375, 812]] as const) { + await page.setViewportSize({ width, height }); + await expect(page.getByRole('region', { name: 'Version history' })).toBeVisible(); + await shoot(page, `x-live-status-prompt-detail-${width}`); + } + // A long prompt name with five labels (plus latest): at 860 and 375 the chips wrap under the title. + const LONG = 'customer-onboarding-concierge-escalation-policy-emea'; + const longVersion = { ...supportVersions[0], name: LONG, prompt_id: `${LONG}-4`, labels: ['production', 'staging', 'canary', 'eu-west', 'experiment-b'] }; + await page.route(`**/observe/api/v1/prompts/${LONG}`, (route) => json(route, { name: LONG, versions: [longVersion] })); + await page.evaluate((name) => (window.location.hash = `#/prompts/${name}`), LONG); + for (const [width, height] of [[1280, 800], [860, 800], [375, 812]] as const) { + await page.setViewportSize({ width, height }); + await expect(page.getByRole('banner').getByRole('status')).toContainText('done'); + await expect(page.getByRole('banner').getByText('experiment-b')).toBeVisible(); + await shoot(page, `x-live-status-long-prompt-${width}`); + } +}); + // --- Kit states (x- shots: review-only, never used in the docs) ------------- test('x-kit: traces empty state', async ({ page }) => { diff --git a/apps/agent-forge/e2e/studioUx.spec.ts b/apps/agent-forge/e2e/studioUx.spec.ts index 26441b1a8..0df298767 100644 --- a/apps/agent-forge/e2e/studioUx.spec.ts +++ b/apps/agent-forge/e2e/studioUx.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page, type WebSocketRoute } from '@playwright/test'; +import { stubObserve } from './observeStub'; /** * Status, copy and naming match the docs - a finished run says @@ -41,6 +42,23 @@ test('a new agent is named what you typed; a finished run says done and labels i await expect(page.locator('.chat-msg.user:not(.run-input)')).toHaveCount(0); }); +test('the live run status follows you off Agents: Prompts shows it in its header', async ({ page }) => { + // A reachable (stubbed) daemon, so Prompts renders its own header rather than the daemon-down default. + await stubObserve(page); + await page.goto(`/?token=${TOKEN}`); + await createAgent(page, `ux-live-${Date.now()}`); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect(page.locator('.topbar [role="status"]')).toContainText('done', { timeout: 15_000 }); + + await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Prompts' }).click(); + await expect(page.getByRole('banner').getByRole('heading', { level: 1 })).toHaveText('Prompts'); + await expect(page.getByRole('region', { name: 'Prompts' })).toBeVisible(); + // Exactly one run-status region on the page, in the header, saying how the run ended. + const runStatus = page.getByRole('status').filter({ hasText: 'Run status:' }); + await expect(runStatus).toHaveCount(1); + await expect(page.getByRole('banner').getByRole('status')).toHaveText('Run status: done'); +}); + test("the drawer's Trace tab shows a finished run in the shared trace viewer", async ({ page }) => { const id = `ux-trace-${Date.now()}`; await page.goto(`/?token=${TOKEN}`); diff --git a/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx b/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx index 25194aa85..dd7efe4aa 100644 --- a/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx +++ b/apps/agent-forge/src/screens/datasets/__tests__/DatasetsScreen.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import { DatasetsScreen } from '../DatasetsScreen'; import { datasetPath } from '../datasetsUrl'; import { render } from '../../../ui/__tests__/render'; diff --git a/apps/agent-forge/src/screens/evals/EvalsTabs.tsx b/apps/agent-forge/src/screens/evals/EvalsTabs.tsx index 36a78594b..e366bec00 100644 --- a/apps/agent-forge/src/screens/evals/EvalsTabs.tsx +++ b/apps/agent-forge/src/screens/evals/EvalsTabs.tsx @@ -37,8 +37,8 @@ export function EvalsTabs() { /** * The header of an Evaluations screen: title "Evaluations" (a detail view passes - * its own title and breadcrumbs), no run status, and the {@link EvalsTabs} row. + * its own title and breadcrumbs), the live run status, and the {@link EvalsTabs} row. */ export function EvalsHeader(props: Omit) { - return } />; + return } />; } diff --git a/apps/agent-forge/src/screens/evaluators/__tests__/EvaluatorsScreen.test.tsx b/apps/agent-forge/src/screens/evaluators/__tests__/EvaluatorsScreen.test.tsx index cbb3f7483..bccceb608 100644 --- a/apps/agent-forge/src/screens/evaluators/__tests__/EvaluatorsScreen.test.tsx +++ b/apps/agent-forge/src/screens/evaluators/__tests__/EvaluatorsScreen.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import { EvaluatorsScreen } from '../EvaluatorsScreen'; import { render } from '../../../ui/__tests__/render'; import { ToastProvider } from '../../../ui/Toast'; diff --git a/apps/agent-forge/src/screens/hub/HubScreen.tsx b/apps/agent-forge/src/screens/hub/HubScreen.tsx index 8f66b64d7..58ecd7097 100644 --- a/apps/agent-forge/src/screens/hub/HubScreen.tsx +++ b/apps/agent-forge/src/screens/hub/HubScreen.tsx @@ -269,11 +269,10 @@ function HubContent({ namespace }: { namespace: string | undefined }) { function HubHeader({ namespace }: { namespace: string | undefined }) { const route = useRoute(); if (namespace === undefined) - return ; + return ; return ( diff --git a/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx b/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx index 51b59a1db..e90142826 100644 --- a/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx +++ b/apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx @@ -113,7 +113,6 @@ export function MonitoringScreen() { diff --git a/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx b/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx index acadebef0..de7eff518 100644 --- a/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx +++ b/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx @@ -43,7 +43,7 @@ const COLUMNS: Column[] = [ { key: 'updated', header: 'Updated', width: 140, hideBelow: 720, render: (p) => fmtDateTime(p.created_at) }, ]; -const HEADER = ; +const HEADER = ; function PromptList() { const prompts = usePrompts(); 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 a7fef18c5..1853f798d 100644 --- a/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx +++ b/apps/agent-forge/src/screens/queues/__tests__/QueuesScreen.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import { QueuesScreen } from '../QueuesScreen'; import { queuePath, queuesPath } from '../queuesUrl'; import { render } from '../../../ui/__tests__/render'; diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index c71bfa919..d058de6a4 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -87,7 +87,7 @@ export function Screens() { else content = (
- +

Settings

diff --git a/apps/agent-forge/src/screens/scores/__tests__/ScoresScreen.test.tsx b/apps/agent-forge/src/screens/scores/__tests__/ScoresScreen.test.tsx index f72628f11..2d4d2c35c 100644 --- a/apps/agent-forge/src/screens/scores/__tests__/ScoresScreen.test.tsx +++ b/apps/agent-forge/src/screens/scores/__tests__/ScoresScreen.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import type { ObserveScorePassRate, ObserveScoreRow } from '../../../observe/types'; import { render } from '../../../ui/__tests__/render'; import { ScoresScreen } from '../ScoresScreen'; diff --git a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx index 1dc36c472..07eba700e 100644 --- a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx @@ -238,7 +238,6 @@ export function ThreadScreen({ sessionId }: { sessionId: string }) {
Thread {sessionId} diff --git a/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx index 36ce25440..90b90a8ff 100644 --- a/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx @@ -174,7 +174,6 @@ function ThreadsListScreen() { <> 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 28a726d1f..225f283d5 100644 --- a/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx +++ b/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import { ThreadsScreen } from '../ThreadsScreen'; import { formatThreadsQuery, parseThreadsQuery } from '../threadsUrl'; import { render } from '../../../ui/__tests__/render'; diff --git a/apps/agent-forge/src/screens/traces/TraceScreen.tsx b/apps/agent-forge/src/screens/traces/TraceScreen.tsx index 811e3afd9..d117c825d 100644 --- a/apps/agent-forge/src/screens/traces/TraceScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TraceScreen.tsx @@ -138,7 +138,6 @@ export function TraceScreen({ traceId, pollMs = DEFAULT_POLL_MS }: { traceId: st
Trace {traceId} diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx index 6cf1a8125..b98944e87 100644 --- a/apps/agent-forge/src/screens/traces/TracesScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -279,7 +279,6 @@ export function TracesScreen() { <> setLive((on) => !on)} />} /> diff --git a/apps/agent-forge/src/shell/ScreenHeader.tsx b/apps/agent-forge/src/shell/ScreenHeader.tsx index 791df28d5..faa000998 100644 --- a/apps/agent-forge/src/shell/ScreenHeader.tsx +++ b/apps/agent-forge/src/shell/ScreenHeader.tsx @@ -18,17 +18,40 @@ interface HeaderSlot { const HeaderSlotContext = createContext(null); -/** The live run StatusPill; its own component, so a header that drops the status never reads the app state. */ -function LiveRunStatus() { +/** + * The live run StatusPill (the page's one run-status region); its own component, so only it reads the app state. + * `labelled` (beside a screen's own chips) makes its tooltip say whose status it is. + */ +function LiveRunStatus({ labelled }: { labelled: boolean }) { const { runStatus } = useAppState(); - return ; + const status = runStatus?.status ?? 'idle'; + return ; } -/** The PageHeader with the shell's defaults: the active nav item's label, and the live run status. */ +/** + * The PageHeader with the shell's defaults: the active nav item's label, and the live run + * status always, at the right end of the screen's own status chips. With chips, a hairline + * divider sets the run pill apart; the two wrap as one (pageHeader.css hides the divider + * when the chips render nothing). + */ function DefaultedHeader({ title, status, ...rest }: ScreenHeaderProps) { const { path } = useRoute(); const label = NAV_ITEMS.find((item) => isActive(item, path))?.label ?? 'Agent Forge'; - return : status} {...rest} />; + return ( + + {status} + + {status != null && + + } + {...rest} + /> + ); } /** @@ -36,7 +59,8 @@ function DefaultedHeader({ title, status, ...rest }: ScreenHeaderProps) { * (the nav label + live run status); a screen that renders its own * `` anywhere in * its tree replaces the default while mounted. `title` defaults to the active nav - * item's label and `status` to the live run StatusPill (`status={null}` drops it). + * item's label. The live run StatusPill is always in the title row; `status` adds the + * screen's own chips (a prompt chip, label chips) before it and never replaces it. * The props render through a portal, so the screen's own context and state apply. */ export function ScreenHeader(props: ScreenHeaderProps) { diff --git a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx index 3feaf42ed..efe5cedad 100644 --- a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx +++ b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx @@ -1,6 +1,10 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ScreenHeader renders inline here (no shell host); its live run status reads the app state. +vi.mock('../../state/AppState', () => ({ useAppState: () => ({ runStatus: null }) })); + import { DaemonStatus } from '../DaemonStatus'; import { TracesScreen } from '../../screens/traces/TracesScreen'; import { TraceScreen } from '../../screens/traces/TraceScreen'; diff --git a/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx b/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx index 42f463cc6..d4fe11f41 100644 --- a/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx +++ b/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx @@ -63,7 +63,9 @@ describe('ScreenHeader', () => { const header = el.querySelector('.screen-header header')!; expect(header.querySelector('.page-header-description')?.textContent).toBe('Examples to evaluate'); expect(header.querySelector('nav[aria-label="Breadcrumb"] [aria-current="page"]')?.textContent).toBe('Datasets'); - expect(header.querySelector('.page-header-status')).toBeNull(); + // `status={null}` adds no chips; the live run status stays, the one run-status region on the page. + expect(header.querySelector('.page-header-status')?.textContent).toBe('Run status: running'); + expect(document.querySelectorAll('[role="status"]')).toHaveLength(1); expect(el.querySelector('main header')).toBeNull(); // The screen's state still drives the portalled actions. @@ -78,6 +80,43 @@ describe('ScreenHeader', () => { expect(h1s()).toEqual(['Datasets']); }); + it("puts a screen's chips first and the live run status at the right end", () => { + window.location.hash = '#/prompts'; + appState.runStatus = { status: 'done' }; + render( + +
+ production} /> +
+
, + ); + const status = document.querySelector('.screen-header .page-header-status')!; + expect([...status.children].map((c) => c.className)).toEqual(['chip', 'page-header-run']); + // A hairline divider (decorative) keeps the run pill from reading as one of the screen's chips. + const run = status.lastElementChild!; + expect([...run.children].map((c) => c.className.split(' ')[0])).toEqual(['page-header-status-sep', 'status-pill']); + expect(run.firstElementChild?.getAttribute('aria-hidden')).toBe('true'); + const pill = run.lastElementChild!; + expect(pill.getAttribute('role')).toBe('status'); + expect(pill.textContent).toBe('Run status: done'); + expect(pill.getAttribute('title')).toBe('Last builder run: done'); + expect(document.querySelectorAll('[role="status"]')).toHaveLength(1); + }); + + it('with no screen chips: just the run pill, no divider, its own label as the tooltip', () => { + window.location.hash = '#/prompts'; + appState.runStatus = { status: 'done' }; + render( + +
+ , + ); + const status = document.querySelector('.screen-header .page-header-status')!; + expect(status.querySelector('.page-header-status-sep')).toBeNull(); + expect(status.children).toHaveLength(1); + expect(status.querySelector('[role="status"]')?.getAttribute('title')).toBe('done'); + }); + it('renders inline outside a host', () => { window.location.hash = '#/traces'; appState.runStatus = null; diff --git a/apps/agent-forge/src/ui/StatusPill.tsx b/apps/agent-forge/src/ui/StatusPill.tsx index 69c0fc5ab..641680583 100644 --- a/apps/agent-forge/src/ui/StatusPill.tsx +++ b/apps/agent-forge/src/ui/StatusPill.tsx @@ -47,16 +47,16 @@ export function statusTone(status: string | null | undefined): StatusTone { * text in a neutral pill; a missing status reads "none". A `live` pill is a polite * `role="status"` region (the top bar's run status), so screen readers hear the * run start, pause and finish; its `title` repeats the label for when a short - * toolbar shows only the dot (layout.css). + * toolbar shows only the dot (layout.css), unless `title` gives another tooltip. */ -export function StatusPill({ status, live = false }: { status: string | null | undefined; live?: boolean }) { +export function StatusPill({ status, live = false, title }: { status: string | null | undefined; live?: boolean; title?: string }) { const label = status == null ? 'none' : (LABELS.get(status) ?? status); return (