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 && (
setOpen((o) => !o)}
>
-
-
-
+
+
Agent Forge
)}
-
setOpen(false)} />
+ setOpen(false)}
+ />
{navOpen && setOpen(false)} />}
diff --git a/apps/agent-forge/src/shell/DaemonStatus.tsx b/apps/agent-forge/src/shell/DaemonStatus.tsx
index 0467288d6..a0f86dc56 100644
--- a/apps/agent-forge/src/shell/DaemonStatus.tsx
+++ b/apps/agent-forge/src/shell/DaemonStatus.tsx
@@ -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
= {
+ 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();
// Re-read on every up-transition: the proxy target can change across a daemon restart.
@@ -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 (
-
- {text}
-
+
+
+
+ {label}
+ {target && (
+
+ {target}
+
+ )}
+
+
);
}
diff --git a/apps/agent-forge/src/shell/NavBrand.tsx b/apps/agent-forge/src/shell/NavBrand.tsx
new file mode 100644
index 000000000..f96debf0c
--- /dev/null
+++ b/apps/agent-forge/src/shell/NavBrand.tsx
@@ -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 (
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {!collapsed && (
+
+ Agent Forge{' '}
+ v{__LOUSHO_VERSION__}
+
+ )}
+ {onToggle && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/agent-forge/src/shell/NavRail.tsx b/apps/agent-forge/src/shell/NavRail.tsx
index dd2582656..d329e262a 100644
--- a/apps/agent-forge/src/shell/NavRail.tsx
+++ b/apps/agent-forge/src/shell/NavRail.tsx
@@ -1,5 +1,7 @@
+import { Activity, Bot, ClipboardCheck, FileText, FlaskConical, Library, LineChart, MessagesSquare, Settings, type LucideIcon } from 'lucide-react';
import { useRoute } from '../router/useRoute';
import { DaemonStatus } from './DaemonStatus';
+import { NavBrand } from './NavBrand';
export type NavSection = 'Build' | 'Observe' | 'Evaluate' | 'Assets' | 'Footer';
@@ -8,18 +10,20 @@ export interface NavItem {
label: string;
href: string;
section: NavSection;
+ /** The lucide icon drawn before the label (alone on the collapsed rail). */
+ icon: LucideIcon;
}
export const NAV_ITEMS: NavItem[] = [
- { id: 'agents', label: 'Agents', href: '#/agents', section: 'Build' },
- { id: 'playground', label: 'Playground', href: '#/playground', section: 'Build' },
- { id: 'traces', label: 'Traces', href: '#/traces', section: 'Observe' },
- { id: 'threads', label: 'Threads', href: '#/threads', section: 'Observe' },
- { id: 'monitoring', label: 'Monitoring', href: '#/monitoring', section: 'Observe' },
- { id: 'evals', label: 'Evaluations', href: '#/evals/scores', section: 'Evaluate' },
- { id: 'prompts', label: 'Prompts', href: '#/prompts', section: 'Assets' },
- { id: 'hub', label: 'Context Hub', href: '#/hub', section: 'Assets' },
- { id: 'settings', label: 'Settings', href: '#/settings', section: 'Footer' },
+ { id: 'agents', label: 'Agents', href: '#/agents', section: 'Build', icon: Bot },
+ { id: 'playground', label: 'Playground', href: '#/playground', section: 'Build', icon: FlaskConical },
+ { id: 'traces', label: 'Traces', href: '#/traces', section: 'Observe', icon: Activity },
+ { id: 'threads', label: 'Threads', href: '#/threads', section: 'Observe', icon: MessagesSquare },
+ { id: 'monitoring', label: 'Monitoring', href: '#/monitoring', section: 'Observe', icon: LineChart },
+ { id: 'evals', label: 'Evaluations', href: '#/evals/scores', section: 'Evaluate', icon: ClipboardCheck },
+ { id: 'prompts', label: 'Prompts', href: '#/prompts', section: 'Assets', icon: FileText },
+ { id: 'hub', label: 'Context Hub', href: '#/hub', section: 'Assets', icon: Library },
+ { id: 'settings', label: 'Settings', href: '#/settings', section: 'Footer', icon: Settings },
];
const SECTION_ORDER: NavSection[] = ['Build', 'Observe', 'Evaluate', 'Assets', 'Footer'];
@@ -32,45 +36,57 @@ export function isActive(item: NavItem, path: string): boolean {
return firstSegment(item.href.slice(1)) === segment;
}
+/** One nav link; on the collapsed rail it is icon-only, named by `aria-label` with a `title` tooltip. */
+function NavLink({ item, active, collapsed, onNavigate }: { item: NavItem; active: boolean; collapsed: boolean; onNavigate?: () => void }) {
+ const Icon = item.icon;
+ return (
+
+
+ {item.label}
+
+ );
+}
+
export function NavRail({
drawer,
+ collapsed = false,
+ onToggleCollapsed,
onNavigate,
}: {
/** Narrow layout: the rail is a slide-over drawer in this state. Undefined when docked. */
drawer?: 'open' | 'closed';
+ /** Docked only: the 56px icon rail. Ignored in the drawer. */
+ collapsed?: boolean;
+ /** Docked only: shows the collapse toggle in the nav header. */
+ onToggleCollapsed?: () => void;
onNavigate?: () => void;
}) {
const { path } = useRoute();
+ const rail = drawer === undefined && collapsed;
return (
-
- Agent Forge
- {SECTION_ORDER.map((section) => {
- const items = NAV_ITEMS.filter((i) => i.section === section);
- return (
-
- {section !== 'Footer' && (
-
- {section}
-
- )}
- {items.map((item) => {
- const active = isActive(item, path);
- return (
-
-
- {item.label}
-
-
- );
- })}
-
- );
- })}
+
+
+ {SECTION_ORDER.map((section) => (
+
+ {section !== 'Footer' && (
+
+ {rail ? null : section}
+
+ )}
+ {NAV_ITEMS.filter((i) => i.section === section).map((item) => (
+
+
+
+ ))}
+
+ ))}
);
diff --git a/apps/agent-forge/src/shell/ScreenHeader.tsx b/apps/agent-forge/src/shell/ScreenHeader.tsx
new file mode 100644
index 000000000..29ec95db9
--- /dev/null
+++ b/apps/agent-forge/src/shell/ScreenHeader.tsx
@@ -0,0 +1,72 @@
+import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { useRoute } from '../router/useRoute';
+import { useAppState } from '../state/AppState';
+import { PageHeader, type PageHeaderProps } from '../ui/PageHeader';
+import { StatusPill } from '../ui/StatusPill';
+import { NAV_ITEMS, isActive } from './NavRail';
+
+/** Props of {@link ScreenHeader}: PageHeader's, all optional. */
+export type ScreenHeaderProps = Partial;
+
+interface HeaderSlot {
+ /** Where an overriding ScreenHeader portals its PageHeader. */
+ target: HTMLElement | null;
+ /** Called on mount by an overriding ScreenHeader; the returned function releases the claim. */
+ claim: () => () => void;
+}
+
+const HeaderSlotContext = createContext(null);
+
+/** The PageHeader with the shell's defaults: the active nav item's label, and the live run status. */
+function DefaultedHeader({ title, status, ...rest }: ScreenHeaderProps) {
+ const { path } = useRoute();
+ const { runStatus } = useAppState();
+ const label = NAV_ITEMS.find((item) => isActive(item, path))?.label ?? 'Agent Forge';
+ return (
+ : status}
+ {...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
+ * ` ` 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).
+ * The props render through a portal, so the screen's own context and state apply.
+ */
+export function ScreenHeader(props: ScreenHeaderProps) {
+ const slot = useContext(HeaderSlotContext);
+ const claim = slot?.claim;
+ useLayoutEffect(() => claim?.(), [claim]);
+ if (!slot) return ;
+ return slot.target ? createPortal( , slot.target) : null;
+}
+
+/**
+ * Hosts the screen header for `children` (the screen): renders the default
+ * ScreenHeader until a ScreenHeader inside `children` claims the slot.
+ */
+export function ScreenHeaderHost({ children }: { children: ReactNode }) {
+ const [target, setTarget] = useState(null);
+ const [claims, setClaims] = useState(0);
+ const claim = useCallback(() => {
+ setClaims((n) => n + 1);
+ return () => setClaims((n) => n - 1);
+ }, []);
+ const slot = useMemo(() => ({ target, claim }), [target, claim]);
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx b/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx
index 4c827fba2..02cada5aa 100644
--- a/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx
+++ b/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx
@@ -8,6 +8,7 @@ vi.mock('../../state/AppState', () => ({ useAppState: () => appState }));
import { AppShell } from '../AppShell';
import { ToastProvider } from '../../ui/Toast';
import { NAV_ITEMS, isActive } from '../NavRail';
+import { breakStorage, installMemoryStorage } from '../../ui/__tests__/memoryStorage';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -39,13 +40,17 @@ function render() {
}
const nav = () => container.querySelector('nav[aria-label="Primary"]') as HTMLElement;
-const toggle = () => container.querySelector('button[aria-controls="primary-nav"]') as HTMLButtonElement | null;
+const toggle = () => container.querySelector('button[aria-label="Navigation"]') as HTMLButtonElement | null;
+const collapseToggle = () => container.querySelector('button.nav-collapse') as HTMLButtonElement | null;
+const shellEl = () => container.querySelector('.app-shell') as HTMLElement;
beforeEach(() => {
appState.connected = true;
window.location.hash = '#/traces';
+ installMemoryStorage();
});
afterEach(() => {
+ vi.restoreAllMocks();
act(() => root?.unmount());
container.remove();
root = undefined;
@@ -125,6 +130,66 @@ describe('AppShell', () => {
});
});
+describe('collapsible icon rail', () => {
+ it('toggles with aria-expanded and labels, and persists across a remount', () => {
+ stubNarrow(false);
+ render();
+ expect(nav().hasAttribute('data-collapsed')).toBe(false);
+ expect(collapseToggle()?.getAttribute('aria-expanded')).toBe('true');
+ expect(collapseToggle()?.getAttribute('aria-label')).toBe('Collapse navigation');
+ expect(collapseToggle()?.getAttribute('aria-controls')).toBe('primary-nav');
+ act(() => collapseToggle()!.click());
+ expect(nav().hasAttribute('data-collapsed')).toBe(true);
+ expect(shellEl().getAttribute('data-nav')).toBe('collapsed');
+ expect(collapseToggle()?.getAttribute('aria-expanded')).toBe('false');
+ expect(collapseToggle()?.getAttribute('aria-label')).toBe('Expand navigation');
+ expect(window.localStorage.getItem('forge.nav.collapsed')).toBe('1');
+
+ act(() => root?.unmount());
+ container.remove();
+ render();
+ expect(nav().hasAttribute('data-collapsed')).toBe(true);
+ act(() => collapseToggle()!.click());
+ expect(nav().hasAttribute('data-collapsed')).toBe(false);
+ expect(window.localStorage.getItem('forge.nav.collapsed')).toBe('0');
+ });
+
+ it('keeps every item reachable by its accessible name when collapsed', () => {
+ stubNarrow(false);
+ window.localStorage.setItem('forge.nav.collapsed', '1');
+ render();
+ expect(nav().hasAttribute('data-collapsed')).toBe(true);
+ for (const item of NAV_ITEMS) {
+ const link = nav().querySelector(`a[href="${item.href}"]`) as HTMLAnchorElement;
+ expect(link.getAttribute('aria-label')).toBe(item.label);
+ expect(link.getAttribute('title')).toBe(item.label);
+ }
+ expect(nav().querySelectorAll('.nav-heading')).toHaveLength(0);
+ expect(nav().querySelectorAll('.nav-divider')).toHaveLength(4);
+ expect(nav().querySelector('.nav-wordmark')).toBeNull();
+ expect(nav().querySelector('.logo-mark')).not.toBeNull();
+ });
+
+ it('is ignored in the narrow drawer, which has no collapse toggle', () => {
+ stubNarrow(true);
+ window.localStorage.setItem('forge.nav.collapsed', '1');
+ render();
+ expect(collapseToggle()).toBeNull();
+ expect(nav().hasAttribute('data-collapsed')).toBe(false);
+ expect(shellEl().hasAttribute('data-nav')).toBe(false);
+ expect(nav().querySelectorAll('.nav-heading')).toHaveLength(4);
+ });
+
+ it('survives storage that throws', () => {
+ stubNarrow(false);
+ breakStorage();
+ render();
+ expect(nav().hasAttribute('data-collapsed')).toBe(false);
+ act(() => collapseToggle()!.click());
+ expect(nav().hasAttribute('data-collapsed')).toBe(true);
+ });
+});
+
describe('isActive', () => {
const item = (id: string) => NAV_ITEMS.find((i) => i.id === id)!;
it('owns every route under its first segment; the bare / is Agents', () => {
diff --git a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx
index a23c84217..3feaf42ed 100644
--- a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx
+++ b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx
@@ -65,27 +65,39 @@ afterEach(() => {
});
describe('DaemonStatus', () => {
+ const card = () => container.querySelector('[role="status"]') as HTMLElement;
+ const label = () => card().querySelector('.nav-daemon-label')?.textContent;
+ const url = () => card().querySelector('.nav-daemon-url')?.textContent;
+
it('reads "checking" until the first health probe settles', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
await render( );
- expect(container.textContent).toBe('Collector: checking…');
+ expect(container.textContent).toBe('Checking collector…');
+ expect(card().getAttribute('data-state')).toBe('checking');
+ expect(card().getAttribute('title')).toBe('Checking collector…');
});
- it('reads "not reachable" while the daemon is down', async () => {
+ it('reads "unreachable" while the daemon is down', async () => {
stubProxy();
await render( );
await flush();
- expect(container.textContent).toBe('Collector: not reachable');
+ expect(container.textContent).toBe('Collector unreachable');
+ expect(card().getAttribute('data-state')).toBe('down');
+ expect(card().getAttribute('title')).toBe('Collector unreachable');
});
it('names the proxy target once the daemon answers, without a reload', async () => {
const { daemon } = stubProxy();
await render( );
await flush();
- expect(container.textContent).toBe('Collector: not reachable');
+ expect(label()).toBe('Collector unreachable');
daemon.up = true;
await poll();
- expect(container.textContent).toBe(`Collector: running at ${TARGET}`);
+ expect(label()).toBe('Collector running');
+ expect(card().getAttribute('data-state')).toBe('up');
+ expect(url()).toBe(TARGET);
+ expect(card().getAttribute('title')).toBe(`Collector running at ${TARGET}`);
+ expect(card().querySelector('.nav-daemon-url')?.getAttribute('title')).toBe(TARGET);
});
it('stays "not reachable" while a re-probe is in flight (no flicker)', async () => {
@@ -99,7 +111,7 @@ describe('DaemonStatus', () => {
await act(async () => {
vi.advanceTimersByTime(5000);
});
- expect(container.textContent).toBe('Collector: not reachable');
+ expect(label()).toBe('Collector unreachable');
await act(async () => release());
expect(daemon.up).toBe(false);
});
diff --git a/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx b/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx
new file mode 100644
index 000000000..42f463cc6
--- /dev/null
+++ b/apps/agent-forge/src/shell/__tests__/ScreenHeader.test.tsx
@@ -0,0 +1,88 @@
+import { act, useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from '../../ui/__tests__/render';
+
+const appState = vi.hoisted(() => ({ runStatus: { status: 'running' } as { status: string } | null }));
+vi.mock('../../state/AppState', () => ({ useAppState: () => appState }));
+
+import { ScreenHeader, ScreenHeaderHost } from '../ScreenHeader';
+
+const h1s = () => [...document.querySelectorAll('h1')].map((h) => h.textContent);
+
+/** A screen that shows its own header while `on`, with a counter proving its state reaches the portal. */
+function Screen({ initial }: { initial: boolean }) {
+ const [on, setOn] = useState(initial);
+ const [count, setCount] = useState(0);
+ return (
+
+ setOn((v) => !v)}>
+ toggle
+
+ {on && (
+ setCount((n) => n + 1)}>
+ Clicked {count}
+
+ }
+ />
+ )}
+
+ );
+}
+
+describe('ScreenHeader', () => {
+ it('defaults to the active nav label and the live run status', () => {
+ window.location.hash = '#/evals/datasets';
+ appState.runStatus = { status: 'running' };
+ render(
+
+
+ ,
+ );
+ expect(h1s()).toEqual(['Evaluations']);
+ const status = document.querySelector('.page-header-status [role="status"][aria-live="polite"]');
+ expect(status?.textContent).toContain('running');
+ expect(document.querySelector('header .page-header-status')).not.toBeNull();
+ });
+
+ it('a nested ScreenHeader replaces the default; unmounting it restores the default', () => {
+ window.location.hash = '#/evals/datasets';
+ const el = render(
+
+
+ ,
+ );
+ expect(h1s()).toEqual(['Datasets']);
+ expect(document.querySelectorAll('header.page-header')).toHaveLength(1);
+ // Rendered in the header slot (outside main), with the screen's own props.
+ 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();
+ expect(el.querySelector('main header')).toBeNull();
+
+ // The screen's state still drives the portalled actions.
+ act(() => (document.getElementById('action') as HTMLButtonElement).click());
+ expect(document.getElementById('action')?.textContent).toBe('Clicked 1');
+
+ act(() => (document.getElementById('toggle') as HTMLButtonElement).click());
+ expect(h1s()).toEqual(['Evaluations']);
+ expect(document.querySelectorAll('header.page-header')).toHaveLength(1);
+
+ act(() => (document.getElementById('toggle') as HTMLButtonElement).click());
+ expect(h1s()).toEqual(['Datasets']);
+ });
+
+ it('renders inline outside a host', () => {
+ window.location.hash = '#/traces';
+ appState.runStatus = null;
+ render( );
+ expect(h1s()).toEqual(['Traces']);
+ expect(document.querySelector('.page-header-status')?.textContent).toContain('idle');
+ });
+});
diff --git a/apps/agent-forge/src/shell/navCollapsed.ts b/apps/agent-forge/src/shell/navCollapsed.ts
new file mode 100644
index 000000000..43485198f
--- /dev/null
+++ b/apps/agent-forge/src/shell/navCollapsed.ts
@@ -0,0 +1,29 @@
+import { useCallback, useState } from 'react';
+
+/** localStorage key for the docked nav's icon-rail state: "1" collapsed, "0" expanded. */
+const NAV_COLLAPSED_KEY = 'forge.nav.collapsed';
+
+function readCollapsed(): boolean {
+ try {
+ return window.localStorage.getItem(NAV_COLLAPSED_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+/** Whether the docked nav is the 56px icon rail, remembered across reloads (storage failures just forget). */
+export function useNavCollapsed(): [boolean, () => void] {
+ const [collapsed, setCollapsed] = useState(readCollapsed);
+ const toggle = useCallback(() => {
+ setCollapsed((was) => {
+ const next = !was;
+ try {
+ window.localStorage.setItem(NAV_COLLAPSED_KEY, next ? '1' : '0');
+ } catch {
+ // Private mode / blocked storage: the toggle still works for this page.
+ }
+ return next;
+ });
+ }, []);
+ return [collapsed, toggle];
+}
diff --git a/apps/agent-forge/src/shell/shell.css b/apps/agent-forge/src/shell/shell.css
index 478f0244e..06a1bd0a2 100644
--- a/apps/agent-forge/src/shell/shell.css
+++ b/apps/agent-forge/src/shell/shell.css
@@ -1,9 +1,18 @@
-/* Left-navigation app shell. Tokens from theme.css only. */
+/*
+ * Left-navigation app shell. Tokens from theme.css only.
+ * --page-gutter: the horizontal page gutter every screen aligns to (PageHeader uses it;
+ * screens' content padding adopts it in the page tickets). 24px docked, 16px narrow.
+ */
.app-shell {
+ --page-gutter: 24px;
height: 100%;
display: grid;
- grid-template-columns: 208px minmax(0, 1fr);
+ grid-template-columns: 232px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
+ transition: grid-template-columns var(--dur) var(--ease);
+}
+.app-shell[data-nav='collapsed'] {
+ grid-template-columns: 56px minmax(0, 1fr);
}
.app-shell-main {
min-width: 0;
@@ -16,66 +25,132 @@
min-height: 0;
height: auto;
}
+/* The ScreenHeader row sizes itself (PageHeader is 56px+, taller with crumbs/description). */
+.app-shell-main > .app-screen {
+ grid-template-rows: auto minmax(0, 1fr);
+}
+.screen-header-target {
+ display: contents;
+}
.nav-rail {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
- padding: 12px 8px;
- gap: 4px;
+ padding: var(--space-3) var(--space-2);
+ gap: var(--space-1);
+ overflow-x: hidden;
overflow-y: auto;
min-height: 0;
+ min-width: 0;
}
.nav-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 0 var(--space-1) var(--space-3) 6px;
+ min-height: 28px;
+}
+.nav-wordmark {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
font-weight: 600;
font-size: 14px;
- padding: 4px 10px 12px;
+ line-height: 1.2;
color: var(--text);
+ white-space: nowrap;
+}
+.nav-version {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font: 400 11px/1.3 var(--font-mono);
+ color: var(--text-faint);
+}
+.nav-collapse {
+ flex: none;
+ margin-left: auto;
+}
+.logo-mark {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--radius-sm);
+ background: var(--accent-solid);
+ color: var(--on-accent);
}
.nav-rail ul {
list-style: none;
margin: 0;
padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
}
.nav-group {
- margin-bottom: 8px;
+ margin-bottom: var(--space-2);
}
.nav-heading {
- font-size: 11px;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--text-faint);
padding: 6px 10px 4px;
}
-.nav-footer {
+.nav-divider {
+ height: 1px;
+ margin: var(--space-1) var(--space-2) 6px;
+ background: var(--border);
+}
+.nav-rail .nav-footer {
margin-top: auto;
border-top: 1px solid var(--border);
- padding-top: 8px;
-}
-.nav-daemon {
- margin: 0;
- padding: 4px 10px;
- font-size: 11px;
- color: var(--text-faint);
- overflow-wrap: anywhere;
+ padding-top: var(--space-2);
}
.nav-item {
- display: block;
- padding: 7px 10px;
- border-radius: 6px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 32px;
+ padding: 0 10px;
+ border-radius: var(--radius);
font-size: 13px;
+ font-weight: 500;
color: var(--text-muted);
text-decoration: none;
+ white-space: nowrap;
+ transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
+}
+.nav-icon {
+ flex: none;
+}
+.nav-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.nav-item:hover {
background: var(--surface-2);
color: var(--text);
}
.nav-item.active {
- background: var(--accent-soft);
+ background: light-dark(var(--surface-3), var(--surface-2));
+ color: var(--text);
+}
+.nav-item.active .nav-icon {
color: var(--accent);
- font-weight: 600;
+}
+.nav-item.active::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 7px;
+ bottom: 7px;
+ width: 2px;
+ border-radius: var(--radius-full);
+ background: var(--accent);
}
.nav-item:focus-visible,
.nav-toggle:focus-visible {
@@ -83,6 +158,82 @@
outline-offset: 2px;
}
+/* Collector status card, pinned under Settings. */
+.nav-daemon {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ margin: var(--space-1) 0 0;
+ padding: var(--space-2) 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ min-width: 0;
+}
+.nav-daemon-dot {
+ flex: none;
+ width: 6px;
+ height: 6px;
+ margin-top: 6px;
+ border-radius: var(--radius-full);
+ background: var(--text-faint);
+}
+.nav-daemon[data-state='up'] .nav-daemon-dot {
+ background: var(--success);
+}
+.nav-daemon[data-state='down'] .nav-daemon-dot {
+ background: var(--danger);
+}
+.nav-daemon-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.nav-daemon-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text);
+}
+.nav-daemon-url {
+ font: 400 11px var(--font-mono);
+ color: var(--text-faint);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Collapsed icon rail (docked only): icons centred, text kept for assistive tech. */
+.nav-rail[data-collapsed] .nav-brand {
+ flex-direction: column;
+ padding: 0 0 var(--space-3);
+}
+.nav-rail[data-collapsed] .nav-collapse {
+ margin-left: 0;
+}
+.nav-rail[data-collapsed] .nav-item {
+ justify-content: center;
+ padding: 0;
+}
+.nav-rail[data-collapsed] .nav-label {
+ display: none;
+}
+.nav-rail[data-collapsed] .nav-daemon {
+ justify-content: center;
+ padding: var(--space-2) 0;
+}
+.nav-rail[data-collapsed] .nav-daemon-dot {
+ margin-top: 0;
+}
+.nav-rail[data-collapsed] .nav-daemon-text {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+
.app-shell-bar {
display: flex;
align-items: center;
@@ -99,6 +250,7 @@
/* Narrow: the nav is a slide-over drawer (same pattern as the Studio side panels). */
.app-shell-narrow {
+ --page-gutter: 16px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
position: relative;
@@ -121,12 +273,12 @@
box-shadow: var(--shadow);
transform: translateX(-100%);
visibility: hidden;
- transition: transform 0.18s ease, visibility 0s linear 0.18s;
+ transition: transform var(--dur) var(--ease), visibility 0s linear var(--dur);
}
.app-shell-narrow .nav-rail[data-drawer='open'] {
transform: none;
visibility: visible;
- transition: transform 0.18s ease;
+ transition: transform var(--dur) var(--ease);
}
.app-shell-scrim {
position: absolute;
@@ -134,3 +286,12 @@
z-index: 35;
background: rgba(10, 12, 18, 0.35);
}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-shell,
+ .nav-item,
+ .app-shell-narrow .nav-rail,
+ .app-shell-narrow .nav-rail[data-drawer='open'] {
+ transition: none;
+ }
+}
diff --git a/apps/agent-forge/src/shell/version.d.ts b/apps/agent-forge/src/shell/version.d.ts
new file mode 100644
index 000000000..c7eea2d59
--- /dev/null
+++ b/apps/agent-forge/src/shell/version.d.ts
@@ -0,0 +1,2 @@
+/** The lousho SDK version (root package.json `version`), injected by Vite `define` (vite.config.ts). */
+declare const __LOUSHO_VERSION__: string;
diff --git a/apps/agent-forge/src/ui/PageHeader.tsx b/apps/agent-forge/src/ui/PageHeader.tsx
new file mode 100644
index 000000000..92047f530
--- /dev/null
+++ b/apps/agent-forge/src/ui/PageHeader.tsx
@@ -0,0 +1,70 @@
+import './pageHeader.css';
+import { ChevronRight } from 'lucide-react';
+import type { ReactNode } from 'react';
+
+/** One breadcrumb above a page title; without `href` it renders as plain text. */
+export interface PageHeaderCrumb {
+ label: string;
+ href?: string;
+}
+
+/** Props of {@link PageHeader}. */
+export interface PageHeaderProps {
+ /** The page's ``. A string title truncates with an ellipsis and keeps the full text as a tooltip. */
+ title: ReactNode;
+ /** One muted line under the title. */
+ description?: ReactNode;
+ /** Trail above the title; the last crumb is the current page (`aria-current="page"`). */
+ breadcrumbs?: PageHeaderCrumb[];
+ /** Inline right of the title, e.g. a StatusPill. */
+ status?: ReactNode;
+ /** Right side of the header (buttons); wraps under the title at narrow widths. */
+ actions?: ReactNode;
+ className?: string;
+}
+
+function Breadcrumbs({ crumbs }: { crumbs: PageHeaderCrumb[] }) {
+ return (
+
+
+ {crumbs.map((crumb, i) => {
+ const last = i === crumbs.length - 1;
+ return (
+
+ {i > 0 && }
+ {crumb.href && !last ? (
+ {crumb.label}
+ ) : (
+ {crumb.label}
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * The header row at the top of a page: optional breadcrumbs, the page title (``)
+ * with an optional status beside it, an optional description, and right-aligned
+ * actions, over a bottom hairline. Purely presentational; in the Studio, screens
+ * use the shell's `ScreenHeader`, which fills in the defaults and places it.
+ */
+export function PageHeader({ title, description, breadcrumbs, status, actions, className }: PageHeaderProps) {
+ return (
+
+ );
+}
diff --git a/apps/agent-forge/src/ui/__tests__/PageHeader.test.tsx b/apps/agent-forge/src/ui/__tests__/PageHeader.test.tsx
new file mode 100644
index 000000000..9e1531ea1
--- /dev/null
+++ b/apps/agent-forge/src/ui/__tests__/PageHeader.test.tsx
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { render } from './render';
+import { PageHeader } from '../PageHeader';
+
+describe('PageHeader', () => {
+ it('renders the title as the h1, with no optional parts by default', () => {
+ const el = render( );
+ const h1 = el.querySelector('header.page-header h1');
+ expect(h1?.textContent).toBe('Traces');
+ expect(h1?.getAttribute('title')).toBe('Traces');
+ expect(el.querySelector('nav')).toBeNull();
+ expect(el.querySelector('.page-header-description')).toBeNull();
+ expect(el.querySelector('.page-header-status')).toBeNull();
+ expect(el.querySelector('.page-header-actions')).toBeNull();
+ });
+
+ it('renders breadcrumbs (last is the current page), description, status and actions', () => {
+ const el = render(
+ Dataset one}
+ description="Examples to evaluate against"
+ breadcrumbs={[{ label: 'Evaluations', href: '#/evals/scores' }, { label: 'Datasets', href: '#/evals/datasets' }, { label: 'one' }]}
+ status={ok }
+ actions={New example }
+ />,
+ );
+ expect(el.querySelector('header')?.className).toBe('page-header extra');
+ const crumbs = el.querySelector('nav[aria-label="Breadcrumb"]')!;
+ expect([...crumbs.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Evaluations', 'Datasets', 'one']);
+ expect([...crumbs.querySelectorAll('a')].map((a) => a.getAttribute('href'))).toEqual(['#/evals/scores', '#/evals/datasets']);
+ expect(crumbs.querySelector('[aria-current="page"]')?.textContent).toBe('one');
+ expect(crumbs.querySelectorAll('svg[aria-hidden="true"]')).toHaveLength(2);
+ // A non-string title gets no tooltip.
+ expect(el.querySelector('h1')?.hasAttribute('title')).toBe(false);
+ expect(el.querySelector('.page-header-description')?.textContent).toBe('Examples to evaluate against');
+ expect(el.querySelector('.page-header-status #pill')).not.toBeNull();
+ expect(el.querySelector('.page-header-actions button')?.textContent).toBe('New example');
+ });
+
+ it('a last crumb with an href is still the current page, not a link', () => {
+ const el = render( );
+ expect(el.querySelector('nav a')).toBeNull();
+ expect(el.querySelector('nav [aria-current="page"]')?.textContent).toBe('Prompts');
+ });
+});
diff --git a/apps/agent-forge/src/ui/pageHeader.css b/apps/agent-forge/src/ui/pageHeader.css
new file mode 100644
index 000000000..a3588cd24
--- /dev/null
+++ b/apps/agent-forge/src/ui/pageHeader.css
@@ -0,0 +1,89 @@
+/* PageHeader: title row at the top of a page. Tokens from theme.css only. */
+.page-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2) var(--space-4);
+ min-height: 56px;
+ padding: var(--space-2) var(--page-gutter, 24px);
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ box-sizing: border-box;
+}
+.page-header-main {
+ flex: 1 1 240px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.page-header-title-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.page-header-title {
+ margin: 0;
+ min-width: 0;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.page-header-status {
+ flex: none;
+ display: flex;
+ align-items: center;
+}
+.page-header-description {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+.page-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ margin-left: auto;
+}
+.page-header-crumbs ol {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.page-header-crumbs li {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+}
+.page-header-crumbs a {
+ color: var(--text-muted);
+ text-decoration: none;
+ border-radius: var(--radius-sm);
+}
+.page-header-crumbs a:hover {
+ color: var(--text);
+}
+.page-header-crumbs a:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.page-header-crumbs [aria-current='page'] {
+ color: var(--text);
+}
+.page-header-crumb-sep {
+ margin: 0 var(--space-1);
+ color: var(--text-faint);
+}
+
diff --git a/apps/agent-forge/vite.config.ts b/apps/agent-forge/vite.config.ts
index 1ac1a0d27..1fc467821 100644
--- a/apps/agent-forge/vite.config.ts
+++ b/apps/agent-forge/vite.config.ts
@@ -1,9 +1,14 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
+import { readFileSync } from 'node:fs';
+
+/** The lousho SDK version (root package.json), shown as the nav's version tag (src/shell/version.d.ts). */
+const LOUSHO_VERSION = (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string }).version;
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ define: { __LOUSHO_VERSION__: JSON.stringify(LOUSHO_VERSION) },
server: {
// `lousho studio --dev` prints http://localhost:5173/?token=...
// and tells the API server to accept this origin, so the port is fixed.
diff --git a/docs/agent-forge.md b/docs/agent-forge.md
index 480499ddb..aa042e83f 100644
--- a/docs/agent-forge.md
+++ b/docs/agent-forge.md
@@ -144,9 +144,13 @@ The left navigation lists every screen, grouped:
| Assets | **Context Hub** | Shared key-values agents publish to the daemon. |
| | **Settings** | Provider keys, settings profiles and deploy. |
-Off the Agents screen the top bar names the screen and keeps the live run
-status pill, nothing else. The last nav item is the collector status (see
-[The collector](#the-collector-observe-daemon)).
+Off the Agents screen a page header names the screen and keeps the live run
+status pill. Under Settings, a small card shows the collector status (see
+[The collector](#the-collector-observe-daemon)). The button beside the
+**Agent Forge** name at the top of the nav (**Collapse navigation**)
+shrinks it to a rail of icons; hover an icon for its name. The choice is
+remembered across reloads. Below 860px wide the nav is always the full
+drawer.
### Routes and deep links
@@ -226,7 +230,7 @@ token on every read and write (as run reporting and the judge evaluators do),
so the screens also work against a daemon started with `--require-token`.
Without it, such a daemon answers `401` to every `/api/` read and the screens
show that error instead of data (`/health` still answers, so the collector
-item says running). The browser's own credentials (the Studio token, cookies,
+card says running). The browser's own credentials (the Studio token, cookies,
`Authorization`) are never passed on. The proxy only forwards reads plus the
writes the screens make (feedback scores, new prompt versions from the
Playground, prompt label moves, hub entry edits, dataset and example writes,
@@ -234,9 +238,9 @@ annotation queue writes, evaluator rule writes); every other write, ingest
above all, is refused with `405`. It always calls the daemon at
`LOUSHO_OBSERVE_URL`, whatever host the request line names.
-The last nav item says whether the daemon answers: **Collector: checking…**
-until the first probe, **Collector: running at ``**, or **Collector:
-not reachable**. The probe keeps polling. While the daemon is down, each
+The collector card at the bottom of the nav says whether the daemon answers:
+**Checking collector…** until the first probe, then **Collector running**
+or **Collector unreachable**, with the daemon URL underneath. The probe keeps polling. While the daemon is down, each
daemon screen shows the command to start it instead of an error, keeps its
URL, and swaps the data back in by itself once the daemon answers. The
Agents screen and Playground runs do not need the daemon.
diff --git a/llms-full.txt b/llms-full.txt
index 2bbd5c117..0af62f561 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -2047,9 +2047,13 @@ The left navigation lists every screen, grouped:
| Assets | **Context Hub** | Shared key-values agents publish to the daemon. |
| | **Settings** | Provider keys, settings profiles and deploy. |
-Off the Agents screen the top bar names the screen and keeps the live run
-status pill, nothing else. The last nav item is the collector status (see
-[The collector](#the-collector-observe-daemon)).
+Off the Agents screen a page header names the screen and keeps the live run
+status pill. Under Settings, a small card shows the collector status (see
+[The collector](#the-collector-observe-daemon)). The button beside the
+**Agent Forge** name at the top of the nav (**Collapse navigation**)
+shrinks it to a rail of icons; hover an icon for its name. The choice is
+remembered across reloads. Below 860px wide the nav is always the full
+drawer.
### Routes and deep links
@@ -2127,7 +2131,7 @@ token on every read and write (as run reporting and the judge evaluators do),
so the screens also work against a daemon started with `--require-token`.
Without it, such a daemon answers `401` to every `/api/` read and the screens
show that error instead of data (`/health` still answers, so the collector
-item says running). The browser's own credentials (the Studio token, cookies,
+card says running). The browser's own credentials (the Studio token, cookies,
`Authorization`) are never passed on. The proxy only forwards reads plus the
writes the screens make (feedback scores, new prompt versions from the
Playground, prompt label moves, hub entry edits, dataset and example writes,
@@ -2135,9 +2139,9 @@ annotation queue writes, evaluator rule writes); every other write, ingest
above all, is refused with `405`. It always calls the daemon at
`LOUSHO_OBSERVE_URL`, whatever host the request line names.
-The last nav item says whether the daemon answers: **Collector: checking…**
-until the first probe, **Collector: running at ``**, or **Collector:
-not reachable**. The probe keeps polling. While the daemon is down, each
+The collector card at the bottom of the nav says whether the daemon answers:
+**Checking collector…** until the first probe, then **Collector running**
+or **Collector unreachable**, with the daemon URL underneath. The probe keeps polling. While the daemon is down, each
daemon screen shows the command to start it instead of an error, keeps its
URL, and swaps the data back in by itself once the daemon answers. The
Agents screen and Playground runs do not need the daemon.