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
39 changes: 39 additions & 0 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
18 changes: 18 additions & 0 deletions apps/agent-forge/e2e/studioUx.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions apps/agent-forge/src/screens/evals/EvalsTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScreenHeaderProps, 'tabs' | 'status'>) {
return <ScreenHeader title="Evaluations" {...props} status={null} tabs={<EvalsTabs />} />;
return <ScreenHeader title="Evaluations" {...props} tabs={<EvalsTabs />} />;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 1 addition & 2 deletions apps/agent-forge/src/screens/hub/HubScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,10 @@ function HubContent({ namespace }: { namespace: string | undefined }) {
function HubHeader({ namespace }: { namespace: string | undefined }) {
const route = useRoute();
if (namespace === undefined)
return <ScreenHeader title="Context Hub" status={null} description="Shared key/value context the fleet's agents publish for each other." />;
return <ScreenHeader title="Context Hub" description="Shared key/value context the fleet's agents publish for each other." />;
return (
<ScreenHeader
title={namespace}
status={null}
description="Entries in this namespace; open one to see its full value."
breadcrumbs={[{ label: 'Context Hub', href: `#${hubPath(undefined, parseHubQuery(route.query))}` }, { label: namespace }]}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ export function MonitoringScreen() {
<ScreenHeader
title="Monitoring"
className="mon-header"
status={null}
description="Fleet health, throughput, latency and cost over time."
actions={
<div className="mon-controls">
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/src/screens/prompts/PromptsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const COLUMNS: Column<ObservePromptRow>[] = [
{ key: 'updated', header: 'Updated', width: 140, hideBelow: 720, render: (p) => fmtDateTime(p.created_at) },
];

const HEADER = <ScreenHeader title="Prompts" status={null} description="Versioned prompt templates and the labels that pin a version." />;
const HEADER = <ScreenHeader title="Prompts" description="Versioned prompt templates and the labels that pin a version." />;

function PromptList() {
const prompts = usePrompts();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/src/screens/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function Screens() {
else
content = (
<div className="screen-settings">
<ScreenHeader title="Settings" description={SETTINGS_DESCRIPTION} status={null} />
<ScreenHeader title="Settings" description={SETTINGS_DESCRIPTION} />
<h2 className="visually-hidden">Settings</h2>
<SettingsPanel variant="screen" />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 0 additions & 1 deletion apps/agent-forge/src/screens/threads/ThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,6 @@ export function ThreadScreen({ sessionId }: { sessionId: string }) {
<section className="thread-screen" aria-label="Thread" data-session-id={sessionId}>
<ScreenHeader
title="Threads"
status={null}
description={
<>
Thread <code className="thread-id">{sessionId}</code>
Expand Down
1 change: 0 additions & 1 deletion apps/agent-forge/src/screens/threads/ThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ function ThreadsListScreen() {
<>
<ScreenHeader
title="Threads"
status={null}
description="Conversations grouped by session"
actions={
<button type="button" className="btn btn-icon" aria-label="Refresh" title="Refresh" disabled={health.down} onClick={bump}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 0 additions & 1 deletion apps/agent-forge/src/screens/traces/TraceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export function TraceScreen({ traceId, pollMs = DEFAULT_POLL_MS }: { traceId: st
<div className="trace-screen" data-trace-id={traceId}>
<ScreenHeader
title="Traces"
status={null}
description={
<>
Trace <code className="trace-id">{traceId}</code>
Expand Down
1 change: 0 additions & 1 deletion apps/agent-forge/src/screens/traces/TracesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ export function TracesScreen() {
<>
<ScreenHeader
title="Traces"
status={null}
description="Every run reported to the collector"
actions={<ListActions live={live} disabled={health.down} onRefresh={bump} onLive={() => setLive((on) => !on)} />}
/>
Expand Down
36 changes: 30 additions & 6 deletions apps/agent-forge/src/shell/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,49 @@ interface HeaderSlot {

const HeaderSlotContext = createContext<HeaderSlot | null>(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 <StatusPill status={runStatus?.status ?? 'idle'} live />;
const status = runStatus?.status ?? 'idle';
return <StatusPill status={status} live title={labelled ? `Last builder run: ${status}` : undefined} />;
}

/** 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 <PageHeader title={title ?? label} status={status === undefined ? <LiveRunStatus /> : status} {...rest} />;
return (
<PageHeader
title={title ?? label}
status={
<>
{status}
<span className="page-header-run">
{status != null && <span className="page-header-status-sep" aria-hidden="true" />}
<LiveRunStatus labelled={status != null} />
</span>
</>
}
{...rest}
/>
);
}

/**
* The header at the top of a non-Agents screen. ScreenFrame renders a default one
* (the nav label + live run status); a screen that renders its own
* `<ScreenHeader title=… description=… actions={…} breadcrumbs={…} />` 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) {
Expand Down
4 changes: 4 additions & 0 deletions apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
41 changes: 40 additions & 1 deletion apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
<ScreenHeaderHost>
<main>
<ScreenHeader title="support-system" status={<span className="chip">production</span>} />
</main>
</ScreenHeaderHost>,
);
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(
<ScreenHeaderHost>
<main />
</ScreenHeaderHost>,
);
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;
Expand Down
6 changes: 3 additions & 3 deletions apps/agent-forge/src/ui/StatusPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span
className={`status-pill pill-${statusTone(status)}`}
role={live ? 'status' : undefined}
aria-live={live ? 'polite' : undefined}
title={live ? label : undefined}
title={title ?? (live ? label : undefined)}
>
<span className="dot" aria-hidden="true" />
{live && <span className="visually-hidden">Run status: </span>}
Expand Down
24 changes: 23 additions & 1 deletion apps/agent-forge/src/ui/pageHeader.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
}
.page-header-title-row {
display: flex;
/* A long title or many chips: the status cluster wraps under the title rather than squeezing it. */
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
gap: var(--space-1) var(--space-2);
min-width: 0;
}
.page-header-title {
Expand All @@ -36,9 +38,29 @@
white-space: nowrap;
}
.page-header-status {
flex: 0 1 auto;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
/* A screen's chips, then the live run status at the right end. */
gap: var(--space-1) var(--space-2);
}
/* The run pill and its divider wrap as one, so the divider never ends a line on its own. */
.page-header-run {
flex: none;
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Between a screen's chips and the run pill; nothing to divide when the chips render nothing. */
.page-header-status-sep {
width: 1px;
height: 14px;
background: var(--border);
}
.page-header-run:first-child > .page-header-status-sep {
display: none;
}
.page-header-description {
margin: 0;
Expand Down
Loading
Loading