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
99 changes: 98 additions & 1 deletion apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, type Page, type Route } from '@playwright/test';
import { test, expect, type Page, type Route, type WebSocketRoute } from '@playwright/test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -275,3 +275,100 @@ test('x: 375 wide, Traces with the nav drawer closed and open', async ({ page })
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(OUT, 'x-narrow-drawer-open.png') });
});

/*
* Review-only shots of the Agents canvas (the `x-` prefix keeps them out of
* the docs set): an empty canvas, a graph with a node selected, and a run in
* progress. The run is staged by injecting a live status and spans into the
* agent's stream socket, so nodes and edges hold their running state.
*/
async function newAgent(page: Page, name: string, template?: 'support-bot') {
await open(page, '/agents');
await page.getByRole('button', { name: '+ New agent' }).click();
await page.locator('#new-agent-name').fill(name);
if (template) await page.locator('#new-agent-template').selectOption(template);
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.locator('.crumbs b')).toHaveText(name);
}

test('agents x: the empty canvas with its templates', async ({ page }) => {
await newAgent(page, `empty-${Date.now()}`);
const nodes = page.locator('.react-flow__node');
while ((await nodes.count()) > 0) {
const before = await nodes.count();
await nodes.first().click();
await page.keyboard.press('Delete');
await expect(nodes).toHaveCount(before - 1);
}
await expect(page.getByRole('list', { name: 'Templates' })).toBeVisible();
await shoot(page, 'x-agents-empty');
});

test('agents x: a graph with a node selected', async ({ page }) => {
await newAgent(page, `selected-${Date.now()}`, 'support-bot');
// The whole graph is in view on load, trigger included.
const triggerInPane = () =>
page.locator('[data-node-type="trigger"]').evaluate((el) => {
const node = el.getBoundingClientRect();
const pane = el.closest('.react-flow')!.getBoundingClientRect();
return node.left >= pane.left && node.right <= pane.right && node.top >= pane.top && node.bottom <= pane.bottom;
});
await expect.poll(triggerInPane).toBe(true);
// An edge can be selected (drawn in accent); selecting a node then moves the selection.
const edge = page.locator('.react-flow__edge').nth(1);
await edge.locator('.react-flow__edge-interaction').click({ force: true });
await expect(edge).toHaveClass(/selected/);
await page.locator('[data-node-type="tool"]').click();
await expect(page.locator('.react-flow__node.selected [data-node-type="tool"]')).toBeVisible();
await shoot(page, 'x-agents-selected');
});

test('agents x: a run in progress', async ({ page }) => {
const sockets: WebSocketRoute[] = [];
const heardFromServer = new Set<WebSocketRoute>();
await page.routeWebSocket(/\/stream/, (ws) => {
const server = ws.connectToServer();
// Forward the server's frames as usual, noting each socket's on-connect status.
server.onMessage((message) => {
ws.send(message);
if (typeof message === 'string' && message.includes('"type":"status"')) heardFromServer.add(ws);
});
sockets.push(ws);
});
const name = `running-${Date.now()}`;
await newAgent(page, name, 'support-bot');
await expect(page.locator('[data-node-type="tool"]')).toBeVisible();
// The builder and the rail each hold a socket for the open agent; both get the run.
const own = () => sockets.filter((s) => s.url().includes(`/agents/${name}/stream`));
await expect.poll(() => own().length).toBeGreaterThan(1);
// The server's own on-connect status must land first, so ours is the last word.
await expect.poll(() => own().every((ws) => heardFromServer.has(ws))).toBe(true);
const start = Date.now() - 1_200;
const send = (message: unknown) => own().forEach((ws) => ws.send(JSON.stringify(message)));
send({ type: 'status', payload: { agentId: name, status: 'running' } });
send({
type: 'span',
payload: { id: 'x-tool', name: 'execute_tool http', startTime: start, endTime: start + 400, attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'http' } },
});
send({
type: 'span',
payload: { id: 'x-chat', name: 'chat mock-1', startTime: start, kind: 'client', attributes: { 'gen_ai.operation.name': 'chat' } },
});
await expect(page.locator('[data-node-type="llm"] .status-pill')).toHaveText('running');

// A breakpoint on the running LLM node, which is also selected: every state ring stays visible.
send({ type: 'debug', payload: { agentId: name, paused: false, messages: [], stepCount: 0, breakpoints: ['llm:before'] } });
const llm = page.locator('[data-node-type="llm"]');
await llm.click();
await expect(llm).toHaveClass(/running/);
await expect(llm).toHaveClass(/has-breakpoint/);
await expect(llm).toHaveClass(/selected/);
const rings = await llm.evaluate((el) => {
const s = getComputedStyle(el);
return { outline: s.outlineStyle, shadow: s.boxShadow, pulse: getComputedStyle(el, '::after').animationName };
});
expect(rings.outline).toBe('solid'); // breakpoint
expect(rings.shadow).toMatch(/0px 0px 0px 3px/); // selected --ring
expect(rings.pulse).toBe('rf-node-running'); // running, on its own layer
await shoot(page, 'x-agents-running');
});
119 changes: 67 additions & 52 deletions apps/agent-forge/src/canvas/AgentNode.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
import { memo, useState, type KeyboardEvent } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Flag, GitBranch, ShieldCheck, Sparkles, Wrench, Zap, type LucideIcon } from 'lucide-react';
import type { AgentGraphNode, AgentGraphNodeType } from '../graph/types';
import { StatusPill } from '../ui/StatusPill';

