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
28 changes: 28 additions & 0 deletions apps/agent-forge/e2e/studioUx.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
85 changes: 4 additions & 81 deletions apps/agent-forge/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<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);
}, [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 (
<div className="app app-observe">
<h1 className="visually-hidden">Agent Forge</h1>
<Topbar narrow={panels.narrow} openPanel={panels.open} onTogglePanel={panels.toggle} />
<ObserveView />
</div>
);
}
return (
<div className="app">
<h1 className="visually-hidden">Agent Forge</h1>
<Topbar narrow={panels.narrow} openPanel={panels.open} onTogglePanel={panels.toggle} />
<div className={shellClass}>
<LeftRail />
<CanvasArea />
<Inspector />
{panels.open && <div className="shell-scrim" aria-hidden="true" onClick={panels.close} />}
</div>
<BottomDrawer />
</div>
);
}
import { AppShell } from './shell/AppShell';
import { Screens } from './screens/routes';

export function App() {
return (
Expand All @@ -89,7 +12,7 @@ export function App() {
{/* Shared by the canvas and the rail's palette (click/keyboard add at the viewport centre). */}
<ReactFlowProvider>
<AppShell>
<Studio />
<Screens />
</AppShell>
</ReactFlowProvider>
</AppStateProvider>
Expand Down
69 changes: 27 additions & 42 deletions apps/agent-forge/src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
<div className="view-toggle" role="group" aria-label="Studio surface">
<button
type="button"
className={`btn btn-ghost${view === 'build' ? ' active' : ''}`}
aria-pressed={view === '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');
navigate('/traces');
}}
>
Observe
</button>
</div>
);
}

function DebugToggle() {
const { debugMode, setDebugMode, setDrawerTab } = useAppState();
return (
Expand Down Expand Up @@ -321,7 +288,7 @@ interface TopbarProps {
}

function NarrowTopbar({ openPanel, onTogglePanel }: Omit<TopbarProps, 'narrow'>) {
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);
Expand All @@ -341,11 +308,9 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit<TopbarProps, 'narrow'>)
{ 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 (
Expand Down Expand Up @@ -383,7 +348,29 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit<TopbarProps, 'narrow'>)
);
}

/**
* 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 (
<header className="topbar">
<div className="crumbs">
<b>{screen?.label}</b>
</div>
<div className="topbar-spacer" />
<StatusPill status={runStatus?.status ?? 'idle'} live />
</header>
);
}

/** 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 <ScreenTopbar path={path} />;
return narrow ? <NarrowTopbar openPanel={openPanel} onTogglePanel={onTogglePanel} /> : <WideTopbar />;
}

Expand All @@ -406,8 +393,6 @@ function WideTopbar() {
</div>
<div className="topbar-spacer" />

<ViewToggle />

<RunNotices runStatus={runStatus} actionError={actionError} />

<StatusPill status={status} live />
Expand Down
43 changes: 26 additions & 17 deletions apps/agent-forge/src/components/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
32 changes: 29 additions & 3 deletions apps/agent-forge/src/observe/ObserveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand All @@ -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<ObserveTab>('traces');
const [ownTab, setOwnTab] = useState<ObserveTab>('traces');
const routed = routeTab !== undefined;
const tab = routeTab ?? ownTab;
const setTab = (next: ObserveTab) => (routed ? onTabChange?.(next) : setOwnTab(next));
const [tracesFilter, setTracesFilter] = useState<TraceListFilter>({});
const [traceId, setTraceId] = useState<string | undefined>(undefined);

Expand Down Expand Up @@ -59,9 +69,25 @@ export function ObserveView() {
/>
);
else if (tab === 'insights') body = <InsightsPanel />;
else if (tab === 'fleet' && routed)
body = (
<>
<FleetPanel onShowTraces={drillToAgent} />
<section aria-label="Insights">
<InsightsPanel />
</section>
</>
);
else if (tab === 'fleet') body = <FleetPanel onShowTraces={drillToAgent} />;
else body = <ScoresPanel onOpenTrace={drillToTrace} />;

if (routed)
return (
<section className="observe" aria-label="Observability">
<div className="observe-body">{body}</div>
</section>
);

const ids = tabIds('observe', tab);
return (
<section className="observe" aria-label="Observability">
Expand Down
Loading
Loading