From 795103570e1a491cbfd057bda537c0438f5da8be Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 08:11:23 +0300 Subject: [PATCH 1/3] feat(studio): docShots review shots of the Agents canvas (empty, selected, running) Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 66 ++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index 8304a0f5..513cc125 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -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'; @@ -275,3 +275,67 @@ 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'); + 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[] = []; + await page.routeWebSocket(/\/stream/, (ws) => { + ws.connectToServer(); + 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); + // Let the server's own on-connect status land first, so ours is the last word. + await page.waitForTimeout(500); + 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'); + await shoot(page, 'x-agents-running'); +}); From 23ee3d72b476a481f3c1325d83afb0354587f397 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 08:25:01 +0300 Subject: [PATCH 2/3] style(studio): restyle the Agents canvas (nodes, edges, toolbar, controls, minimap, empty state) 240px node cards with a kind icon tile (lucide), status pill in the header, running/selected states; smoothstep edges with arrows, accent and animated while their source runs; an icon-only segmented toolbar; Controls and MiniMap themed through xyflow variables so they follow dark mode; a designed empty state with template cards. Graph column spacing and auto-layout/palette node sizes follow the wider card. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/src/canvas/AgentNode.tsx | 119 +++--- apps/agent-forge/src/canvas/EmptyCanvas.tsx | 45 +- .../src/canvas/__tests__/AgentNode.test.tsx | 18 + .../__snapshots__/AgentNode.test.tsx.snap | 2 +- .../src/canvas/__tests__/flowNodes.test.ts | 30 +- .../canvas/__tests__/paletteActions.test.ts | 6 +- apps/agent-forge/src/canvas/layout.ts | 2 +- apps/agent-forge/src/canvas/paletteActions.ts | 2 +- .../agent-forge/src/components/CanvasArea.tsx | 103 +++-- apps/agent-forge/src/graph/specToGraph.ts | 2 +- .../agent-forge/src/screens/agents/agents.css | 396 +++++++++++++----- 11 files changed, 515 insertions(+), 210 deletions(-) diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx index 2173dbaa..1fa2103b 100644 --- a/apps/agent-forge/src/canvas/AgentNode.tsx +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -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 `` 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 `` 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 = { - 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-` in screens/agents/agents.css (also the MiniMap's node fill). */ +type KindTone = 'accent' | 'violet' | 'info' | 'warning' | 'success' | 'neutral'; + +const NODE_META: Record = { + 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 }; @@ -100,8 +102,14 @@ function useRenameDraft(graphNode: AgentGraphNode, onRename: (nodeId: string, la type RenameDraft = ReturnType; -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): 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 @@ -151,7 +159,7 @@ function NodeTitle({ label, rename }: { label: string; rename: RenameDraft }) { ); } return ( - + {label} ); @@ -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) { +/** The model chip and tool count; nothing at all for a node with neither. */ +function NodeMeta({ graphNode, toolCount = 0 }: Pick) { const model = modelChipOf(graphNode); + if (!model && toolCount === 0) return null; return (
{model && ( - + {model} )} - {toolCount > 0 && {toolCount === 1 ? '1 tool' : `${toolCount} tools`}} - - + {toolCount > 0 && ( + + + )} +
+ ); +} + +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 ( +
+ {rows.map((row) => ( +
+ {row.k} + {row.v} +
+ ))}
); } 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 ( -
- {meta.hasIn && } - {meta.hasOut && } +
+ {hasIn && } + {hasOut && }
- - {meta.icon} + +
- - {/* No empty body box under a node with nothing to show (llm, output, router). */} - {rows.length > 0 && ( -
- {rows.map((row) => ( -
- {row.k} - {row.v} -
- ))} -
- )} + +
); } @@ -235,7 +250,7 @@ export const NODE_TYPES: Record = 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 { const graphNode = (node.data as Partial).graphNode; return graphNode ? `minimap-node-${graphNode.type}` : ''; diff --git a/apps/agent-forge/src/canvas/EmptyCanvas.tsx b/apps/agent-forge/src/canvas/EmptyCanvas.tsx index b5c5cb44..a93a2b1b 100644 --- a/apps/agent-forge/src/canvas/EmptyCanvas.tsx +++ b/apps/agent-forge/src/canvas/EmptyCanvas.tsx @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react'; +import { LayoutTemplate, MessagesSquare, Sparkles, 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 icon and kind tone (the tone of the node it opens on: `kind-` in agents.css). */ +const TEMPLATE_ICONS: Record = { + blank: { Icon: Sparkles, tone: 'violet' }, + 'support-bot': { Icon: MessagesSquare, tone: 'info' }, +}; + /** True between a drag's start and its end (or drop) anywhere in the window. */ function useDragInProgress(): boolean { const [dragging, setDragging] = useState(false); @@ -43,22 +49,35 @@ export function EmptyCanvas() { return (
- - {AGENT_TEMPLATES.map((template) => ( +
+ +

+ Start from a template +

+

+ This agent's canvas is empty. Pick a template, or drag a node in from the Nodes palette. +

+
    + {AGENT_TEMPLATES.map((template) => { + const { Icon, tone } = TEMPLATE_ICONS[template.id]; + return (
  • - ))} -
- } - /> + ); + })} + +
); } diff --git a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx index 14a9b337..e6f833a1 100644 --- a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx +++ b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx @@ -46,6 +46,24 @@ describe('AgentNode card', () => { expect(card.querySelector('.node-row code')?.textContent).toBe('http'); }); + it('marks a running node and gives each kind its own icon tile tone', () => { + const llm = renderCard(nodeOf('llm'), { status: 'running' }); + expect(llm.classList.contains('running')).toBe(true); + expect(llm.querySelector('.node-kind')?.classList.contains('kind-violet')).toBe(true); + expect(llm.querySelector('.node-kind svg')).not.toBeNull(); + const tool = renderCard(nodeOf('tool'), { status: 'done' }); + expect(tool.classList.contains('running')).toBe(false); + expect(tool.querySelector('.node-kind')?.classList.contains('kind-info')).toBe(true); + }); + + it('keeps a long name whole in its tooltip while the card truncates it', () => { + const long = 'x'.repeat(80); + const card = renderCard({ ...nodeOf('llm'), label: long }); + const title = card.querySelector('.rf-node-title'); + expect(title?.textContent).toBe(long); + expect(title?.getAttribute('title')).toContain(long); + }); + 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__/__snapshots__/AgentNode.test.tsx.snap b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap index 0c73309d..faa43e78 100644 --- a/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap +++ b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap @@ -1,3 +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
"`; +exports[`AgentNode card > renders the LLM card markup 1`] = `"
support-botawaiting approval
openai/gpt-4o-mini1 tool
"`; diff --git a/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts b/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts index 073fbb64..1daee54d 100644 --- a/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts +++ b/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import type { NodeChange } from '@xyflow/react'; import { NODE_TYPES, flowNodeType, miniMapNodeClassName } from '../AgentNode'; -import { applyDimensionChanges } from '../../components/CanvasArea'; +import { applyDimensionChanges, toRfEdges } from '../../components/CanvasArea'; +import { specToGraph } from '../../graph/specToGraph'; import type { AgentGraphNodeType } from '../../graph/types'; const GRAPH_TYPES: AgentGraphNodeType[] = ['trigger', 'llm', 'tool', 'approval', 'output', 'router']; @@ -38,3 +39,30 @@ describe('applyDimensionChanges', () => { expect(applyDimensionChanges(prev, [{ type: 'remove', id: 'b' }])).toBe(prev); }); }); + +describe('toRfEdges', () => { + const graph = specToGraph({ name: 'a', prompt: 'p', provider: { type: 'mock', model: 'mock-1' }, tools: ['http'], triggers: [{ type: 'input' }] }); + const llm = graph.nodes.find((n) => n.type === 'llm')!; + + it('draws smoothstep edges with a closed arrow, still while nothing runs', () => { + const edges = toRfEdges(graph); + expect(edges).toHaveLength(graph.edges.length); + for (const edge of edges) { + expect(edge.type).toBe('smoothstep'); + expect(edge.animated).toBe(false); + expect(edge.markerEnd).toMatchObject({ type: 'arrowclosed', color: 'var(--border-strong)' }); + } + }); + + it("animates the edges leaving a running node in the accent colour", () => { + const edges = toRfEdges(graph, { [llm.id]: 'running' }); + const fromLlm = edges.filter((e) => e.source === llm.id); + expect(fromLlm.length).toBeGreaterThan(0); + for (const edge of fromLlm) { + expect(edge.animated).toBe(true); + expect(edge.className).toBe('edge-running'); + expect(edge.markerEnd).toMatchObject({ color: 'var(--accent)' }); + } + for (const edge of edges.filter((e) => e.source !== llm.id)) expect(edge.animated).toBe(false); + }); +}); diff --git a/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts b/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts index 62f26740..29c4f818 100644 --- a/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts +++ b/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts @@ -4,16 +4,16 @@ import { nextTabId, tabIds } from '../../components/tabs'; describe('viewportCentrePosition', () => { it('centres a node under the middle of an unpanned, unzoomed pane', () => { - expect(viewportCentrePosition([0, 0, 1], 800, 600)).toEqual({ x: 305, y: 260 }); + expect(viewportCentrePosition([0, 0, 1], 800, 600)).toEqual({ x: 280, y: 260 }); }); it('accounts for pan and zoom', () => { // Panned 100px right/50px down at 2x: the pane centre (400, 300) is flow (150, 125). - expect(viewportCentrePosition([100, 50, 2], 800, 600)).toEqual({ x: 150 - 95, y: 125 - 40 }); + expect(viewportCentrePosition([100, 50, 2], 800, 600)).toEqual({ x: 150 - 120, y: 125 - 40 }); }); it('survives a zero zoom before the pane has measured', () => { - expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -95, y: -40 }); + expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -120, y: -40 }); }); }); diff --git a/apps/agent-forge/src/canvas/layout.ts b/apps/agent-forge/src/canvas/layout.ts index 35fd7e7d..d1a017e5 100644 --- a/apps/agent-forge/src/canvas/layout.ts +++ b/apps/agent-forge/src/canvas/layout.ts @@ -1,7 +1,7 @@ import dagre from 'dagre'; import type { AgentGraphSpec } from '../graph/types'; -const NODE_WIDTH = 190; +const NODE_WIDTH = 240; const NODE_HEIGHT = 96; /** diff --git a/apps/agent-forge/src/canvas/paletteActions.ts b/apps/agent-forge/src/canvas/paletteActions.ts index 02758b7b..412b201b 100644 --- a/apps/agent-forge/src/canvas/paletteActions.ts +++ b/apps/agent-forge/src/canvas/paletteActions.ts @@ -2,7 +2,7 @@ import { HOOK_TEMPLATES, type HookTemplate } from '../hooks/hookTemplates'; import type { AgentNodeHookPhase, GraphPosition } from '../graph/types'; /** Approximate rendered size of an `.rf-node` (screens/agents/agents.css), used to centre a newly added node. */ -const NODE_SIZE = { width: 190, height: 80 }; +const NODE_SIZE = { width: 240, height: 80 }; /** * Where a palette item added from the keyboard (or a click) diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index 8e61772c..b159b19d 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -4,6 +4,7 @@ import { Background, BackgroundVariant, Controls, + MarkerType, MiniMap, ReactFlow, useReactFlow, @@ -14,6 +15,7 @@ import { type NodeChange, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import { Copy, LayoutGrid, Maximize, type LucideIcon } from 'lucide-react'; import { useAppState } from '../state/AppState'; import { isEditableTarget } from '../canvas/isEditableTarget'; import { NODE_TYPES, flowNodeType, miniMapNodeClassName, type AgentNodeData } from '../canvas/AgentNode'; @@ -100,8 +102,24 @@ function toRfNodes(graph: AgentGraphSpec, decor: NodeDecor, measured: MeasuredSi })); } -function toRfEdges(graph: AgentGraphSpec): Edge[] { - return graph.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })); +/** + * Smoothstep edges ending in a closed arrow. An edge whose source node is + * running is animated in the accent colour (`.edge-running` in agents.css; + * reduced motion keeps the colour and drops the dash animation). + */ +export function toRfEdges(graph: AgentGraphSpec, statuses: Record = {}): Edge[] { + return graph.edges.map((e) => { + const running = statuses[e.source] === 'running'; + return { + id: e.id, + source: e.source, + target: e.target, + type: 'smoothstep', + animated: running, + className: running ? 'edge-running' : undefined, + markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: running ? 'var(--accent)' : 'var(--border-strong)' }, + }; + }); } /** Apply every ReactFlow position change that carries a position to the graph. */ @@ -327,6 +345,34 @@ function useToolbarActions(setGraph: SetGraph, selectedNodeId: string | undefine return { handleAutoLayout, handleFitView, handleDuplicate }; } +/** One icon button of the canvas toolbar: `label` is its accessible name, `tooltip` its title. */ +function ToolbarButton({ + Icon, + label, + tooltip, + onClick, + disabled, +}: { + Icon: LucideIcon; + label: string; + tooltip: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + function CanvasToolbar({ canDuplicate, onAutoLayout, @@ -339,25 +385,39 @@ function CanvasToolbar({ onDuplicate: () => void; }) { return ( -
- - - + />
); } +/** The MiniMap (bottom-right) and zoom/fit Controls (bottom-left), both themed in agents.css. */ +function CanvasNavigator() { + return ( + <> + + + + ); +} + /** * Real ReactFlow canvas. `AppState.graph` is the single source * of truth: every handler below reads it and, on a real mutation, calls @@ -384,7 +444,7 @@ function CanvasInner() { () => toRfNodes(graph, { selectedNodeId, onRename, highlightedNodeId, breakpoints, statuses }, measured), [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey, statuses, measured] ); - const edges = useMemo(() => toRfEdges(graph), [graph]); + const edges = useMemo(() => toRfEdges(graph, statuses), [graph, statuses]); const editHandlers = useGraphEditHandlers(setGraph, selectedNodeId, setSelectedNodeId, setMeasured); const { onConnect, isValidConnection } = useConnectionHandlers(graph, setGraph, flashError); @@ -420,15 +480,8 @@ function CanvasInner() { proOptions={{ hideAttribution: true }} > - - + {/* Nothing to overview or zoom on an empty canvas; the template card has the room. */} + {graph.nodes.length > 0 && } ); diff --git a/apps/agent-forge/src/graph/specToGraph.ts b/apps/agent-forge/src/graph/specToGraph.ts index 1e60f35f..30624f5e 100644 --- a/apps/agent-forge/src/graph/specToGraph.ts +++ b/apps/agent-forge/src/graph/specToGraph.ts @@ -21,7 +21,7 @@ function isSerializedHookArray(value: unknown): value is SerializedHook[] { ); } -const COLUMN_WIDTH = 260; +const COLUMN_WIDTH = 320; const ROW_HEIGHT = 140; /** diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index 958c81ed..cf324361 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -208,30 +208,59 @@ button.breakpoint-toggle { /* ---------- Canvas (real ReactFlow / @xyflow/react) ---------- */ +/* React Flow's own chrome (controls, minimap, edges, handles) is themed through + its --xy-* variables, so it follows the colour scheme like everything else. */ .canvas-wrap { position: relative; overflow: hidden; background: var(--bg); } +.canvas-wrap .react-flow { + --xy-background-color: var(--bg); + --xy-edge-stroke: var(--border-strong); + --xy-edge-stroke-selected: var(--accent); + --xy-edge-stroke-width: 1.5; + --xy-connectionline-stroke: var(--accent); + --xy-connectionline-stroke-width: 1.5; + --xy-handle-background-color: var(--surface); + --xy-handle-border-color: var(--border-strong); + --xy-controls-button-background-color: var(--surface); + --xy-controls-button-background-color-hover: var(--surface-2); + --xy-controls-button-color: var(--text-muted); + --xy-controls-button-color-hover: var(--text); + --xy-controls-button-border-color: var(--border); + --xy-controls-box-shadow: var(--shadow-sm); + --xy-minimap-background-color: var(--surface); + --xy-minimap-mask-background-color: color-mix(in srgb, var(--bg) 70%, transparent); + --xy-minimap-mask-stroke-color: var(--border-strong); + --xy-minimap-node-background-color: var(--text-faint); + --xy-selection-background-color: color-mix(in srgb, var(--accent) 8%, transparent); + --xy-selection-border: 1px dashed var(--accent); + --xy-attribution-background-color: transparent; +} .canvas-wrap .react-flow__background { background: var(--bg); } + +/* Auto layout / Fit view / Duplicate: one segmented card of icon buttons, top-left. */ .canvas-toolbar { position: absolute; - left: 12px; - top: 12px; + left: var(--space-3); + top: var(--space-3); z-index: 5; display: flex; - gap: 6px; + gap: 2px; background: var(--surface); border: 1px solid var(--border); - border-radius: 9px; - padding: 4px; - box-shadow: var(--shadow); + border-radius: var(--radius); + padding: 2px; + box-shadow: var(--shadow-sm); } -.canvas-toolbar .btn { - padding: 5px 8px; +.canvas-toolbar .btn-icon.btn-sm { + width: 30px; + min-height: 30px; } + /* Empty-canvas onboarding (canvas/EmptyCanvas.tsx), centred over the pane. */ .canvas-empty { position: absolute; @@ -240,51 +269,90 @@ button.breakpoint-toggle { display: flex; align-items: center; justify-content: center; - padding: 16px; + padding: var(--space-4); pointer-events: none; } -.canvas-empty .empty-state { +.canvas-empty-card { pointer-events: auto; - max-width: 420px; + max-width: 480px; width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + text-align: center; background: var(--surface); border: 1px solid var(--border); - border-radius: 11px; - box-shadow: var(--shadow); - padding: 20px 16px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--space-6) var(--space-5) var(--space-5); } -.canvas-empty .empty-state-action { - width: 100%; +.canvas-empty-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-lg); + background: var(--surface-2); + color: var(--text-muted); + margin-bottom: var(--space-1); +} +.canvas-empty-title { + margin: 0; + color: var(--text); + font-size: 14px; + font-weight: 600; +} +.canvas-empty-hint { + margin: 0; + max-width: 48ch; + color: var(--text-muted); + font-size: 13px; } .canvas-templates { list-style: none; - margin: 0; + width: 100%; + margin: var(--space-3) 0 0; padding: 0; - display: flex; - flex-direction: column; - gap: 6px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-2); text-align: left; } .canvas-template { width: 100%; + height: 100%; display: flex; - flex-direction: column; - gap: 2px; + align-items: flex-start; + gap: var(--space-3); text-align: left; - padding: 8px 10px; + padding: var(--space-3); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius); background: var(--surface); color: var(--text); font: inherit; font-size: 13px; cursor: pointer; + transition: + border-color var(--dur-fast) var(--ease), + background-color var(--dur-fast) var(--ease); +} +.canvas-template:hover { + border-color: var(--border-strong); + background: var(--surface-2); } -.canvas-template:hover, .canvas-template:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; border-color: var(--accent); - background: var(--accent-soft); - outline: none; +} +.canvas-template-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; } .canvas-template-name { font-weight: 600; @@ -296,181 +364,285 @@ button.breakpoint-toggle { .canvas-connect-error { position: absolute; left: 50%; - top: 12px; + top: var(--space-3); transform: translateX(-50%); z-index: 6; background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger); - border-radius: 8px; + border-radius: var(--radius); padding: 6px 12px; - font-size: 11.5px; + font-size: 12px; font-weight: 600; box-shadow: var(--shadow); } -/* ReactFlow's own MiniMap/Controls chrome, nudged to match the mockup - (minimap top-right, controls bottom-right) and re-themed with our tokens - rather than reimplementing either widget. */ -.react-flow__minimap { - background: var(--surface) !important; +/* Controls: a vertical card bottom-left. MiniMap: bottom-right, a card in the same family. */ +.canvas-wrap .react-flow__controls { border: 1px solid var(--border); - border-radius: 9px; - box-shadow: var(--shadow); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface); +} +.canvas-wrap .react-flow__controls-button { + width: 30px; + height: 30px; + padding: 0; } -/* minimap nodes in their type's swatch colour (AgentNode.tsx's - miniMapNodeClassName), and a mask that is visible on --surface in both - schemes - the default light-grey nodes and mask vanished on it. */ -.react-flow__minimap-mask { - fill: color-mix(in srgb, var(--text) 10%, transparent); +.canvas-wrap .react-flow__controls-button:last-child { + border-bottom: none; } +.canvas-wrap .react-flow__controls-button svg { + fill: currentColor; + max-width: 12px; + max-height: 12px; +} +.canvas-wrap .react-flow__controls-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.canvas-wrap .react-flow__minimap { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +/* minimap nodes in their kind's tile colour (AgentNode.tsx's miniMapNodeClassName). */ .react-flow__minimap-node { - fill: var(--text-faint); stroke: none; } .react-flow__minimap-node.minimap-node-trigger { - fill: var(--info); + fill: var(--accent); } .react-flow__minimap-node.minimap-node-llm { - fill: var(--accent); + fill: var(--violet); +} +.react-flow__minimap-node.minimap-node-tool { + fill: var(--info); } -.react-flow__minimap-node.minimap-node-tool, .react-flow__minimap-node.minimap-node-approval { fill: var(--warning); } -.react-flow__minimap-node.minimap-node-router { - fill: var(--danger); -} .react-flow__minimap-node.minimap-node-output { fill: var(--success); } -.react-flow__controls { - box-shadow: var(--shadow); - border-radius: 9px; - overflow: hidden; - border: 1px solid var(--border); -} -.react-flow__controls-button { - background: var(--surface); - border-bottom: 1px solid var(--border); - color: var(--text-muted); -} -.react-flow__controls-button:hover { - background: var(--surface-2); -} -.react-flow__controls-button svg { - fill: currentColor; -} -.react-flow__edge-path { - stroke: var(--text-faint); - stroke-width: 1.6; +.react-flow__minimap-node.minimap-node-router { + fill: var(--text-muted); } -.react-flow__edge.selected .react-flow__edge-path { + +/* Edges: smoothstep, 1.5px --border-strong with an arrow (CanvasArea.tsx's toRfEdges); + accent and a moving dash while the source node runs. */ +.canvas-wrap .react-flow__edge.edge-running .react-flow__edge-path, +.canvas-wrap .react-flow__edge.selected .react-flow__edge-path, +.canvas-wrap .react-flow__edge:focus-visible .react-flow__edge-path { stroke: var(--accent); - stroke-width: 2; } .react-flow__attribution { display: none; } -/* Custom node chrome (AgentNode.tsx), matching .node in the mockup. */ +/* Custom node chrome (AgentNode.tsx): a fixed 240px card. */ .rf-node { - width: 190px; - border-radius: 11px; + position: relative; + width: 240px; + border-radius: var(--radius-lg); background: var(--surface); - border: 1.5px solid var(--border); - box-shadow: var(--shadow); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); font-family: var(--font-ui); + color: var(--text); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} +.react-flow__node:hover .rf-node { + border-color: var(--border-strong); } -.rf-node.selected { +.canvas-wrap .rf-node.selected { border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft), var(--shadow); + box-shadow: var(--ring), var(--shadow-sm); +} +/* Running: the accent border breathes a soft accent glow; static under reduced motion. */ +.canvas-wrap .rf-node.running { + border-color: var(--accent); + animation: rf-node-running 1.4s var(--ease) infinite alternate; +} +@keyframes rf-node-running { + from { + box-shadow: + 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent), + var(--shadow-sm); + } + to { + box-shadow: + 0 0 0 4px color-mix(in srgb, var(--accent) 24%, transparent), + var(--shadow-sm); + } +} +/* The drawer's highlighted span and a breakpoint stay visible over selected/running. */ +.canvas-wrap .rf-node.highlighted { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent), var(--shadow-sm); + animation: none; +} +.canvas-wrap .rf-node.has-breakpoint { + box-shadow: 0 0 0 2px var(--danger), var(--shadow-sm); +} +.canvas-wrap .rf-node.has-breakpoint.selected { + box-shadow: 0 0 0 2px var(--danger), var(--ring), var(--shadow-sm); } .rf-node-head { display: flex; align-items: center; - gap: 7px; - padding: 8px 10px; - font-weight: 600; - font-size: 12px; - border-radius: 11px 11px 0 0; + gap: var(--space-2); + padding: 10px var(--space-3); + min-width: 0; } -.rf-node-head .node-swatch { - display: flex; +.rf-node-head .status-pill { + flex: none; + margin-left: auto; +} +.node-kind { + display: inline-flex; align-items: center; justify-content: center; - font-size: 9px; + flex: none; + width: 24px; + height: 24px; + border-radius: var(--radius-sm); +} +.node-kind.kind-accent { + background: var(--accent-soft); + color: var(--accent); +} +.node-kind.kind-violet { + background: var(--violet-soft); + color: var(--violet); +} +.node-kind.kind-info { + background: var(--info-soft); + color: var(--info); +} +.node-kind.kind-warning { + background: var(--warning-soft); + color: var(--warning); +} +.node-kind.kind-success { + background: var(--success-soft); + color: var(--success); +} +.node-kind.kind-neutral { + background: var(--surface-3); + color: var(--text-muted); } .rf-node-title { + flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.rf-node-title { font-size: 13px; + font-weight: 600; +} +.rf-node-head .input { + flex: 1; + min-width: 0; } -/* Model chip, tool count and the node's run status (AgentNode.tsx's NodeMeta). */ +/* Model chip and tool count (AgentNode.tsx's NodeMeta). */ .rf-node-meta { display: flex; - flex-wrap: wrap; align-items: center; - gap: 4px; - padding: 6px 10px; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); -} -.rf-node-meta-spacer { - flex: 1; + min-width: 0; } .rf-node .node-chip { - max-width: 100%; + display: inline-flex; + align-items: center; + gap: 3px; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - border-radius: 4px; - padding: 1px 5px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px 6px; background: var(--surface-2); color: var(--text); + font-size: 11px; + line-height: 16px; } .rf-node .node-model { + display: block; font-family: var(--font-mono); } -.rf-node-meta .status-pill { - font-size: 10.5px; +.rf-node .node-tools { + flex: none; + color: var(--text-muted); } .rf-node-body { border-top: 1px solid var(--border); - padding: 9px 10px; + padding: var(--space-2) var(--space-3); font-size: 11px; color: var(--text-muted); } .rf-node-body code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-family: var(--font-mono); - background: var(--surface-2); - padding: 1px 4px; - border-radius: 4px; color: var(--text); } .rf-node .node-row { display: flex; justify-content: space-between; - gap: 6px; + gap: var(--space-2); margin-top: 3px; } .rf-node .node-row:first-child { margin-top: 0; } -.rf-port.rf-port-in, -.rf-port.rf-port-out { - width: 9px; - height: 9px; +.rf-node .node-row > span { + flex: none; +} +/* Ports: 10px surface circles, accent on hover and while a connection is drawn. */ +.canvas-wrap .rf-port.rf-port-in, +.canvas-wrap .rf-port.rf-port-out { + width: 10px; + height: 10px; border-radius: 50%; background: var(--surface); - border: 2px solid var(--text-faint); -} -.rf-port.rf-port-out { + border: 1.5px solid var(--border-strong); + transition: + border-color var(--dur-fast) var(--ease), + background-color var(--dur-fast) var(--ease); +} +.canvas-wrap .rf-port:hover, +.canvas-wrap .rf-port.connectingfrom, +.canvas-wrap .rf-port.connectingto, +.canvas-wrap .rf-port.valid { border-color: var(--accent); + background: var(--accent); +} + +@media (prefers-reduced-motion: reduce) { + .canvas-wrap .rf-node.running, + .canvas-wrap .react-flow__edge.animated path { + animation: none; + } + .canvas-wrap .rf-node.running:not(.selected, .highlighted, .has-breakpoint) { + box-shadow: 0 0 0 1px var(--accent), var(--shadow-sm); + } + .rf-node, + .canvas-template, + .canvas-wrap .rf-port.rf-port-in, + .canvas-wrap .rf-port.rf-port-out { + transition: none; + } } /* ---------- Inspector ---------- */ From aa4fd59f2aa885042c18cde9cd6c557d589bca89 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 08:47:33 +0300 Subject: [PATCH 3/3] fix(studio): canvas fits the whole graph on load, composable state rings, visible ports, selectable edges - Fit the whole graph when an agent opens or an empty canvas is filled (padding 0.15, maxZoom 1, minZoom 0.2); tighter 48px column gap. - Ports: 2px text-faint ring on surface above the edges; edges stop 5px short of the target port so the arrowhead never covers it. - Selected (--ring), highlighted, breakpoint (outline) and running (pulsing ::after border) now compose instead of replacing each other. - Empty state gets a primary 'Start with a blank graph' action; template cards use kind icons. - Edges are selectable (accent when selected), still not deletable alone. - Dot grid visible, MiniMap 160x110 and themed through xyflow vars only; docShots waits on the server's status instead of a timeout. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 39 ++++++- apps/agent-forge/src/canvas/EmptyCanvas.tsx | 15 ++- .../src/canvas/__tests__/AgentNode.test.tsx | 13 +++ .../src/canvas/__tests__/EmptyCanvas.test.tsx | 11 ++ .../src/canvas/__tests__/flowNodes.test.ts | 13 ++- .../agent-forge/src/components/CanvasArea.tsx | 105 +++++++++++++++--- apps/agent-forge/src/graph/specToGraph.ts | 2 +- .../agent-forge/src/screens/agents/agents.css | 68 +++++++----- 8 files changed, 212 insertions(+), 54 deletions(-) diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index 513cc125..c68aa0fc 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -306,6 +306,18 @@ test('agents x: the empty canvas with its templates', async ({ page }) => { 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'); @@ -313,8 +325,14 @@ test('agents x: a graph with a node selected', async ({ page }) => { test('agents x: a run in progress', async ({ page }) => { const sockets: WebSocketRoute[] = []; + const heardFromServer = new Set(); await page.routeWebSocket(/\/stream/, (ws) => { - ws.connectToServer(); + 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()}`; @@ -323,8 +341,8 @@ test('agents x: a run in progress', async ({ page }) => { // 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); - // Let the server's own on-connect status land first, so ours is the last word. - await page.waitForTimeout(500); + // 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' } }); @@ -337,5 +355,20 @@ test('agents x: a run in progress', async ({ page }) => { 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'); }); diff --git a/apps/agent-forge/src/canvas/EmptyCanvas.tsx b/apps/agent-forge/src/canvas/EmptyCanvas.tsx index a93a2b1b..d6315781 100644 --- a/apps/agent-forge/src/canvas/EmptyCanvas.tsx +++ b/apps/agent-forge/src/canvas/EmptyCanvas.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from 'react'; -import { LayoutTemplate, MessagesSquare, Sparkles, type LucideIcon } from 'lucide-react'; +import { LayoutTemplate, Plus, Sparkles, Zap, type LucideIcon } from 'lucide-react'; import { useAppState } from '../state/AppState'; import { AGENT_TEMPLATES, graphFromTemplate, type TemplateId } from './templates'; -/** Each template card's icon and kind tone (the tone of the node it opens on: `kind-` in agents.css). */ -const TEMPLATE_ICONS: Record = { +/** + * Each template card's kind icon: the kind its pipeline starts with, in that + * kind's canvas tone (AgentNode.tsx's NODE_META, `kind-` in agents.css). + */ +const TEMPLATE_ICONS: Record = { blank: { Icon: Sparkles, tone: 'violet' }, - 'support-bot': { Icon: MessagesSquare, tone: 'info' }, + 'support-bot': { Icon: Zap, tone: 'accent' }, }; /** True between a drag's start and its end (or drop) anywhere in the window. */ @@ -59,6 +62,10 @@ export function EmptyCanvas() {

This agent's canvas is empty. Pick a template, or drag a node in from the Nodes palette.

+
    {AGENT_TEMPLATES.map((template) => { const { Icon, tone } = TEMPLATE_ICONS[template.id]; diff --git a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx index e6f833a1..a872ce39 100644 --- a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx +++ b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx @@ -56,6 +56,19 @@ describe('AgentNode card', () => { expect(tool.querySelector('.node-kind')?.classList.contains('kind-info')).toBe(true); }); + it('keeps running, selected, highlighted and breakpoint states together on one card', () => { + const Card = NODE_TYPES[flowNodeType('llm')]; + const data: AgentNodeData = { graphNode: nodeOf('llm'), onRename: vi.fn(), status: 'running', hasBreakpoint: true, highlighted: true }; + const props = { id: 'llm-0', type: flowNodeType('llm'), data, selected: true } as unknown as NodeProps; + const container = render( + + + + ); + const card = container.querySelector('.rf-node') as HTMLElement; + for (const state of ['running', 'selected', 'highlighted', 'has-breakpoint']) expect(card.classList).toContain(state); + }); + it('keeps a long name whole in its tooltip while the card truncates it', () => { const long = 'x'.repeat(80); const card = renderCard({ ...nodeOf('llm'), label: long }); diff --git a/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx index e2fa7a2e..416ba634 100644 --- a/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx +++ b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx @@ -62,4 +62,15 @@ describe('EmptyCanvas', () => { expect(appState.setSelectedNodeId).toHaveBeenCalledWith(llm?.id); expect(appState.switchAgent).not.toHaveBeenCalled(); }); + + it('has a primary action that starts the blank graph', () => { + const container = render(); + const primary = container.querySelector('.canvas-empty-action') as HTMLButtonElement; + expect(primary.classList.contains('btn-primary')).toBe(true); + expect(primary.textContent).toBe('Start with a blank graph'); + act(() => primary.click()); + const next = appState.setGraph.mock.calls[0][0](appState.graph) as AgentGraphSpec; + expect(next.nodes.some((n) => n.type === 'llm')).toBe(true); + expect(next.nodes.some((n) => n.type === 'tool')).toBe(false); + }); }); diff --git a/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts b/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts index 1daee54d..00eb2614 100644 --- a/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts +++ b/apps/agent-forge/src/canvas/__tests__/flowNodes.test.ts @@ -48,9 +48,11 @@ describe('toRfEdges', () => { const edges = toRfEdges(graph); expect(edges).toHaveLength(graph.edges.length); for (const edge of edges) { - expect(edge.type).toBe('smoothstep'); + expect(edge.type).toBe('gapped'); + expect(edge.selected).toBe(false); + expect(edge.deletable).toBe(false); expect(edge.animated).toBe(false); - expect(edge.markerEnd).toMatchObject({ type: 'arrowclosed', color: 'var(--border-strong)' }); + expect(edge.markerEnd).toMatchObject({ type: 'arrowclosed', color: 'var(--text-faint)' }); } }); @@ -65,4 +67,11 @@ describe('toRfEdges', () => { } for (const edge of edges.filter((e) => e.source !== llm.id)) expect(edge.animated).toBe(false); }); + + it('marks only the selected edge as selected', () => { + const [first, ...rest] = graph.edges; + const edges = toRfEdges(graph, {}, first.id); + expect(edges.find((e) => e.id === first.id)?.selected).toBe(true); + for (const edge of rest) expect(edges.find((e) => e.id === edge.id)?.selected).toBe(false); + }); }); diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index b159b19d..37500e7d 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -7,7 +7,9 @@ import { MarkerType, MiniMap, ReactFlow, + SmoothStepEdge, useReactFlow, + type EdgeProps, type Connection, type Edge, type EdgeChange, @@ -102,22 +104,50 @@ function toRfNodes(graph: AgentGraphSpec, decor: NodeDecor, measured: MeasuredSi })); } +/** How far short of the target port an edge stops, so its arrowhead never covers the port. */ +const ARROW_GAP = 5; + +/** A smoothstep edge that ends `ARROW_GAP` px before its (left-hand) target port. */ +function GappedSmoothStepEdge(props: EdgeProps) { + return ; +} + +const EDGE_TYPES = { gapped: GappedSmoothStepEdge }; + +/** Every fit (on load, on resize, the toolbar): the whole graph, never zoomed in past 1:1. */ +const FIT_VIEW_OPTIONS = { padding: 0.15, maxZoom: 1 }; + +/** + * React Flow's default floor (0.5) is too high for a 4-column graph in the + * builder's ~540px pane at 1280px wide: the fit stopped at 0.5 and cut the + * trigger off. A whole graph in view beats a zoom floor. + */ +const MIN_ZOOM = 0.2; + /** * Smoothstep edges ending in a closed arrow. An edge whose source node is * running is animated in the accent colour (`.edge-running` in agents.css; - * reduced motion keeps the colour and drops the dash animation). + * reduced motion keeps the colour and drops the dash animation). The selected + * edge (`selectedEdgeId`) is drawn in the accent colour. Edges are selectable + * but not deletable on their own (as before): removing a node still drops its edges. */ -export function toRfEdges(graph: AgentGraphSpec, statuses: Record = {}): Edge[] { +export function toRfEdges( + graph: AgentGraphSpec, + statuses: Record = {}, + selectedEdgeId?: string +): Edge[] { return graph.edges.map((e) => { const running = statuses[e.source] === 'running'; return { id: e.id, source: e.source, target: e.target, - type: 'smoothstep', + type: 'gapped', + selected: e.id === selectedEdgeId, + deletable: false, animated: running, className: running ? 'edge-running' : undefined, - markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: running ? 'var(--accent)' : 'var(--border-strong)' }, + markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: running ? 'var(--accent)' : 'var(--text-faint)' }, }; }); } @@ -173,7 +203,8 @@ function useGraphEditHandlers( setGraph: SetGraph, selectedNodeId: string | undefined, setSelectedNodeId: SetSelectedNodeId, - setMeasured: (updater: (prev: MeasuredSizes) => MeasuredSizes) => void + setMeasured: (updater: (prev: MeasuredSizes) => MeasuredSizes) => void, + setSelectedEdgeId: (id: string | undefined) => void ) { const onNodesChange = useCallback( (changes: NodeChange[]) => { @@ -191,11 +222,13 @@ function useGraphEditHandlers( const onEdgesChange = useCallback( (changes: EdgeChange[]) => { + const select = [...changes].reverse().find((c) => c.type === 'select'); + if (select && select.type === 'select') setSelectedEdgeId(select.selected ? select.id : undefined); const removed = changes.filter((c) => c.type === 'remove'); if (removed.length === 0) return; setGraph((g) => removed.reduce((acc, c) => (c.type === 'remove' ? removeEdge(acc, c.id) : acc), g)); }, - [setGraph] + [setGraph, setSelectedEdgeId] ); const onNodesDelete = useCallback( @@ -299,7 +332,7 @@ function useFitViewOnResize() { let timer: ReturnType | undefined; function onResize() { if (timer) clearTimeout(timer); - timer = setTimeout(() => void fitView({ padding: 0.2, duration: 150 }), 150); + timer = setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 150 }), 150); } window.addEventListener('resize', onResize); return () => { @@ -309,17 +342,39 @@ function useFitViewOnResize() { }, [fitView]); } +/** + * Fit the whole graph when an agent is opened and when an empty canvas is + * filled (a template applied, or the first node dropped) - React Flow's own + * `fitView` prop only fits once, on mount. Waits until every node has been + * measured, so the fit uses real card sizes. + */ +function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes) { + const { fitView } = useReactFlow(); + const pending = useRef(true); + const loadKey = `${agentId}|${graph.nodes.length > 0 ? 'filled' : 'empty'}`; + useEffect(() => { + pending.current = true; + }, [loadKey]); + const allMeasured = graph.nodes.length > 0 && graph.nodes.every((n) => measured[n.id]); + useEffect(() => { + if (!pending.current || !allMeasured) return; + pending.current = false; + const frame = window.requestAnimationFrame(() => void fitView(FIT_VIEW_OPTIONS)); + return () => window.cancelAnimationFrame(frame); + }, [loadKey, allMeasured, fitView]); +} + /** Toolbar actions (auto layout, fit view, duplicate) plus the Ctrl/Cmd+D shortcut. */ function useToolbarActions(setGraph: SetGraph, selectedNodeId: string | undefined) { const { fitView } = useReactFlow(); function handleAutoLayout() { setGraph((g) => autoLayout(g)); - window.requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); + window.requestAnimationFrame(() => fitView({ ...FIT_VIEW_OPTIONS, duration: 200 })); } function handleFitView() { - fitView({ padding: 0.2, duration: 200 }); + fitView({ ...FIT_VIEW_OPTIONS, duration: 200 }); } const handleDuplicate = useCallback(() => { @@ -407,8 +462,7 @@ function CanvasNavigator() { position="bottom-right" pannable zoomable - bgColor="var(--surface)" - maskColor="color-mix(in srgb, var(--bg) 70%, transparent)" + style={{ width: 160, height: 110 }} nodeClassName={miniMapNodeClassName} nodeBorderRadius={6} ariaLabel="Graph overview" @@ -425,9 +479,13 @@ function CanvasNavigator() { * `graph` on every render rather than kept in separate ReactFlow-owned * state, so there is nowhere for the two to drift apart. */ -function CanvasInner() { - const { graph, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState, spans, runStatus } = useAppState(); - const { connectError, flashError } = useConnectError(); +/** + * The React Flow nodes and edges derived from `graph` and the run/debug/selection + * state, plus the measured-size and selected-edge state they depend on. + */ +function useCanvasElements() { + const { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState, spans, runStatus } = + useAppState(); const onRename = useCallback( (nodeId: string, label: string) => { @@ -444,13 +502,23 @@ function CanvasInner() { () => toRfNodes(graph, { selectedNodeId, onRename, highlightedNodeId, breakpoints, statuses }, measured), [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey, statuses, measured] ); - const edges = useMemo(() => toRfEdges(graph, statuses), [graph, statuses]); + const [selectedEdgeId, setSelectedEdgeId] = useState(undefined); + const edges = useMemo(() => toRfEdges(graph, statuses, selectedEdgeId), [graph, statuses, selectedEdgeId]); + + return { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, nodes, edges, measured, setMeasured, setSelectedEdgeId }; +} + +function CanvasInner() { + const { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, nodes, edges, measured, setMeasured, setSelectedEdgeId } = + useCanvasElements(); + const { connectError, flashError } = useConnectError(); - const editHandlers = useGraphEditHandlers(setGraph, selectedNodeId, setSelectedNodeId, setMeasured); + const editHandlers = useGraphEditHandlers(setGraph, selectedNodeId, setSelectedNodeId, setMeasured, setSelectedEdgeId); const { onConnect, isValidConnection } = useConnectionHandlers(graph, setGraph, flashError); const { onDragOver, onDrop } = useDropHandlers(graph, setGraph, flashError); const { handleAutoLayout, handleFitView, handleDuplicate } = useToolbarActions(setGraph, selectedNodeId); useFitViewOnResize(); + useFitViewOnLoad(agentId, graph, measured); return (
    @@ -470,6 +538,7 @@ function CanvasInner() { nodes={nodes} edges={edges} nodeTypes={NODE_TYPES} + edgeTypes={EDGE_TYPES} onNodesChange={editHandlers.onNodesChange} onEdgesChange={editHandlers.onEdgesChange} onNodesDelete={editHandlers.onNodesDelete} @@ -477,9 +546,11 @@ function CanvasInner() { isValidConnection={isValidConnection} deleteKeyCode={['Backspace', 'Delete']} fitView + fitViewOptions={FIT_VIEW_OPTIONS} + minZoom={MIN_ZOOM} proOptions={{ hideAttribution: true }} > - + {/* Nothing to overview or zoom on an empty canvas; the template card has the room. */} {graph.nodes.length > 0 && } diff --git a/apps/agent-forge/src/graph/specToGraph.ts b/apps/agent-forge/src/graph/specToGraph.ts index 30624f5e..e04a46b7 100644 --- a/apps/agent-forge/src/graph/specToGraph.ts +++ b/apps/agent-forge/src/graph/specToGraph.ts @@ -21,7 +21,7 @@ function isSerializedHookArray(value: unknown): value is SerializedHook[] { ); } -const COLUMN_WIDTH = 320; +const COLUMN_WIDTH = 288; const ROW_HEIGHT = 140; /** diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index cf324361..04254b8e 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -310,6 +310,9 @@ button.breakpoint-toggle { color: var(--text-muted); font-size: 13px; } +.canvas-empty-action { + margin-top: var(--space-2); +} .canvas-templates { list-style: none; width: 100%; @@ -458,39 +461,50 @@ button.breakpoint-toggle { .react-flow__node:hover .rf-node { border-color: var(--border-strong); } -.canvas-wrap .rf-node.selected { +/* + * State rings compose rather than replace each other, so selected, highlighted, + * breakpoint and running all stay visible together: + * selected accent border + --ring (box-shadow, 0-3px out) + * highlighted a solid 3px accent ring in the same box-shadow slot (the drawer's pick wins) + * breakpoint a 2px danger outline 3px further out (its own property) + * running an accent border drawn by ::after, which pulses (its own layer) + */ +.canvas-wrap .rf-node.has-breakpoint { + box-shadow: var(--shadow-sm); + outline: 2px solid var(--danger); + outline-offset: 3px; +} +.canvas-wrap .rf-node.selected, +.canvas-wrap .rf-node.selected.has-breakpoint { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-sm); } -/* Running: the accent border breathes a soft accent glow; static under reduced motion. */ +.canvas-wrap .rf-node.highlighted, +.canvas-wrap .rf-node.highlighted.selected, +.canvas-wrap .rf-node.highlighted.has-breakpoint { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent), var(--shadow-sm); +} .canvas-wrap .rf-node.running { border-color: var(--accent); - animation: rf-node-running 1.4s var(--ease) infinite alternate; +} +.canvas-wrap .rf-node.running::after { + content: ''; + position: absolute; + inset: -1px; + border: 2px solid var(--accent); + border-radius: inherit; + pointer-events: none; + animation: rf-node-running 1.2s var(--ease) infinite alternate; } @keyframes rf-node-running { from { - box-shadow: - 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent), - var(--shadow-sm); + opacity: 1; } to { - box-shadow: - 0 0 0 4px color-mix(in srgb, var(--accent) 24%, transparent), - var(--shadow-sm); + opacity: 0.3; } } -/* The drawer's highlighted span and a breakpoint stay visible over selected/running. */ -.canvas-wrap .rf-node.highlighted { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent), var(--shadow-sm); - animation: none; -} -.canvas-wrap .rf-node.has-breakpoint { - box-shadow: 0 0 0 2px var(--danger), var(--shadow-sm); -} -.canvas-wrap .rf-node.has-breakpoint.selected { - box-shadow: 0 0 0 2px var(--danger), var(--ring), var(--shadow-sm); -} .rf-node-head { display: flex; align-items: center; @@ -609,14 +623,17 @@ button.breakpoint-toggle { .rf-node .node-row > span { flex: none; } -/* Ports: 10px surface circles, accent on hover and while a connection is drawn. */ +/* Ports: 10px surface circles with a clearly visible ring (above the edges), accent on hover and while connecting. */ .canvas-wrap .rf-port.rf-port-in, .canvas-wrap .rf-port.rf-port-out { + z-index: 1; width: 10px; height: 10px; + min-width: 0; + min-height: 0; border-radius: 50%; background: var(--surface); - border: 1.5px solid var(--border-strong); + border: 2px solid var(--text-faint); transition: border-color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease); @@ -630,13 +647,10 @@ button.breakpoint-toggle { } @media (prefers-reduced-motion: reduce) { - .canvas-wrap .rf-node.running, + .canvas-wrap .rf-node.running::after, .canvas-wrap .react-flow__edge.animated path { animation: none; } - .canvas-wrap .rf-node.running:not(.selected, .highlighted, .has-breakpoint) { - box-shadow: 0 0 0 1px var(--accent), var(--shadow-sm); - } .rf-node, .canvas-template, .canvas-wrap .rf-port.rf-port-in,