/**
* Custom ReactFlow node renderer for every `AgentGraphNodeType`: a colored
* type-swatch header with the node's name, a meta line (an LLM node's
* `provider/model` chip and tool count, and the node's run status pill), a
* Custom ReactFlow node renderer for every `AgentGraphNodeType`: a 240px card
* whose header carries a kind icon tile, the node's name and its run status
* pill; a meta row (an LLM node's `provider/model` chip and tool count); a
* body of key/value config rows read straight from the node's
* `AgentGraphSpec` data (nothing hardcoded per-node), and
* left/right ports drawn with ReactFlow's own `<Handle>` so pipeline
* connections attach to a real, draggable anchor rather than a picture of
* one.
* `AgentGraphSpec` data (nothing hardcoded per-node); and left/right ports
* drawn with ReactFlow's own `<Handle>` so pipeline connections attach to a
* real, draggable anchor rather than a picture of one.
*
* One component + a `type` field handles all five kinds rather than five
* One component + a `type` field handles all six kinds rather than six
* near-duplicate components, since the only real differences are the
* swatch color/icon, which ports are shown, and which data rows to render.
* kind tile's tone/icon, which ports are shown, and which data rows to render.
*/

const NODE_META: Record<AgentGraphNodeType, { color: string; icon: string; hasIn: boolean; hasOut: boolean }> = {
trigger: { color: 'var(--info)', icon: '⚡', hasIn: false, hasOut: true },
llm: { color: 'var(--accent)', icon: '🧠', hasIn: true, hasOut: true },
tool: { color: 'var(--warning)', icon: '🔧', hasIn: true, hasOut: true },
approval: { color: 'var(--warning)', icon: '✓', hasIn: true, hasOut: true },
output: { color: 'var(--success)', icon: '→', hasIn: true, hasOut: false },
// Router shares tool's warning-orange swatch family (both are
// "control" nodes) but with its own icon so it reads
// distinctly on the canvas; `hasOut: true` matters here more than
/** A kind tile's colour family: `kind-<tone>` in screens/agents/agents.css (also the MiniMap's node fill). */
type KindTone = 'accent' | 'violet' | 'info' | 'warning' | 'success' | 'neutral';

const NODE_META: Record<AgentGraphNodeType, { tone: KindTone; Icon: LucideIcon; hasIn: boolean; hasOut: boolean }> = {
trigger: { tone: 'accent', Icon: Zap, hasIn: false, hasOut: true },
llm: { tone: 'violet', Icon: Sparkles, hasIn: true, hasOut: true },
tool: { tone: 'info', Icon: Wrench, hasIn: true, hasOut: true },
// The approval gate is the canvas's "hook" kind: warning, like a pause.
approval: { tone: 'warning', Icon: ShieldCheck, hasIn: true, hasOut: true },
output: { tone: 'success', Icon: Flag, hasIn: true, hasOut: false },
// Router is neutral control flow; `hasOut: true` matters here more than
// anywhere else - it's the one node type actually expected to carry
// MULTIPLE outgoing edges (its branches), which ReactFlow already
// supports from a single source `Handle` (see llm -> multiple tool nodes,
// an existing pattern this reuses rather than needing per-branch ports).
router: { color: 'var(--danger)', icon: '⑂', hasIn: true, hasOut: true },
router: { tone: 'neutral', Icon: GitBranch, hasIn: true, hasOut: true },
};

type NodeRow = { k: string; v: string };
Expand Down Expand Up @@ -100,8 +102,14 @@ function useRenameDraft(graphNode: AgentGraphNode, onRename: (nodeId: string, la

type RenameDraft = ReturnType<typeof useRenameDraft>;

function nodeClassName(selected: boolean | undefined, highlighted?: boolean, hasBreakpoint?: boolean): string {
return `rf-node${selected ? ' selected' : ''}${highlighted ? ' highlighted' : ''}${hasBreakpoint ? ' has-breakpoint' : ''}`;
function nodeClassName(selected: boolean | undefined, data: Pick<AgentNodeData, 'highlighted' | 'hasBreakpoint' | 'status'>): string {
const flags: [string, boolean | undefined][] = [
['selected', selected],
['highlighted', data.highlighted],
['has-breakpoint', data.hasBreakpoint],
['running', data.status === 'running'],
];
return ['rf-node', ...flags.filter(([, on]) => on).map(([name]) => name)].join(' ');
}

// Pre·N / post·N badges (`.hook-badge`) - only
Expand Down Expand Up @@ -151,7 +159,7 @@ function NodeTitle({ label, rename }: { label: string; rename: RenameDraft }) {
);
}
return (
<span className="rf-node-title" onDoubleClick={rename.start} title="Double-click to rename">
<span className="rf-node-title" onDoubleClick={rename.start} title={`${label} (double-click to rename)`}>
{label}
</span>
);
Expand All @@ -163,55 +171,62 @@ function modelChipOf(graphNode: AgentGraphNode): string | undefined {
return `${orUnset(graphNode.data.provider.type)}/${orUnset(graphNode.data.provider.model)}`;
}

function NodeMeta({ graphNode, toolCount = 0, status }: Pick<AgentNodeData, 'graphNode' | 'toolCount' | 'status'>) {
/** The model chip and tool count; nothing at all for a node with neither. */
function NodeMeta({ graphNode, toolCount = 0 }: Pick<AgentNodeData, 'graphNode' | 'toolCount'>) {
const model = modelChipOf(graphNode);
if (!model && toolCount === 0) return null;
return (
<div className="rf-node-meta">
{model && (
<code className="node-chip node-model" title="Provider/model">
<code className="node-chip node-model" title={`Provider/model: ${model}`}>
{model}
</code>
)}
{toolCount > 0 && <span className="node-chip node-tools">{toolCount === 1 ? '1 tool' : `${toolCount} tools`}</span>}
<span className="rf-node-meta-spacer" />
<StatusPill status={status ?? 'idle'} />
{toolCount > 0 && (
<span className="node-chip node-tools">
<Wrench size={12} strokeWidth={1.75} aria-hidden="true" />
{toolCount === 1 ? '1 tool' : `${toolCount} tools`}
</span>
)}
</div>
);
}

function NodeRows({ rows }: { rows: NodeRow[] }) {
// No empty body box under a node with nothing to show (llm, output, router).
if (rows.length === 0) return null;
return (
<div className="rf-node-body">
{rows.map((row) => (
<div className="node-row" key={row.k}>
<span>{row.k}</span>
<code title={row.v}>{row.v}</code>
</div>
))}
</div>
);
}

function AgentNodeImpl({ data, selected }: NodeProps) {
const { graphNode, onRename, highlighted, hasBreakpoint, toolCount, status } = data as unknown as AgentNodeData;
const meta = NODE_META[graphNode.type];
const nodeData = data as unknown as AgentNodeData;
const { graphNode, onRename, toolCount, status } = nodeData;
const { tone, Icon, hasIn, hasOut } = NODE_META[graphNode.type];
const rename = useRenameDraft(graphNode, onRename);
const rows = rowsFor(graphNode);

return (
<div
className={nodeClassName(selected, highlighted, hasBreakpoint)}
data-node-type={graphNode.type}
data-node-id={graphNode.id}
>
{meta.hasIn && <Handle type="target" position={Position.Left} className="rf-port rf-port-in" />}
{meta.hasOut && <Handle type="source" position={Position.Right} className="rf-port rf-port-out" />}
<div className={nodeClassName(selected, nodeData)} data-node-type={graphNode.type} data-node-id={graphNode.id}>
{hasIn && <Handle type="target" position={Position.Left} className="rf-port rf-port-in" />}
{hasOut && <Handle type="source" position={Position.Right} className="rf-port rf-port-out" />}
<HookBadges graphNode={graphNode} />
<div className="rf-node-head">
<span className="node-swatch" style={{ background: meta.color, width: 16, height: 16, borderRadius: 5 }}>
{meta.icon}
<span className={`node-kind kind-${tone}`} aria-hidden="true">
<Icon size={14} strokeWidth={1.75} />
</span>
<NodeTitle label={graphNode.label} rename={rename} />
<StatusPill status={status ?? 'idle'} />
</div>
<NodeMeta graphNode={graphNode} toolCount={toolCount} status={status} />
{/* No empty body box under a node with nothing to show (llm, output, router). */}
{rows.length > 0 && (
<div className="rf-node-body">
{rows.map((row) => (
<div className="node-row" key={row.k}>
<span>{row.k}</span>
<code>{row.v}</code>
</div>
))}
</div>
)}
<NodeMeta graphNode={graphNode} toolCount={toolCount} />
<NodeRows rows={rowsFor(graphNode)} />
</div>
);
}
Expand All @@ -235,7 +250,7 @@ export const NODE_TYPES: Record<string, typeof AgentNode> = Object.fromEntries(
GRAPH_NODE_TYPES.map((type) => [flowNodeType(type), AgentNode])
);

/** The MiniMap draws each node in its type's swatch colour (see `.minimap-node-*` in screens/agents/agents.css). */
/** The MiniMap draws each node in its kind's tile colour (see `.minimap-node-*` in screens/agents/agents.css). */
export function miniMapNodeClassName(node: { data: Record<string, unknown> }): string {
const graphNode = (node.data as Partial<AgentNodeData>).graphNode;
return graphNode ? `minimap-node-${graphNode.type}` : '';
Expand Down
52 changes: 39 additions & 13 deletions apps/agent-forge/src/canvas/EmptyCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useEffect, useState } from 'react';
import { LayoutTemplate, Plus, Sparkles, Zap, type LucideIcon } from 'lucide-react';
import { useAppState } from '../state/AppState';
import { EmptyState } from '../ui/EmptyState';
import { AGENT_TEMPLATES, graphFromTemplate, type TemplateId } from './templates';

/**
* Each template card's kind icon: the kind its pipeline starts with, in that
* kind's canvas tone (AgentNode.tsx's NODE_META, `kind-<tone>` in agents.css).
*/
const TEMPLATE_ICONS: Record<TemplateId, { Icon: LucideIcon; tone: 'violet' | 'accent' }> = {
blank: { Icon: Sparkles, tone: 'violet' },
'support-bot': { Icon: Zap, tone: 'accent' },
};

/** True between a drag's start and its end (or drop) anywhere in the window. */
function useDragInProgress(): boolean {
const [dragging, setDragging] = useState(false);
Expand Down Expand Up @@ -43,22 +52,39 @@ export function EmptyCanvas() {

return (
<div className="canvas-empty">
<EmptyState
title="Start from a template"
hint="This agent's canvas is empty. Pick a template, or drag a node in from the Nodes palette."
action={
<ul className="canvas-templates" aria-label="Templates">
{AGENT_TEMPLATES.map((template) => (
<section className="canvas-empty-card" aria-labelledby="canvas-empty-title">
<span className="canvas-empty-icon" aria-hidden="true">
<LayoutTemplate size={20} strokeWidth={1.75} />
</span>
<h2 className="canvas-empty-title" id="canvas-empty-title">
Start from a template
</h2>
<p className="canvas-empty-hint">
This agent&apos;s canvas is empty. Pick a template, or drag a node in from the Nodes palette.
</p>
<button type="button" className="btn btn-primary canvas-empty-action" onClick={() => choose('blank')}>
<Plus aria-hidden="true" />
Start with a blank graph
</button>
<ul className="canvas-templates" aria-label="Templates">
{AGENT_TEMPLATES.map((template) => {
const { Icon, tone } = TEMPLATE_ICONS[template.id];
return (
<li key={template.id}>
<button type="button" className="canvas-template" onClick={() => choose(template.id)}>
<span className="canvas-template-name">{template.name}</span>
<span className="canvas-template-desc">{template.description}</span>
<span className={`node-kind kind-${tone}`} aria-hidden="true">
<Icon size={14} strokeWidth={1.75} />
</span>
<span className="canvas-template-text">
<span className="canvas-template-name">{template.name}</span>
<span className="canvas-template-desc">{template.description}</span>
</span>
</button>
</li>
))}
</ul>
}
/>
);
})}
</ul>
</section>
</div>
);
}
Loading
Loading