diff --git a/apps/agent-forge/e2e/studioUx.spec.ts b/apps/agent-forge/e2e/studioUx.spec.ts index d7d871a9..d6b874c1 100644 --- a/apps/agent-forge/e2e/studioUx.spec.ts +++ b/apps/agent-forge/e2e/studioUx.spec.ts @@ -101,3 +101,31 @@ test('the Output node is drawn once and the minimap shows the graph', async ({ p // The minimap draws one node per canvas node. await expect.poll(() => page.locator('.react-flow__minimap-node').count()).toBe(await page.locator('.react-flow__node').count()); }); + +/** + * Lands on `#/agents` with the session still authenticated: the redirect only + * touches the hash, and studioToken.ts has moved `?token=` into sessionStorage. + */ +async function expectLanded(page: Page, hash: string) { + await expect(page).toHaveURL(new RegExp(`/${hash}$`)); + expect(await page.evaluate(() => sessionStorage.getItem('lousho-studio-token'))).toBe(TOKEN); + await expect(page.locator('.connection-banner')).toHaveCount(0); +} + +test('a ?token= link lands on Agents; the Build toolbar stays on Agents', async ({ page }) => { + await page.goto(`/?token=${TOKEN}`); + await expectLanded(page, '#/agents'); + await expect(page.getByRole('button', { name: 'Run', exact: true })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Studio surface' })).toHaveCount(0); + + await page.getByRole('navigation', { name: 'Primary' }).getByRole('link', { name: 'Traces' }).click(); + await expectLanded(page, '#/traces'); + await expect(page.locator('.topbar .crumbs')).toHaveText('Traces'); + for (const name of ['Run', 'Stop', 'Import', 'Export', 'Save', 'Debug']) { + await expect(page.getByRole('button', { name, exact: true })).toHaveCount(0); + } + await expect(page.locator('.env-select')).toHaveCount(0); + + await page.goto(`/?token=${TOKEN}#/no-such-screen`); + await expectLanded(page, '#/agents'); +}); diff --git a/apps/agent-forge/src/App.tsx b/apps/agent-forge/src/App.tsx index 71e3d974..ec929100 100644 --- a/apps/agent-forge/src/App.tsx +++ b/apps/agent-forge/src/App.tsx @@ -1,86 +1,9 @@ import './components/layout.css'; -import { useEffect, useState } from 'react'; import { ReactFlowProvider } from '@xyflow/react'; -import { AppStateProvider, useAppState } from './state/AppState'; -import { Topbar, type SidePanel } from './components/Topbar'; -import { LeftRail } from './components/LeftRail'; -import { CanvasArea } from './components/CanvasArea'; -import { Inspector } from './components/Inspector'; -import { BottomDrawer } from './components/BottomDrawer'; +import { AppStateProvider } from './state/AppState'; 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, - * opened one at a time from the top bar and closed by Escape or the scrim. - */ -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); - }, [narrow]); - - useEffect(() => { - if (!open) return undefined; - const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(undefined); - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [open]); - - const toggle = (panel: SidePanel) => setOpen((current) => (current === panel ? undefined : panel)); - return { narrow, open, toggle, close: () => setOpen(undefined) }; -} - -function Studio() { - const panels = useSidePanels(); - 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 (observePath) { - return ( -
-

Agent Forge

- - -
- ); - } - return ( -
-

Agent Forge

- -
- - - - {panels.open && - -
- ); -} +import { AppShell } from './shell/AppShell'; +import { Screens } from './screens/routes'; export function App() { return ( @@ -89,7 +12,7 @@ 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 c27d0484..88c856c0 100644 --- a/apps/agent-forge/src/components/Topbar.tsx +++ b/apps/agent-forge/src/components/Topbar.tsx @@ -5,6 +5,8 @@ import { ApprovalCard } from './ApprovalCard'; import { errorMessage } from './errorMessage'; import { StatusPill } from './StatusPill'; import { navigate } from '../router/router'; +import { useRoute } from '../router/useRoute'; +import { NAV_ITEMS, isActive } from '../shell/NavRail'; function BrandMark() { return ( @@ -82,41 +84,6 @@ function EnvSelect() { ); } -/** - * Build/Observe surface switcher. "Observe" swaps the builder shell for the - * observability panels (Traces/Insights/Monitoring/Scores) that read the - * `lousho observe` daemon - a separate data source from the studio server. - */ -function ViewToggle() { - const { view, setView } = useAppState(); - return ( -
- - -
- ); -} - function DebugToggle() { const { debugMode, setDebugMode, setDrawerTab } = useAppState(); return ( @@ -321,7 +288,7 @@ interface TopbarProps { } function NarrowTopbar({ openPanel, onTogglePanel }: Omit) { - const { spec, dirty, runStatus, debugMode, setDebugMode, setDrawerTab, view, setView } = useAppState(); + const { spec, dirty, runStatus, debugMode, setDebugMode, setDrawerTab } = useAppState(); const { actionError, handleRun, handleStop, handleSave, handleImport } = useRunActions(); const { fileInput, openImport, exportSpec } = useSpecFiles(handleImport); const toggle = onTogglePanel ?? (() => undefined); @@ -341,11 +308,9 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit) { label: 'Import', onSelect: openImport }, { label: 'Export', onSelect: exportSpec }, { label: 'Save', onSelect: () => void handleSave() }, + // The old Build/Observe switch's menu entry, kept for muscle memory: Observe is now Traces. // Last: the menu focuses its first item on open (e2e expects Debug). - { - label: view === 'observe' ? 'Build surface' : 'Observe surface', - onSelect: () => setView(view === 'observe' ? 'build' : 'observe'), - }, + { label: 'Observe surface', onSelect: () => navigate('/traces') }, ]; return ( @@ -383,7 +348,29 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit) ); } +/** + * Off the Agents screen there is nothing to run, import or save: the bar + * names the screen and keeps the live run status, nothing else. + */ +function ScreenTopbar({ path }: { path: string }) { + const { runStatus } = useAppState(); + const screen = NAV_ITEMS.find((item) => isActive(item, path)); + return ( +
+
+ {screen?.label} +
+
+ +
+ ); +} + +/** The Build toolbar (Run/Stop/Import/Export/Save/Debug, provider pill) renders only on `#/agents`. */ export function Topbar({ narrow = false, openPanel, onTogglePanel }: TopbarProps = {}) { + const { path } = useRoute(); + const nav = NAV_ITEMS.find((item) => isActive(item, path)); + if (nav && nav.id !== 'agents') return ; return narrow ? : ; } @@ -406,8 +393,6 @@ function WideTopbar() {
- - diff --git a/apps/agent-forge/src/components/layout.css b/apps/agent-forge/src/components/layout.css index 46d8e9f2..b5cbff03 100644 --- a/apps/agent-forge/src/components/layout.css +++ b/apps/agent-forge/src/components/layout.css @@ -11,27 +11,36 @@ display: grid; grid-template-rows: 48px 1fr 240px; } -/* Observe mode: the panels take the whole content area, no run drawer. */ -.app-observe { - grid-template-rows: 48px 1fr; +/* Every screen but Agents: top bar over the screen, no run drawer. */ +.app-screen { + grid-template-rows: 48px minmax(0, 1fr); } - -/* Build/Observe surface switcher (Topbar.tsx's ViewToggle). */ -.view-toggle { +.screen-main { + min-width: 0; + min-height: 0; display: flex; - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - flex: none; + flex-direction: column; + overflow: auto; } -.view-toggle .btn { - border: none; - border-radius: 0; - padding: 5px 10px; - font-size: 11.5px; +.screen-main > .observe, +.screen-main > .screen-settings { + flex: 1; + min-height: 0; } -.view-toggle .btn.active { - background: var(--accent-soft); +.screen-placeholder { + padding: 32px 24px; + max-width: 560px; + color: var(--text-muted); +} +.screen-placeholder h2 { + margin: 0 0 8px; + font-size: 18px; + color: var(--text); +} +.screen-placeholder p { + margin: 0 0 16px; +} +.screen-placeholder a { color: var(--accent); } diff --git a/apps/agent-forge/src/observe/ObserveView.tsx b/apps/agent-forge/src/observe/ObserveView.tsx index 09bb1879..66193b81 100644 --- a/apps/agent-forge/src/observe/ObserveView.tsx +++ b/apps/agent-forge/src/observe/ObserveView.tsx @@ -16,7 +16,7 @@ const TABS = [ { id: 'fleet', label: 'Monitoring' }, { id: 'scores', label: 'Scores' }, ] as const; -type ObserveTab = (typeof TABS)[number]['id']; +export type ObserveTab = (typeof TABS)[number]['id']; const TAB_IDS: ObserveTab[] = TABS.map((t) => t.id); /** @@ -25,10 +25,20 @@ const TAB_IDS: ObserveTab[] = TABS.map((t) => t.id); * `/observe` proxy. Cross-panel drill-down (fleet row -> that agent's * traces) is just shared state here: `tracesFilter` is owned at the top so * another tab can pre-fill it. + * + * Given `tab`, the route owns the panel: the tab strip is hidden (the left + * nav is the only switcher) and drill-downs ask `onTabChange` to navigate. + * Monitoring then also shows Insights under the fleet table. */ -export function ObserveView() { +export function ObserveView({ + tab: routeTab, + onTabChange, +}: { tab?: ObserveTab; onTabChange?: (tab: ObserveTab) => void } = {}) { const health = useDaemonHealth(); - const [tab, setTab] = useState('traces'); + const [ownTab, setOwnTab] = useState('traces'); + const routed = routeTab !== undefined; + const tab = routeTab ?? ownTab; + const setTab = (next: ObserveTab) => (routed ? onTabChange?.(next) : setOwnTab(next)); const [tracesFilter, setTracesFilter] = useState({}); const [traceId, setTraceId] = useState(undefined); @@ -59,9 +69,25 @@ export function ObserveView() { /> ); else if (tab === 'insights') body = ; + else if (tab === 'fleet' && routed) + body = ( + <> + +
+ +
+ + ); else if (tab === 'fleet') body = ; else body = ; + if (routed) + return ( +
+
{body}
+
+ ); + const ids = tabIds('observe', tab); return (
diff --git a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx index ae5ab783..551918b0 100644 --- a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx +++ b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx @@ -88,4 +88,48 @@ describe('ObserveView', () => { act(() => back!.click()); expect(onBack).toHaveBeenCalledTimes(1); }); + + function stubDaemon() { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) { + return new Response(JSON.stringify({ service: 'lousho-observe', ok: true }), { status: 200 }); + } + if (url.includes('/api/v1/agents')) { + return new Response(JSON.stringify({ agents: [] }), { status: 200 }); + } + if (url.includes('/api/v1/insights')) { + return new Response(JSON.stringify({ window: { since: 0, until: 1 }, agents: [], series: [] }), { status: 200 }); + } + return new Response(JSON.stringify({ traces: [], total: 0, limit: 50, offset: 0 }), { status: 200 }); + }) + ); + } + + async function renderView(node: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render(node); + }); + await flush(); + await flush(); + } + + it('hides its own tab strip when the route picks the panel', async () => { + stubDaemon(); + await renderView( {}} />); + expect(container!.querySelector('[role="tablist"]')).toBeNull(); + expect(container!.textContent).toContain('No traces match'); + }); + + it('shows Insights under the fleet table on the Monitoring panel', async () => { + stubDaemon(); + await renderView( {}} />); + expect(container!.querySelector('[role="tablist"]')).toBeNull(); + expect(container!.querySelector('[aria-label="Insights"]')).not.toBeNull(); + }); }); diff --git a/apps/agent-forge/src/screens/AgentsScreen.tsx b/apps/agent-forge/src/screens/AgentsScreen.tsx new file mode 100644 index 00000000..3a88199c --- /dev/null +++ b/apps/agent-forge/src/screens/AgentsScreen.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { Topbar, type SidePanel } from '../components/Topbar'; +import { LeftRail } from '../components/LeftRail'; +import { CanvasArea } from '../components/CanvasArea'; +import { Inspector } from '../components/Inspector'; +import { BottomDrawer } from '../components/BottomDrawer'; +import { NARROW_QUERY, useMediaQuery } from '../components/useMediaQuery'; +import { useShell } from '../shell/AppShell'; + +/** + * Below 860px the rail and the Inspector are slide-over drawers, + * opened one at a time from the top bar and closed by Escape or the scrim. + */ +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); + }, [narrow]); + + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(undefined); + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open]); + + const toggle = (panel: SidePanel) => setOpen((current) => (current === panel ? undefined : panel)); + return { narrow, open, toggle, close: () => setOpen(undefined) }; +} + +/** `#/agents` - the builder: agent rail, canvas, Inspector and the run drawer, under the Build toolbar. */ +export function AgentsScreen() { + const panels = useSidePanels(); + const shellClass = `shell${panels.open ? ` ${panels.open}-open` : ''}`; + return ( +
+

Agent Forge

+ +
+ + + + {panels.open && + +
+ ); +} diff --git a/apps/agent-forge/src/screens/Placeholder.tsx b/apps/agent-forge/src/screens/Placeholder.tsx new file mode 100644 index 00000000..4f1ddf22 --- /dev/null +++ b/apps/agent-forge/src/screens/Placeholder.tsx @@ -0,0 +1,10 @@ +/** Stand-in for a screen that has a nav item but no content yet: what it is, what's coming, and a way back. */ +export function Placeholder({ title, blurb }: { title: string; blurb: string }) { + return ( +
+

{title}

+

{blurb}

+ Back to Agents +
+ ); +} diff --git a/apps/agent-forge/src/screens/__tests__/routes.test.tsx b/apps/agent-forge/src/screens/__tests__/routes.test.tsx new file mode 100644 index 00000000..716100f0 --- /dev/null +++ b/apps/agent-forge/src/screens/__tests__/routes.test.tsx @@ -0,0 +1,169 @@ +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const appState = vi.hoisted(() => ({ + spec: { name: 'demo', provider: { type: 'mock', model: 'echo' } }, + agentId: 'demo', + dirty: false, + runStatus: undefined, + debugMode: false, + setDebugMode: () => {}, + setDrawerTab: () => {}, + runAgent: async () => {}, + stopAgent: async () => {}, + save: async () => {}, + setSpec: () => {}, + activeProfile: undefined, + connected: true, +})); +vi.mock('../../state/AppState', () => ({ useAppState: () => appState })); + +import { screenFor, useScreen } from '../routes'; +import { Placeholder } from '../Placeholder'; +import { Topbar } from '../../components/Topbar'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; +let container: HTMLDivElement; + +function render(node: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root!.render(node)); +} + +function go(hash: string) { + act(() => { + window.location.hash = hash; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); +} + +const buttonNames = () => [...container.querySelectorAll('button')].map((b) => b.textContent?.trim()); + +beforeEach(() => { + window.history.replaceState(null, '', '/?token=secret'); +}); +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + window.history.replaceState(null, '', '/'); +}); + +describe('screenFor', () => { + it('maps every nav destination and its deep links to a screen', () => { + expect(screenFor('#/agents')).toBe('agents'); + expect(screenFor('#/agents/support-bot')).toBe('agents'); + expect(screenFor('#/traces')).toBe('traces'); + expect(screenFor('#/traces/abc?obs=1')).toBe('traces'); + expect(screenFor('#/monitoring?window=24h')).toBe('monitoring'); + expect(screenFor('#/evals/scores')).toBe('scores'); + expect(screenFor('#/evals/datasets')).toBe('evals'); + expect(screenFor('#/playground')).toBe('playground'); + expect(screenFor('#/threads/s1')).toBe('threads'); + expect(screenFor('#/prompts/greeting@v2')).toBe('prompts'); + expect(screenFor('#/hub/docs')).toBe('hub'); + expect(screenFor('#/settings')).toBe('settings'); + }); + it('has no screen for an empty hash or an unknown route', () => { + expect(screenFor('')).toBeNull(); + expect(screenFor('#/')).toBeNull(); + expect(screenFor('#/nope')).toBeNull(); + }); +}); + +describe('useScreen', () => { + function probe() { + const seen: string[] = []; + const Probe = () => { + seen.push(useScreen()); + return null; + }; + render(createElement(Probe)); + return seen; + } + + it('redirects an empty hash to #/agents with replace, keeping ?token=', () => { + const before = window.history.length; + const seen = probe(); + expect(window.location.hash).toBe('#/agents'); + expect(window.location.search).toBe('?token=secret'); + expect(window.history.length).toBe(before); + expect(seen.every((s) => s === 'agents')).toBe(true); + }); + + it('redirects an unknown route to #/agents, keeping ?token=', () => { + window.history.replaceState(null, '', '/?token=secret#/nope'); + probe(); + expect(window.location.hash).toBe('#/agents'); + expect(window.location.search).toBe('?token=secret'); + }); + + it('follows hash changes', () => { + window.history.replaceState(null, '', '/?token=secret#/traces'); + const seen = probe(); + expect(seen.at(-1)).toBe('traces'); + go('#/monitoring'); + expect(seen.at(-1)).toBe('monitoring'); + }); +}); + +describe('Topbar', () => { + it('shows the Build controls on #/agents', () => { + window.history.replaceState(null, '', '/?token=secret#/agents'); + render(); + expect(buttonNames()).toEqual(expect.arrayContaining(['Run', 'Stop', 'Import', 'Export', 'Save', 'Debug'])); + expect(container.querySelector('.env-select')).not.toBeNull(); + }); + + it('has no Build controls or model badge on #/traces', () => { + window.history.replaceState(null, '', '/?token=secret#/traces'); + render(); + for (const name of ['Run', 'Stop', 'Import', 'Export', 'Save', 'Debug', 'Build', 'Observe']) { + expect(buttonNames()).not.toContain(name); + } + expect(container.querySelector('.env-select')).toBeNull(); + expect(container.querySelector('.mock-badge')).toBeNull(); + expect(container.querySelector('header')?.textContent).toContain('Traces'); + expect(container.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull(); + }); + + it('has no Build controls in the narrow layout off Agents either', () => { + window.history.replaceState(null, '', '/?token=secret#/monitoring'); + render(); + expect(buttonNames()).not.toContain('Run'); + expect(container.querySelector('[aria-label="More actions"]')).toBeNull(); + }); + + it('drops the Build/Observe switch on #/agents', () => { + window.history.replaceState(null, '', '/?token=secret#/agents'); + render(); + expect(container.querySelector('[aria-label="Studio surface"]')).toBeNull(); + expect(buttonNames()).not.toContain('Observe'); + }); + + it("maps the old 'Observe surface' shortcut to #/traces", () => { + window.history.replaceState(null, '', '/?token=secret#/agents'); + render(); + act(() => (container.querySelector('[aria-label="More actions"]') as HTMLButtonElement).click()); + const item = [...container.querySelectorAll('[role="menuitem"]')].find( + (el) => el.textContent === 'Observe surface', + ) as HTMLButtonElement; + act(() => item.click()); + expect(window.location.hash).toBe('#/traces'); + expect(window.location.search).toBe('?token=secret'); + }); +}); + +describe('Placeholder', () => { + it('shows a title, what is coming, and a link back to Agents', () => { + render(); + expect(container.querySelector('h2')?.textContent).toBe('Playground'); + expect(container.textContent).toContain('Coming soon.'); + expect(container.querySelector('a[href="#/agents"]')).not.toBeNull(); + }); +}); diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx new file mode 100644 index 00000000..e544f703 --- /dev/null +++ b/apps/agent-forge/src/screens/routes.tsx @@ -0,0 +1,112 @@ +import { useEffect, type ReactNode } from 'react'; +import { matchRoute, navigate } from '../router/router'; +import { useRoute } from '../router/useRoute'; +import { Topbar } from '../components/Topbar'; +import { SettingsPanel } from '../components/SettingsPanel'; +import { ObserveView, type ObserveTab } from '../observe/ObserveView'; +import { AgentsScreen } from './AgentsScreen'; +import { Placeholder } from './Placeholder'; + +export type ScreenId = + | 'agents' + | 'playground' + | 'traces' + | 'threads' + | 'monitoring' + | 'scores' + | 'evals' + | 'prompts' + | 'hub' + | 'settings'; + +/** Hash route patterns, first match wins. Deep-link params are accepted now and read by the screens that land later. */ +const ROUTES: { pattern: string; screen: ScreenId }[] = [ + { pattern: '/agents', screen: 'agents' }, + { pattern: '/agents/:id', screen: 'agents' }, + { pattern: '/playground', screen: 'playground' }, + { pattern: '/traces', screen: 'traces' }, + { pattern: '/traces/:traceId', screen: 'traces' }, + { pattern: '/threads', screen: 'threads' }, + { pattern: '/threads/:sessionId', screen: 'threads' }, + { pattern: '/monitoring', screen: 'monitoring' }, + { pattern: '/evals/scores', screen: 'scores' }, + { pattern: '/evals/:section', screen: 'evals' }, + { pattern: '/evals/:section/:id', screen: 'evals' }, + { pattern: '/prompts', screen: 'prompts' }, + { pattern: '/prompts/:name', screen: 'prompts' }, + { pattern: '/hub', screen: 'hub' }, + { pattern: '/hub/:namespace', screen: 'hub' }, + { pattern: '/settings', screen: 'settings' }, +]; + +/** The screen a hash shows, or `null` for an empty hash / unknown route. */ +export function screenFor(hash: string): ScreenId | null { + for (const { pattern, screen } of ROUTES) if (matchRoute(hash, [pattern])) return screen; + return null; +} + +/** + * The current screen. An empty hash or unknown route is replaced by `#/agents` + * (only the hash changes, so a `?token=` from `lousho studio` survives) and + * shows Agents meanwhile. + */ +export function useScreen(): ScreenId { + const { path } = useRoute(); + const screen = screenFor(`#${path}`); + useEffect(() => { + if (screen === null) navigate('/agents', { replace: true }); + }, [screen]); + return screen ?? 'agents'; +} + +/** Until the Phase 3 screens land, Traces/Monitoring/Scores are the existing observe panels, one per route. */ +const OBSERVE_TAB: Partial> = { traces: 'traces', monitoring: 'fleet', scores: 'scores' }; +const TAB_PATH: Record = { + traces: '/traces', + insights: '/monitoring', + fleet: '/monitoring', + scores: '/evals/scores', +}; + +const PLACEHOLDERS: Partial> = { + playground: { + title: 'Playground', + blurb: 'Try a prompt against different models and settings side by side, without editing an agent.', + }, + threads: { title: 'Threads', blurb: 'Multi-turn conversations grouped by session, with every run in each thread.' }, + evals: { title: 'Evaluations', blurb: 'Datasets, evaluators and annotation queues for scoring runs.' }, + prompts: { title: 'Prompts', blurb: 'Versioned prompts you can compare, pin and pull into your agents.' }, + hub: { title: 'Context Hub', blurb: 'The shared context namespaces your agents read from, browsable by namespace.' }, +}; + +/** Every screen but Agents: the shell top bar over the screen's content. */ +function ScreenFrame({ children }: { children: ReactNode }) { + return ( +
+

Agent Forge

+ +
{children}
+
+ ); +} + +/** Renders the screen the hash route names. */ +export function Screens() { + const screen = useScreen(); + if (screen === 'agents') return ; + + const observeTab = OBSERVE_TAB[screen]; + const placeholder = PLACEHOLDERS[screen]; + let content: ReactNode; + // Same element type at the same position for all three, so the drill-down filter survives a route change. + if (observeTab) content = navigate(TAB_PATH[tab])} />; + else if (placeholder) content = ; + else + content = ( +
+

Settings

+ +
+ ); + return {content}; +} diff --git a/apps/agent-forge/src/state/AppState.tsx b/apps/agent-forge/src/state/AppState.tsx index f80eadce..71ebaf2e 100644 --- a/apps/agent-forge/src/state/AppState.tsx +++ b/apps/agent-forge/src/state/AppState.tsx @@ -22,8 +22,6 @@ import { useActiveProfile, useChatControls, useRunControls } from './useAgentCon type RailTab = 'agents' | 'nodes'; export type DrawerTab = 'chat' | 'logs' | 'trace' | 'output' | 'history' | 'settings'; -/** Top-level surface: the builder canvas, or the observability panels (Traces/Insights/Monitoring/Scores). */ -type StudioView = 'build' | 'observe'; interface AppState { store: AgentStore; @@ -55,9 +53,6 @@ interface AppState { setRailTab: (tab: RailTab) => void; drawerTab: DrawerTab; setDrawerTab: (tab: DrawerTab) => void; - /** Which surface the middle of the app shows - the agent builder canvas or the observe panels. */ - view: StudioView; - setView: (view: StudioView) => void; /** Every agent currently known to `store`, refreshed after save/create/switch. */ agents: AgentStoreEntry[]; /** Loads a different agent's graph into the canvas - a full state swap, not a visual pan. */ @@ -155,7 +150,6 @@ export function AppStateProvider({ children }: { children: ReactNode }) { const store = useMemo(() => new HttpAgentStore(runtimeClient), []); const [railTab, setRailTab] = useState('agents'); const [drawerTab, setDrawerTab] = useState('chat'); - const [view, setView] = useState('build'); const doc = useAgentDocument(store); const { agentId, spec } = doc; const agentStatuses = useAgentStatuses(doc.agents); @@ -171,8 +165,6 @@ export function AppStateProvider({ children }: { children: ReactNode }) { setRailTab, drawerTab, setDrawerTab, - view, - setView, ...run, agentStatuses, ...stream,