From 3e4253f391f7455ff9e066b11d695cd58ad1f342 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 24 Oct 2026 17:00:13 +0300 Subject: [PATCH] feat(studio): app shell with left navigation Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/a11y.spec.ts | 3 +- apps/agent-forge/src/App.tsx | 39 ++--- apps/agent-forge/src/components/Topbar.tsx | 11 +- .../src/router/__tests__/router.test.ts | 23 +++ apps/agent-forge/src/router/useRoute.ts | 9 +- apps/agent-forge/src/shell/AppShell.tsx | 68 +++++++++ apps/agent-forge/src/shell/NavRail.tsx | 75 ++++++++++ .../src/shell/__tests__/AppShell.test.tsx | 134 ++++++++++++++++++ apps/agent-forge/src/shell/shell.css | 129 +++++++++++++++++ 9 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 apps/agent-forge/src/shell/AppShell.tsx create mode 100644 apps/agent-forge/src/shell/NavRail.tsx create mode 100644 apps/agent-forge/src/shell/__tests__/AppShell.test.tsx create mode 100644 apps/agent-forge/src/shell/shell.css diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index c58856cf..1eebaf7d 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -12,7 +12,8 @@ test.beforeEach(async ({ page }) => { test('the page has landmarks, an h1 and a live run status', async ({ page }) => { await expect(page.getByRole('banner')).toHaveCount(1); - await expect(page.getByRole('navigation')).toHaveCount(1); + await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1); + await expect(page.getByRole('navigation', { name: 'Agents and node palette' })).toHaveCount(1); await expect(page.getByRole('main')).toHaveCount(1); await expect(page.getByRole('complementary', { name: 'Inspector' })).toHaveCount(1); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Agent Forge'); diff --git a/apps/agent-forge/src/App.tsx b/apps/agent-forge/src/App.tsx index 170c124e..71e3d974 100644 --- a/apps/agent-forge/src/App.tsx +++ b/apps/agent-forge/src/App.tsx @@ -10,6 +10,12 @@ import { BottomDrawer } from './components/BottomDrawer'; import { ErrorBoundary } from './components/ErrorBoundary'; import { NARROW_QUERY, useMediaQuery } from './components/useMediaQuery'; import { ObserveView } from './observe/ObserveView'; +import { AppShell, useShell } from './shell/AppShell'; +import { useRoute } from './router/useRoute'; + +/** Until the route table lands, these hash routes show the existing observability panels. */ +const OBSERVE_ROUTES = new Set(['traces', 'monitoring', 'evals']); +const isObservePath = (path: string) => OBSERVE_ROUTES.has(path.split('/')[1] ?? ''); /** * Below 860px the rail and the Inspector are slide-over drawers, @@ -18,6 +24,12 @@ import { ObserveView } from './observe/ObserveView'; function useSidePanels() { const narrow = useMediaQuery(NARROW_QUERY); const [open, setOpen] = useState(undefined); + const { navOpen } = useShell(); + + // One drawer at a time: opening the nav drawer closes a side panel. + useEffect(() => { + if (navOpen) setOpen(undefined); + }, [navOpen]); useEffect(() => { if (!narrow) setOpen(undefined); @@ -34,28 +46,22 @@ function useSidePanels() { return { narrow, open, toggle, close: () => setOpen(undefined) }; } -/** Says so when the live connection to the studio server drops, instead of going quietly stale. */ -function ConnectionBanner() { - const { connected } = useAppState(); - if (connected) return null; - return ( -
- Lost the connection to the studio server - reconnecting. Is lousho studio still running? -
- ); -} - function Studio() { const panels = useSidePanels(); - const { view } = useAppState(); + const { setView } = useAppState(); + const { path } = useRoute(); + const observePath = isObservePath(path); + // Keeps the (still-present) Build/Observe state in step with the route; the route decides what renders. + useEffect(() => { + setView(observePath ? 'observe' : 'build'); + }, [observePath, setView]); const shellClass = `shell${panels.open ? ` ${panels.open}-open` : ''}`; // Observe mode: the daemon-backed panels take the whole content area // (builder rail/canvas/inspector and the run drawer are build-mode chrome). - if (view === 'observe') { + if (observePath) { return (

Agent Forge

-
@@ -64,7 +70,6 @@ function Studio() { return (

Agent Forge

-
@@ -83,7 +88,9 @@ export function App() { {/* Shared by the canvas and the rail's palette (click/keyboard add at the viewport centre). */} - + + + diff --git a/apps/agent-forge/src/components/Topbar.tsx b/apps/agent-forge/src/components/Topbar.tsx index db299a21..c27d0484 100644 --- a/apps/agent-forge/src/components/Topbar.tsx +++ b/apps/agent-forge/src/components/Topbar.tsx @@ -4,6 +4,7 @@ import { downloadSpec, importSpecFile } from '../persistence/importExport'; import { ApprovalCard } from './ApprovalCard'; import { errorMessage } from './errorMessage'; import { StatusPill } from './StatusPill'; +import { navigate } from '../router/router'; function BrandMark() { return ( @@ -94,7 +95,10 @@ function ViewToggle() { type="button" className={`btn btn-ghost${view === 'build' ? ' active' : ''}`} aria-pressed={view === 'build'} - onClick={() => setView('build')} + onClick={() => { + setView('build'); + navigate('/agents'); + }} > Build @@ -102,7 +106,10 @@ function ViewToggle() { type="button" className={`btn btn-ghost${view === 'observe' ? ' active' : ''}`} aria-pressed={view === 'observe'} - onClick={() => setView('observe')} + onClick={() => { + setView('observe'); + navigate('/traces'); + }} > Observe diff --git a/apps/agent-forge/src/router/__tests__/router.test.ts b/apps/agent-forge/src/router/__tests__/router.test.ts index 5e2c0187..95e0fb90 100644 --- a/apps/agent-forge/src/router/__tests__/router.test.ts +++ b/apps/agent-forge/src/router/__tests__/router.test.ts @@ -109,3 +109,26 @@ describe('useQueryState', () => { expect(result.current[0]).toBe(1); }); }); + +describe('useQueryState setter identity', () => { + it('stays referentially stable across re-renders with inline parse/format lambdas', async () => { + navigate('/traces'); + await tick(); + const { result } = renderHook(() => + useQueryState( + 'page', + (s) => (s === null ? 1 : Number(s)), + (v) => (v === 1 ? null : String(v)), + ), + ); + const first = result.current[1]; + act(() => first(2)); + await act(tick); + expect(result.current[0]).toBe(2); + expect(result.current[1]).toBe(first); + // The latest format is still used by the stable setter. + act(() => first(1)); + await act(tick); + expect(window.location.hash).toBe('#/traces'); + }); +}); diff --git a/apps/agent-forge/src/router/useRoute.ts b/apps/agent-forge/src/router/useRoute.ts index 12345c18..7847b824 100644 --- a/apps/agent-forge/src/router/useRoute.ts +++ b/apps/agent-forge/src/router/useRoute.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from 'react'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; import { navigate, parseHash, type Route } from './router'; let cachedHash: string | null = null; @@ -31,18 +31,21 @@ export function useQueryState( format: (v: T) => string | null, ): [T, (v: T) => void] { const route = useRoute(); + // Held in a ref so the setter keeps its identity when callers pass inline lambdas. + const formatRef = useRef(format); + formatRef.current = format; const value = parse(route.query.get(key)); const set = useCallback( (v: T) => { const { path, query } = parseHash(window.location.hash); const next = new URLSearchParams(query); - const s = format(v); + const s = formatRef.current(v); if (s === null) next.delete(key); else next.set(key, s); const qs = next.toString(); navigate(qs ? `${path}?${qs}` : path, { replace: true }); }, - [key, format], + [key], ); return [value, set]; } diff --git a/apps/agent-forge/src/shell/AppShell.tsx b/apps/agent-forge/src/shell/AppShell.tsx new file mode 100644 index 00000000..cfdc2cf0 --- /dev/null +++ b/apps/agent-forge/src/shell/AppShell.tsx @@ -0,0 +1,68 @@ +import './shell.css'; +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { NARROW_QUERY, useMediaQuery } from '../components/useMediaQuery'; +import { useAppState } from '../state/AppState'; +import { NavRail } from './NavRail'; + +/** Lets the Studio side panels (rail/inspector drawers) yield when the nav drawer opens - one drawer at a time. */ +const ShellContext = createContext({ navOpen: false }); +export const useShell = () => useContext(ShellContext); + +/** Says so when the live connection to the studio server drops, instead of going quietly stale. */ +function ConnectionBanner() { + const { connected } = useAppState(); + if (connected) return null; + return ( +
+ Lost the connection to the studio server - reconnecting. Is lousho studio still running? +
+ ); +} + +/** Left navigation around every screen; below 860px the nav 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); + + useEffect(() => { + if (!narrow) setOpen(false); + }, [narrow]); + + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false); + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open]); + + const navOpen = narrow && open; + return ( + +
+ {narrow && ( +
+ + Agent Forge +
+ )} + setOpen(false)} /> + {navOpen && + + ); +} diff --git a/apps/agent-forge/src/shell/NavRail.tsx b/apps/agent-forge/src/shell/NavRail.tsx new file mode 100644 index 00000000..c7b0bbce --- /dev/null +++ b/apps/agent-forge/src/shell/NavRail.tsx @@ -0,0 +1,75 @@ +import { useRoute } from '../router/useRoute'; + +export type NavSection = 'Build' | 'Observe' | 'Evaluate' | 'Assets' | 'Footer'; + +export interface NavItem { + id: string; + label: string; + href: string; + section: NavSection; +} + +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' }, +]; + +const SECTION_ORDER: NavSection[] = ['Build', 'Observe', 'Evaluate', 'Assets', 'Footer']; + +const firstSegment = (path: string) => path.split('/')[1] ?? ''; + +/** An item owns every route under its first path segment (`/evals/datasets` -> Evaluations). The bare `/` is Agents. */ +export function isActive(item: NavItem, path: string): boolean { + const segment = firstSegment(path) || 'agents'; + return firstSegment(item.href.slice(1)) === segment; +} + +export function NavRail({ + drawer, + onNavigate, +}: { + /** Narrow layout: the rail is a slide-over drawer in this state. Undefined when docked. */ + drawer?: 'open' | 'closed'; + onNavigate?: () => void; +}) { + const { path } = useRoute(); + return ( + + ); +} diff --git a/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx b/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx new file mode 100644 index 00000000..2696dded --- /dev/null +++ b/apps/agent-forge/src/shell/__tests__/AppShell.test.tsx @@ -0,0 +1,134 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const appState = vi.hoisted(() => ({ connected: true })); +vi.mock('../../state/AppState', () => ({ useAppState: () => appState })); + +import { AppShell } from '../AppShell'; +import { NAV_ITEMS, isActive } from '../NavRail'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; +let container: HTMLDivElement; + +function stubNarrow(narrow: boolean) { + window.matchMedia = ((query: string) => ({ + matches: narrow, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; +} + +function render() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root!.render( + +

screen

+
, + ), + ); +} + +const nav = () => container.querySelector('nav[aria-label="Primary"]') as HTMLElement; +const toggle = () => container.querySelector('button[aria-controls="primary-nav"]') as HTMLButtonElement | null; + +beforeEach(() => { + appState.connected = true; + window.location.hash = '#/traces'; +}); +afterEach(() => { + act(() => root?.unmount()); + container.remove(); + root = undefined; +}); + +describe('AppShell', () => { + it('renders the 9 nav items in order and the screen', () => { + stubNarrow(false); + render(); + const links = [...nav().querySelectorAll('a')]; + expect(links.map((a) => a.textContent)).toEqual([ + 'Agents', + 'Playground', + 'Traces', + 'Threads', + 'Monitoring', + 'Evaluations', + 'Prompts', + 'Context Hub', + 'Settings', + ]); + expect(NAV_ITEMS.map((i) => i.href)).toEqual([ + '#/agents', + '#/playground', + '#/traces', + '#/threads', + '#/monitoring', + '#/evals/scores', + '#/prompts', + '#/hub', + '#/settings', + ]); + expect(container.querySelector('#screen')).not.toBeNull(); + expect(toggle()).toBeNull(); + }); + + it('marks only the active route aria-current=page, following hash changes', () => { + stubNarrow(false); + render(); + const current = () => [...nav().querySelectorAll('a[aria-current="page"]')].map((a) => a.textContent); + expect(current()).toEqual(['Traces']); + act(() => { + window.location.hash = '#/evals/datasets'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(current()).toEqual(['Evaluations']); + act(() => { + window.location.hash = '#/traces/abc?obs=1'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(current()).toEqual(['Traces']); + }); + + it('shows the connection banner when the studio server drops', () => { + stubNarrow(false); + appState.connected = false; + render(); + expect(container.querySelector('.connection-banner')?.getAttribute('role')).toBe('alert'); + }); + + it('collapses to a drawer when narrow: toggle, Escape, scrim and link close it', () => { + stubNarrow(true); + render(); + expect(nav().getAttribute('data-drawer')).toBe('closed'); + expect(toggle()?.getAttribute('aria-expanded')).toBe('false'); + act(() => toggle()!.click()); + expect(nav().getAttribute('data-drawer')).toBe('open'); + expect(toggle()?.getAttribute('aria-expanded')).toBe('true'); + act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + expect(nav().getAttribute('data-drawer')).toBe('closed'); + act(() => toggle()!.click()); + act(() => (container.querySelector('.app-shell-scrim') as HTMLElement).click()); + expect(nav().getAttribute('data-drawer')).toBe('closed'); + act(() => toggle()!.click()); + act(() => (nav().querySelector('a') as HTMLElement).click()); + expect(nav().getAttribute('data-drawer')).toBe('closed'); + }); +}); + +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', () => { + expect(isActive(item('evals'), '/evals/datasets')).toBe(true); + expect(isActive(item('traces'), '/traces/abc')).toBe(true); + expect(isActive(item('agents'), '/')).toBe(true); + expect(isActive(item('traces'), '/')).toBe(false); + expect(isActive(item('threads'), '/traces')).toBe(false); + }); +}); diff --git a/apps/agent-forge/src/shell/shell.css b/apps/agent-forge/src/shell/shell.css new file mode 100644 index 00000000..5cc41c7b --- /dev/null +++ b/apps/agent-forge/src/shell/shell.css @@ -0,0 +1,129 @@ +/* Left-navigation app shell. Tokens from theme.css only. */ +.app-shell { + height: 100%; + display: grid; + grid-template-columns: 208px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); +} +.app-shell-main { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} +.app-shell-main > .app { + flex: 1; + min-height: 0; + height: auto; +} + +.nav-rail { + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 12px 8px; + gap: 4px; + overflow-y: auto; + min-height: 0; +} +.nav-brand { + font-weight: 600; + font-size: 14px; + padding: 4px 10px 12px; + color: var(--text); +} +.nav-rail ul { + list-style: none; + margin: 0; + padding: 0; +} +.nav-group { + margin-bottom: 8px; +} +.nav-heading { + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); + padding: 6px 10px 4px; +} +.nav-footer { + margin-top: auto; + border-top: 1px solid var(--border); + padding-top: 8px; +} +.nav-item { + display: block; + padding: 7px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--text-muted); + text-decoration: none; +} +.nav-item:hover { + background: var(--surface-2); + color: var(--text); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} +.nav-item:focus-visible, +.nav-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.app-shell-bar { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 8px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.nav-bar-title { + font-weight: 600; + font-size: 13px; +} + +/* Narrow: the nav is a slide-over drawer (same pattern as the Studio side panels). */ +.app-shell-narrow { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + position: relative; + overflow: hidden; +} +.app-shell-narrow .app-shell-bar, +.app-shell-narrow .app-shell-main { + grid-column: 1; +} +.app-shell-narrow .app-shell-main { + grid-row: 2; +} +.app-shell-narrow .nav-rail { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 40; + width: min(260px, 86vw); + box-shadow: var(--shadow); + transform: translateX(-100%); + visibility: hidden; + transition: transform 0.18s ease, visibility 0s linear 0.18s; +} +.app-shell-narrow .nav-rail[data-drawer='open'] { + transform: none; + visibility: visible; + transition: transform 0.18s ease; +} +.app-shell-scrim { + position: absolute; + inset: 0; + z-index: 35; + background: rgba(10, 12, 18, 0.35); +}