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
3 changes: 2 additions & 1 deletion apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
39 changes: 23 additions & 16 deletions apps/agent-forge/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +24,12 @@ import { ObserveView } from './observe/ObserveView';
function useSidePanels() {
const narrow = useMediaQuery(NARROW_QUERY);
const [open, setOpen] = useState<SidePanel | undefined>(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);
Expand All @@ -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 (
<div className="connection-banner" role="alert">
Lost the connection to the studio server - reconnecting. Is <code>lousho studio</code> still running?
</div>
);
}

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 (
<div className="app app-observe">
<h1 className="visually-hidden">Agent Forge</h1>
<ConnectionBanner />
<Topbar narrow={panels.narrow} openPanel={panels.open} onTogglePanel={panels.toggle} />
<ObserveView />
</div>
Expand All @@ -64,7 +70,6 @@ function Studio() {
return (
<div className="app">
<h1 className="visually-hidden">Agent Forge</h1>
<ConnectionBanner />
<Topbar narrow={panels.narrow} openPanel={panels.open} onTogglePanel={panels.toggle} />
<div className={shellClass}>
<LeftRail />
Expand All @@ -83,7 +88,9 @@ export function App() {
<AppStateProvider>
{/* Shared by the canvas and the rail's palette (click/keyboard add at the viewport centre). */}
<ReactFlowProvider>
<Studio />
<AppShell>
<Studio />
</AppShell>
</ReactFlowProvider>
</AppStateProvider>
</ErrorBoundary>
Expand Down
11 changes: 9 additions & 2 deletions apps/agent-forge/src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -94,15 +95,21 @@ function ViewToggle() {
type="button"
className={`btn btn-ghost${view === 'build' ? ' active' : ''}`}
aria-pressed={view === 'build'}
onClick={() => setView('build')}
onClick={() => {
setView('build');
navigate('/agents');
}}
>
Build
</button>
<button
type="button"
className={`btn btn-ghost${view === 'observe' ? ' active' : ''}`}
aria-pressed={view === 'observe'}
onClick={() => setView('observe')}
onClick={() => {
setView('observe');
navigate('/traces');
}}
>
Observe
</button>
Expand Down
23 changes: 23 additions & 0 deletions apps/agent-forge/src/router/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(
'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');
});
});
9 changes: 6 additions & 3 deletions apps/agent-forge/src/router/useRoute.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,18 +31,21 @@ export function useQueryState<T>(
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];
}
68 changes: 68 additions & 0 deletions apps/agent-forge/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="connection-banner" role="alert">
Lost the connection to the studio server - reconnecting. Is <code>lousho studio</code> still running?
</div>
);
}

/** 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 (
<ShellContext.Provider value={{ navOpen }}>
<div className={`app-shell${narrow ? ' app-shell-narrow' : ''}`}>
{narrow && (
<div className="app-shell-bar">
<button
type="button"
className="btn btn-ghost nav-toggle"
aria-label="Navigation"
aria-expanded={navOpen}
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>
</button>
<span className="nav-bar-title">Agent Forge</span>
</div>
)}
<NavRail drawer={narrow ? (navOpen ? 'open' : 'closed') : undefined} onNavigate={() => setOpen(false)} />
{navOpen && <div className="app-shell-scrim" aria-hidden="true" onClick={() => setOpen(false)} />}
<div className="app-shell-main">
<ConnectionBanner />
{children}
</div>
</div>
</ShellContext.Provider>
);
}
75 changes: 75 additions & 0 deletions apps/agent-forge/src/shell/NavRail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav id="primary-nav" className="nav-rail" aria-label="Primary" data-drawer={drawer}>
<div className="nav-brand">Agent Forge</div>
{SECTION_ORDER.map((section) => {
const items = NAV_ITEMS.filter((i) => i.section === section);
return (
<ul key={section} className={`nav-group${section === 'Footer' ? ' nav-footer' : ''}`}>
{section !== 'Footer' && (
<li className="nav-heading" aria-hidden="true">
{section}
</li>
)}
{items.map((item) => {
const active = isActive(item, path);
return (
<li key={item.id}>
<a
className={`nav-item${active ? ' active' : ''}`}
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={onNavigate}
>
{item.label}
</a>
</li>
);
})}
</ul>
);
})}
</nav>
);
}
Loading
Loading