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
7 changes: 5 additions & 2 deletions apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
21 changes: 21 additions & 0 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') });
});
53 changes: 52 additions & 1 deletion apps/agent-forge/e2e/studioUx.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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');
});
13 changes: 8 additions & 5 deletions apps/agent-forge/src/screens/routes.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,13 +45,16 @@ function TraceRoute() {
return <TraceScreen key={traceId} traceId={traceId} />;
}

/** 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 `<ScreenHeader … />` to replace the default.
*/
function ScreenFrame({ children }: { children: ReactNode }) {
return (
<div className="app app-screen">
<h1 className="visually-hidden">Agent Forge</h1>
<Topbar />
<main className="screen-main">{children}</main>
<ScreenHeaderHost>
<main className="screen-main">{children}</main>
</ScreenHeaderHost>
</div>
);
}
Expand Down
23 changes: 17 additions & 6 deletions apps/agent-forge/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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);
Expand All @@ -39,7 +46,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const navOpen = narrow && open;
return (
<ShellContext.Provider value={{ navOpen }}>
<div className={`app-shell${narrow ? ' app-shell-narrow' : ''}`}>
<div className={`app-shell${narrow ? ' app-shell-narrow' : ''}`} data-nav={!narrow && collapsed ? 'collapsed' : undefined}>
{narrow && (
<div className="app-shell-bar">
<button
Expand All @@ -50,14 +57,18 @@ export function AppShell({ children }: { children: ReactNode }) {
aria-controls="primary-nav"
onClick={() => setOpen((o) => !o)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<Menu size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
<LogoMark />
<span className="nav-bar-title">Agent Forge</span>
</div>
)}
<NavRail drawer={narrow ? (navOpen ? 'open' : 'closed') : undefined} onNavigate={() => setOpen(false)} />
<NavRail
drawer={narrow ? (navOpen ? 'open' : 'closed') : undefined}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
onNavigate={() => setOpen(false)}
/>
{navOpen && <div className="app-shell-scrim" aria-hidden="true" onClick={() => setOpen(false)} />}
<div className="app-shell-main">
<ConnectionBanner />
Expand Down
32 changes: 24 additions & 8 deletions apps/agent-forge/src/shell/DaemonStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ import { useEffect, useState } from 'react';
import { observeClient } from '../observe/observeClient';
import { useDaemonHealth } from '../observe/useObserve';

type CollectorState = 'up' | 'down' | 'checking';

const LABELS: Record<CollectorState, string> = {
up: 'Collector running',
down: 'Collector unreachable',
checking: 'Checking collector…',
};

/**
* The nav rail's collector line: whether the observe daemon behind the
* The nav rail's collector card: whether the observe daemon behind the
* `/observe` proxy answers, and where. "checking" only until the first
* health probe settles; the probe keeps polling, so it flips by itself when
* `lousho observe start` runs or the daemon dies.
* `lousho observe start` runs or the daemon dies. On the collapsed rail only
* the dot shows; the text stays as accessible text and the card's tooltip.
*/
export function DaemonStatus() {
const health = useDaemonHealth();
const up = health.data !== undefined;
const state: CollectorState = up ? 'up' : health.down ? 'down' : 'checking';
const [target, setTarget] = useState<string | undefined>();

// Re-read on every up-transition: the proxy target can change across a daemon restart.
Expand All @@ -25,12 +35,18 @@ export function DaemonStatus() {
};
}, [up]);

let text = 'Collector: checking…';
if (up) text = target === undefined ? 'Collector: running' : `Collector: running at ${target}`;
else if (health.down) text = 'Collector: not reachable';
const label = LABELS[state];
return (
<p className="nav-daemon" role="status" data-state={up ? 'up' : health.down ? 'down' : 'checking'}>
{text}
</p>
<div className="nav-daemon" role="status" data-state={state} title={target ? `${label} at ${target}` : label}>
<span className="nav-daemon-dot" aria-hidden="true" />
<span className="nav-daemon-text">
<span className="nav-daemon-label">{label}</span>
{target && (
<span className="nav-daemon-url" title={target}>
{target}
</span>
)}
</span>
</div>
);
}
42 changes: 42 additions & 0 deletions apps/agent-forge/src/shell/NavBrand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Anvil, PanelLeftClose, PanelLeftOpen } from 'lucide-react';

/** The Agent Forge logo mark: a small accent square with an anvil. Decorative; the wordmark or a label names it. */
export function LogoMark() {
return (
<span className="logo-mark" aria-hidden="true">
<Anvil size={16} strokeWidth={1.75} />
</span>
);
}

/**
* The nav header: logo mark, "Agent Forge" wordmark and the SDK version tag, plus the
* collapse toggle when docked (`onToggle` given). Collapsed, only the mark and the toggle show.
*/
export function NavBrand({ collapsed, onToggle }: { collapsed: boolean; onToggle?: () => void }) {
const Icon = collapsed ? PanelLeftOpen : PanelLeftClose;
return (
<div className="nav-brand">
<LogoMark />
{!collapsed && (
<span className="nav-wordmark">
Agent Forge{' '}
<span className="nav-version">v{__LOUSHO_VERSION__}</span>
</span>
)}
{onToggle && (
<button
type="button"
className="btn btn-ghost btn-icon btn-sm nav-collapse"
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
title={collapsed ? 'Expand navigation' : 'Collapse navigation'}
aria-expanded={!collapsed}
aria-controls="primary-nav"
onClick={onToggle}
>
<Icon size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
)}
</div>
);
}
Loading
Loading