- {/* No empty body box under a node with nothing to show (output, router). */}
+
+ {/* No empty body box under a node with nothing to show (llm, output, router). */}
{rows.length > 0 && (
{rows.map((row) => (
diff --git a/apps/agent-forge/src/canvas/EmptyCanvas.tsx b/apps/agent-forge/src/canvas/EmptyCanvas.tsx
new file mode 100644
index 00000000..b5c5cb44
--- /dev/null
+++ b/apps/agent-forge/src/canvas/EmptyCanvas.tsx
@@ -0,0 +1,64 @@
+import { useEffect, useState } from 'react';
+import { useAppState } from '../state/AppState';
+import { EmptyState } from '../ui/EmptyState';
+import { AGENT_TEMPLATES, graphFromTemplate, type TemplateId } from './templates';
+
+/** True between a drag's start and its end (or drop) anywhere in the window. */
+function useDragInProgress(): boolean {
+ const [dragging, setDragging] = useState(false);
+ useEffect(() => {
+ const start = () => setDragging(true);
+ const stop = () => setDragging(false);
+ window.addEventListener('dragstart', start);
+ window.addEventListener('dragend', stop);
+ window.addEventListener('drop', stop);
+ return () => {
+ window.removeEventListener('dragstart', start);
+ window.removeEventListener('dragend', stop);
+ window.removeEventListener('drop', stop);
+ };
+ }, []);
+ return dragging;
+}
+
+/**
+ * Empty-canvas onboarding: while the open agent's graph has no nodes, the
+ * canvas lists the starter templates. Choosing one replaces the graph through
+ * `setGraph` (which marks the agent dirty, like any canvas edit) and keeps the
+ * agent's own name; the open agent and its route do not change.
+ *
+ * While anything is being dragged (a palette node) the card steps aside, so
+ * the drop lands on the React Flow pane rather than on the card.
+ */
+export function EmptyCanvas() {
+ const { graph, spec, setGraph, setSelectedNodeId } = useAppState();
+ const dragging = useDragInProgress();
+ if (graph.nodes.length > 0 || dragging) return null;
+
+ function choose(id: TemplateId) {
+ const next = graphFromTemplate(id, spec.name || undefined);
+ setGraph(() => next);
+ setSelectedNodeId(next.nodes.find((n) => n.type === 'llm')?.id);
+ }
+
+ return (
+
+
+ {AGENT_TEMPLATES.map((template) => (
+
+
+
+ ))}
+
+ }
+ />
+
+ );
+}
diff --git a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx
new file mode 100644
index 00000000..14a9b337
--- /dev/null
+++ b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest';
+import { ReactFlowProvider, type NodeProps } from '@xyflow/react';
+import { render } from '../../ui/__tests__/render';
+import { NODE_TYPES, flowNodeType, type AgentNodeData } from '../AgentNode';
+import { specToGraph } from '../../graph/specToGraph';
+import type { AgentGraphNode } from '../../graph/types';
+
+const graph = specToGraph({
+ name: 'support-bot',
+ prompt: 'p',
+ provider: { type: 'openai', model: 'gpt-4o-mini' },
+ tools: ['http', 'search'],
+});
+const nodeOf = (type: AgentGraphNode['type']) => graph.nodes.find((n) => n.type === type)!;
+
+function renderCard(graphNode: AgentGraphNode, extra: Partial = {}): HTMLElement {
+ const Card = NODE_TYPES[flowNodeType(graphNode.type)];
+ const data: AgentNodeData = { graphNode, onRename: vi.fn(), ...extra };
+ const props = { id: graphNode.id, type: flowNodeType(graphNode.type), data, selected: false } as unknown as NodeProps;
+ const container = render(
+
+
+
+ );
+ return container.querySelector('.rf-node') as HTMLElement;
+}
+
+describe('AgentNode card', () => {
+ it('shows the name, a provider/model chip, the tool count and the run status', () => {
+ const card = renderCard(nodeOf('llm'), { toolCount: 2, status: 'running' });
+ expect(card.querySelector('.rf-node-title')?.textContent).toBe('support-bot');
+ expect(card.querySelector('.node-model')?.textContent).toBe('openai/gpt-4o-mini');
+ expect(card.querySelector('.node-tools')?.textContent).toBe('2 tools');
+ expect(card.querySelector('.status-pill')?.textContent).toBe('running');
+ });
+
+ it('is idle with no tool count when nothing is known', () => {
+ const card = renderCard(nodeOf('llm'));
+ expect(card.querySelector('.node-tools')).toBeNull();
+ expect(card.querySelector('.status-pill')?.textContent).toBe('idle');
+ });
+
+ it('has no model chip on a non-LLM node', () => {
+ const card = renderCard(nodeOf('tool'), { status: 'done' });
+ expect(card.querySelector('.node-model')).toBeNull();
+ expect(card.querySelector('.node-row code')?.textContent).toBe('http');
+ });
+
+ it('renders the LLM card markup', () => {
+ const card = renderCard(nodeOf('llm'), { toolCount: 1, status: 'paused' });
+ expect(card.outerHTML).toMatchSnapshot();
+ });
+});
diff --git a/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx
new file mode 100644
index 00000000..e2fa7a2e
--- /dev/null
+++ b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx
@@ -0,0 +1,65 @@
+import { act } from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { AgentGraphSpec } from '../../graph/types';
+
+const appState = vi.hoisted(() => ({
+ graph: { version: 1, nodes: [], edges: [] } as AgentGraphSpec,
+ spec: { name: 'my-agent', prompt: '', provider: { type: 'mock', model: 'mock-1' } },
+ setGraph: vi.fn(),
+ setSelectedNodeId: vi.fn(),
+ switchAgent: vi.fn(),
+}));
+vi.mock('../../state/AppState', () => ({ useAppState: () => appState }));
+
+import { render } from '../../ui/__tests__/render';
+import { EmptyCanvas } from '../EmptyCanvas';
+import { AGENT_TEMPLATES } from '../templates';
+import { specToGraph } from '../../graph/specToGraph';
+
+beforeEach(() => {
+ appState.graph = { version: 1, nodes: [], edges: [] };
+ appState.setGraph.mockReset();
+ appState.setSelectedNodeId.mockReset();
+ appState.switchAgent.mockReset();
+});
+
+describe('EmptyCanvas', () => {
+ it('lists every template, by name and description, when the graph has no nodes', () => {
+ const container = render();
+ for (const template of AGENT_TEMPLATES) {
+ const button = [...container.querySelectorAll('button')].find((b) => b.textContent?.includes(template.name));
+ expect(button?.textContent).toContain(template.description);
+ }
+ });
+
+ it('steps aside while something is dragged, so a palette drop lands on the canvas', () => {
+ const container = render();
+ act(() => {
+ window.dispatchEvent(new Event('dragstart'));
+ });
+ expect(container.querySelector('.canvas-empty')).toBeNull();
+ act(() => {
+ window.dispatchEvent(new Event('dragend'));
+ });
+ expect(container.querySelector('.canvas-empty')).not.toBeNull();
+ });
+
+ it('renders nothing once the canvas has a node', () => {
+ appState.graph = specToGraph(appState.spec);
+ const container = render();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('fills the canvas from the chosen template through setGraph, keeping the agent name', () => {
+ const container = render();
+ const supportBot = [...container.querySelectorAll('button')].find((b) => b.textContent?.includes('Support bot'))!;
+ act(() => supportBot.click());
+ expect(appState.setGraph).toHaveBeenCalledTimes(1);
+ const next = appState.setGraph.mock.calls[0][0](appState.graph) as AgentGraphSpec;
+ expect(next.nodes.map((n) => n.type).sort()).toEqual(['llm', 'output', 'tool', 'trigger']);
+ const llm = next.nodes.find((n) => n.type === 'llm');
+ expect(llm?.type === 'llm' && llm.data.name).toBe('my-agent');
+ expect(appState.setSelectedNodeId).toHaveBeenCalledWith(llm?.id);
+ expect(appState.switchAgent).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap
new file mode 100644
index 00000000..140c4850
--- /dev/null
+++ b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AgentNode card > renders the LLM card markup 1`] = `"
🧠support-bot
openai/gpt-4o-mini1 toolawaiting approval
"`;
diff --git a/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts
new file mode 100644
index 00000000..fca09b47
--- /dev/null
+++ b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest';
+import { nodeRunStatuses } from '../nodeStatus';
+import { specToGraph } from '../../graph/specToGraph';
+import type { AgentRunStatusPayload, SpanEvent } from '../../../shared/wireTypes';
+
+const graph = specToGraph({
+ name: 'bot',
+ prompt: 'p',
+ provider: { type: 'mock', model: 'mock-1' },
+ tools: ['http'],
+});
+const llmId = graph.nodes.find((n) => n.type === 'llm')!.id;
+const toolId = graph.nodes.find((n) => n.type === 'tool')!.id;
+
+const chat = (over: Partial = {}): SpanEvent => ({
+ id: 'chat-1',
+ name: 'chat',
+ parentId: 'root',
+ startTime: 1,
+ attributes: { 'gen_ai.operation.name': 'chat' },
+ ...over,
+});
+const tool = (over: Partial = {}): SpanEvent => ({
+ id: 'tool-1',
+ name: 'execute_tool http',
+ parentId: 'root',
+ startTime: 2,
+ attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'http' },
+ ...over,
+});
+const status = (s: AgentRunStatusPayload['status'], over: Partial = {}): AgentRunStatusPayload =>
+ ({ agentId: 'bot', status: s, updatedAt: '', ...over }) as AgentRunStatusPayload;
+
+describe('nodeRunStatuses', () => {
+ it('is idle for every node before any run', () => {
+ const statuses = nodeRunStatuses(graph, [], undefined);
+ expect(graph.nodes.every((n) => statuses[n.id] === 'idle')).toBe(true);
+ });
+
+ it('marks the node of an open span running, and a finished one done', () => {
+ const statuses = nodeRunStatuses(graph, [chat({ endTime: 5 }), tool()], status('running'));
+ expect(statuses[llmId]).toBe('done');
+ expect(statuses[toolId]).toBe('running');
+ });
+
+ it('reads an open span as running only while the run is running', () => {
+ for (const s of ['stopped', 'done', 'error', 'idle'] as const) {
+ expect(nodeRunStatuses(graph, [tool()], status(s))[toolId]).toBe('idle');
+ }
+ expect(nodeRunStatuses(graph, [tool()], undefined)[toolId]).toBe('idle');
+ expect(nodeRunStatuses(graph, [tool()], status('paused'))[toolId]).toBe('paused');
+ });
+
+ it('marks a failed span error', () => {
+ const statuses = nodeRunStatuses(graph, [chat({ endTime: 5, status: { code: 'error' } })], status('error'));
+ expect(statuses[llmId]).toBe('error');
+ expect(statuses[toolId]).toBe('idle');
+ });
+
+ it('marks the tool awaiting approval paused', () => {
+ const paused = status('paused', { pendingApproval: { toolName: 'http', args: {} } as AgentRunStatusPayload['pendingApproval'] });
+ const statuses = nodeRunStatuses(graph, [chat({ endTime: 5 })], paused);
+ expect(statuses[toolId]).toBe('paused');
+ });
+});
diff --git a/apps/agent-forge/src/canvas/nodeStatus.ts b/apps/agent-forge/src/canvas/nodeStatus.ts
new file mode 100644
index 00000000..15e7677e
--- /dev/null
+++ b/apps/agent-forge/src/canvas/nodeStatus.ts
@@ -0,0 +1,43 @@
+import type { AgentGraphSpec } from '../graph/types';
+import type { AgentRunStatusPayload, SpanEvent } from '../../shared/wireTypes';
+import { nodeIdForSpan } from '../components/debug/nodeLookup';
+
+/** A node card's status, in the builder's StatusPill vocabulary. */
+export type NodeRunStatus = 'idle' | 'running' | 'paused' | 'done' | 'error';
+
+function spanStatus(span: SpanEvent, runStatus: AgentRunStatusPayload | undefined): NodeRunStatus {
+ if (span.endTime === undefined) {
+ // An open span is live only while its run is: a stopped or finished run's never-ended span is not running.
+ const run = runStatus?.status;
+ return run === 'running' || run === 'paused' ? run : 'idle';
+ }
+ return span.status?.code === 'error' ? 'error' : 'done';
+}
+
+/** An open span outranks a failed one, which outranks a finished one. */
+const RANK: Record = { idle: 0, done: 1, error: 2, paused: 3, running: 4 };
+
+/**
+ * Each node's status in the current run, from the live span feed: the node
+ * of an open span is running while the run is running (paused while it is
+ * paused, idle once it stopped or ended), a node whose spans
+ * all ended is done (error if one failed), the tool a run is paused on for
+ * approval is paused, and every other node is idle.
+ */
+export function nodeRunStatuses(
+ graph: AgentGraphSpec,
+ spans: SpanEvent[],
+ runStatus: AgentRunStatusPayload | undefined
+): Record {
+ const statuses: Record = Object.fromEntries(graph.nodes.map((n) => [n.id, 'idle']));
+ const raise = (nodeId: string | undefined, status: NodeRunStatus) => {
+ if (nodeId && nodeId in statuses && RANK[status] > RANK[statuses[nodeId]]) statuses[nodeId] = status;
+ };
+ for (const span of spans) raise(nodeIdForSpan(graph, span), spanStatus(span, runStatus));
+ const pending = runStatus?.status === 'paused' ? runStatus.pendingApproval : undefined;
+ if (pending) {
+ const tool = graph.nodes.find((n) => n.type === 'tool' && n.data.toolName === pending.toolName);
+ raise(tool?.id, 'paused');
+ }
+ return statuses;
+}
diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx
index a937df22..ebab2ef3 100644
--- a/apps/agent-forge/src/components/BottomDrawer.tsx
+++ b/apps/agent-forge/src/components/BottomDrawer.tsx
@@ -9,6 +9,7 @@ import { DebugBar } from './debug/DebugBar';
import { ChatPanel } from './ChatPanel';
import { SettingsPanel } from './SettingsPanel';
import { tabIds, tabListKeyDown } from './tabs';
+import { DrawerResizeHandle, useDrawerHeight } from './drawerHeight';
const TABS: { id: DrawerTab; label: string }[] = [
{ id: 'chat', label: 'Chat' },
@@ -40,15 +41,18 @@ const FLUSH_BODY_STYLE: CSSProperties = { padding: 0, overflow: 'hidden' };
* collapsible JSON tree of the final/paused ExecutionResult). `DebugBar`
* surfaces above Trace whenever debug mode is on or a run is paused
* at a breakpoint. Chat and Settings are still later
- * epics' jobs.
+ * epics' jobs. Its top edge resizes it; the height is remembered per viewer
+ * (see drawerHeight.tsx).
*/
export function BottomDrawer() {
const { drawerTab, setDrawerTab } = useAppState();
const ids = tabIds('drawer', drawerTab);
+ const { height, setHeight } = useDrawerHeight();
// A real WAI-ARIA tablist (roles, aria-selected, arrow keys).
return (
-
+
+