diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index 668eb7a77..433082898 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -143,8 +143,11 @@ for (const { label, path } of SCANS) { await expect(page.locator('main')).toHaveCount(1); await expect(page.getByRole('banner')).toHaveCount(1); await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1); - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Agent Forge'); - await expect(page.getByRole('navigation', { name: 'Primary' }).locator('[aria-current="page"]')).toHaveCount(1); + const current = page.getByRole('navigation', { name: 'Primary' }).locator('[aria-current="page"]'); + await expect(current).toHaveCount(1); + // Agents keeps its visually hidden "Agent Forge" h1; every other screen's h1 is its PageHeader title (the nav label). + const h1 = path.startsWith('/agents') ? 'Agent Forge' : await current.innerText(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(h1); await expectNoSeriousAxeViolations(page, path); }); } diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index a643ddde2..8304a0f56 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -254,3 +254,24 @@ test('command palette: open over Traces', async ({ page }) => { await page.waitForTimeout(150); await page.screenshot({ path: path.join(OUT, 'studio-command-palette.png') }); }); + +// Review-only shots of the shell (`x-` names: not in the docs set). +test('x: collapsed rail on Traces', async ({ page }) => { + await open(page, '/traces'); + await expect(rows(page).first()).toBeVisible(); + await page.getByRole('button', { name: 'Collapse navigation' }).click(); + await page.waitForTimeout(250); + await page.mouse.move(0, 0); + await page.screenshot({ path: path.join(OUT, 'x-rail-collapsed.png') }); +}); + +test('x: 375 wide, Traces with the nav drawer closed and open', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + await open(page, '/traces'); + await expect(rows(page).first()).toBeVisible(); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(OUT, 'x-narrow-traces.png') }); + await page.getByRole('button', { name: 'Navigation', exact: true }).click(); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(OUT, 'x-narrow-drawer-open.png') }); +}); diff --git a/apps/agent-forge/e2e/studioUx.spec.ts b/apps/agent-forge/e2e/studioUx.spec.ts index cfcda2bfc..3cc295eb3 100644 --- a/apps/agent-forge/e2e/studioUx.spec.ts +++ b/apps/agent-forge/e2e/studioUx.spec.ts @@ -154,7 +154,7 @@ test('a ?token= link lands on Agents; the Build toolbar stays on Agents', async await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Traces' }).click(); await expectLanded(page, '#/traces'); - await expect(page.locator('.topbar .crumbs')).toHaveText('Traces'); + await expect(page.getByRole('banner').getByRole('heading', { level: 1 })).toHaveText('Traces'); for (const name of ['Run', 'Stop', 'Import', 'Export', 'Save', 'Debug']) { await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0); } @@ -163,3 +163,54 @@ test('a ?token= link lands on Agents; the Build toolbar stays on Agents', async await page.goto(`/?token=${TOKEN}#/no-such-screen`); await expectLanded(page, '#/agents'); }); + +const NAV_LABELS = ['Agents', 'Playground', 'Traces', 'Threads', 'Monitoring', 'Evaluations', 'Prompts', 'Context Hub', 'Settings']; + +test('the docked nav collapses to an icon rail that survives a reload, every link still named', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`/?token=${TOKEN}#/traces`); + await expectLanded(page, '#/traces'); + const nav = page.getByRole('navigation', { name: 'Primary' }); + const card = nav.getByRole('status'); + /** Settings and the collector card are pinned to the nav's bottom edge. */ + const expectCardPinned = async () => { + await expect + .poll(async () => { + const [n, c] = [await nav.boundingBox(), await card.boundingBox()]; + return n && c ? n.y + n.height - (c.y + c.height) : -1; + }) + .toBeLessThanOrEqual(12); + const [n, c] = [await nav.boundingBox(), await card.boundingBox()]; + expect(n!.y + n!.height - (c!.y + c!.height)).toBeGreaterThanOrEqual(0); + }; + await expectCardPinned(); + await nav.getByRole('button', { name: 'Collapse navigation' }).click(); + const expand = nav.getByRole('button', { name: 'Expand navigation' }); + await expect(expand).toHaveAttribute('aria-expanded', 'false'); + await expect.poll(async () => Math.round((await nav.boundingBox())?.width ?? 0)).toBe(56); + + await page.reload(); + await expect(page.locator('.app')).toBeVisible(); + await expect(expand).toHaveAttribute('aria-expanded', 'false'); + expect(Math.round((await nav.boundingBox())?.width ?? 0)).toBe(56); + await expectCardPinned(); + for (const name of NAV_LABELS) await expect(nav.getByRole('link', { name, exact: true })).toBeVisible(); + await expect(nav.getByRole('link', { name: 'Traces', exact: true })).toHaveAttribute('aria-current', 'page'); + await nav.getByRole('link', { name: 'Threads', exact: true }).click(); + await expectLanded(page, '#/threads'); + + // Narrow ignores the collapsed state: the drawer opens full width and closes as before. + await page.setViewportSize({ width: 375, height: 800 }); + await expect(nav.getByRole('button', { name: 'Expand navigation' })).toHaveCount(0); + const toggle = page.getByRole('button', { name: 'Navigation', exact: true }); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(nav.getByRole('link', { name: 'Traces', exact: true })).toBeVisible(); + await expect(nav.locator('.nav-label').first()).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(nav).toBeHidden(); + + await page.setViewportSize({ width: 1280, height: 800 }); + await nav.getByRole('button', { name: 'Expand navigation' }).click(); + await expect(nav.getByRole('button', { name: 'Collapse navigation' })).toHaveAttribute('aria-expanded', 'true'); +}); diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index c9f6bed74..6882ff678 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -1,7 +1,7 @@ import { useEffect, type ReactNode } from 'react'; import { matchRoute, navigate } from '../router/router'; import { useRoute } from '../router/useRoute'; -import { Topbar } from '../components/Topbar'; +import { ScreenHeaderHost } from '../shell/ScreenHeader'; import { SettingsPanel } from '../components/SettingsPanel'; import { TracesScreen } from './traces/TracesScreen'; import { ThreadsScreen } from './threads/ThreadsScreen'; @@ -45,13 +45,16 @@ function TraceRoute() { return ; } -/** Every screen but Agents: the shell top bar over the screen's content. */ +/** + * Every screen but Agents: the shell's ScreenHeader (its h1 is the page's h1) over the + * screen's content. A screen may render its own `` to replace the default. + */ function ScreenFrame({ children }: { children: ReactNode }) { return (
-

Agent Forge

- -
{children}
+ +
{children}
+
); } diff --git a/apps/agent-forge/src/shell/AppShell.tsx b/apps/agent-forge/src/shell/AppShell.tsx index 3e62be792..5bc7c7c4f 100644 --- a/apps/agent-forge/src/shell/AppShell.tsx +++ b/apps/agent-forge/src/shell/AppShell.tsx @@ -1,9 +1,12 @@ import './shell.css'; +import { Menu } from 'lucide-react'; import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { NARROW_QUERY, useMediaQuery } from '../components/useMediaQuery'; import { useAppState } from '../state/AppState'; import { CommandPalette } from '../ui/CommandPalette'; +import { LogoMark } from './NavBrand'; import { NavRail } from './NavRail'; +import { useNavCollapsed } from './navCollapsed'; /** Lets the Studio side panels (rail/inspector drawers) yield when the nav drawer opens - one drawer at a time. */ const ShellContext = createContext({ navOpen: false }); @@ -20,10 +23,14 @@ function ConnectionBanner() { ); } -/** Left navigation around every screen; below 860px the nav is a slide-over drawer (scrim / Escape close it). */ +/** + * Left navigation around every screen. Docked, it collapses to a 56px icon rail + * (remembered in localStorage); below 860px it is a slide-over drawer (scrim / Escape close it). + */ export function AppShell({ children }: { children: ReactNode }) { const narrow = useMediaQuery(NARROW_QUERY); const [open, setOpen] = useState(false); + const [collapsed, toggleCollapsed] = useNavCollapsed(); useEffect(() => { if (!narrow) setOpen(false); @@ -39,7 +46,7 @@ export function AppShell({ children }: { children: ReactNode }) { const navOpen = narrow && open; return ( -
+
{narrow && (
+ Agent Forge
)} - setOpen(false)} /> + setOpen(false)} + /> {navOpen &&