diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index 38a0b9b5c..f77a1ee88 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -20,7 +20,14 @@ test('the page has landmarks, an h1 and a live run status', async ({ page }) => await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1); await expect(page.getByRole('navigation', { name: 'Agents and node palette' })).toHaveCount(1); await expect(page.getByRole('main')).toHaveCount(1); + // Nothing is selected on load, so the Inspector starts collapsed; its toggle opens it from the keyboard. + await expect(page.getByRole('complementary', { name: 'Inspector' })).toHaveCount(0); + const showInspector = page.getByRole('button', { name: 'Show inspector' }); + await expect(showInspector).toHaveAttribute('aria-expanded', 'false'); + await showInspector.focus(); + await page.keyboard.press('Enter'); await expect(page.getByRole('complementary', { name: 'Inspector' })).toHaveCount(1); + await expect(page.getByRole('button', { name: 'Hide inspector' })).toHaveAttribute('aria-expanded', 'true'); await expect(page.getByRole('heading', { level: 1 })).toHaveText('Agent Forge'); await expect(page.locator('.topbar [role="status"][aria-live="polite"]')).toContainText('idle'); }); @@ -119,6 +126,16 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page }) /** Run axe (WCAG 2 A/AA) on the page as it stands; fail on any serious/critical violation, naming the rule and its targets. */ async function expectNoSeriousAxeViolations(page: Page, label: string) { + // Let entrance animations (a dialog's fade-in) land first: mid-fade text blends with the scrim and + // reads as low contrast. Infinite ones (skeleton shimmer, running pulse) never finish, so they are skipped. + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((a) => a.effect?.getComputedTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => undefined)) + ) + ); const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); const serious = violations.filter((v) => v.impact === 'serious' || v.impact === 'critical'); const report = serious.map((v) => `${v.id} (${v.impact}): ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`); diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index 2fa11e972..ed1536769 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -610,13 +610,34 @@ test('agents x: a graph with a node selected', async ({ page }) => { 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 }); + // Ports sit level with each card's header, so a pipeline edge is a flat line whose box has no height + // (Playwright calls it off-screen): click the middle of its path instead. + const mid = await edge.locator('.react-flow__edge-interaction').evaluate((el) => { + const path = el as SVGPathElement; + const point = path.getPointAtLength(path.getTotalLength() / 2); + const m = path.getScreenCTM()!; + return { x: point.x * m.a + point.y * m.c + m.e, y: point.x * m.b + point.y * m.d + m.f }; + }); + await page.mouse.click(mid.x, mid.y); 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: the default graph on load at 1280, 1440, 1920 and 860 wide', async ({ page }) => { + const name = `fit-${Date.now()}`; + await newAgent(page, name, 'support-bot'); + for (const [width, height] of [[1280, 800], [1440, 900], [1920, 1080], [860, 800]] as const) { + // Reopened at each size, so every shot is the on-load fit (nothing selected, Inspector collapsed). + await page.setViewportSize({ width, height }); + await page.reload(); + await expect(page.locator('.crumbs b')).toHaveText(name); + await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0); + await shoot(page, `x-agents-fit-${width}`); + } +}); + test('agents x: a run in progress', async ({ page }) => { const sockets: WebSocketRoute[] = []; const heardFromServer = new Set(); @@ -659,10 +680,11 @@ test('agents x: a run in progress', async ({ page }) => { 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 }; + return { outline: s.outlineStyle, pulse: getComputedStyle(el, '::after').animationName }; }); expect(rings.outline).toBe('solid'); // breakpoint - expect(rings.shadow).toMatch(/0px 0px 0px 3px/); // selected --ring + // Nothing is selected on load, so the click above just selected it: let the ring's transition land. + await expect.poll(() => llm.evaluate((el) => getComputedStyle(el).boxShadow)).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/e2e/narrow.spec.ts b/apps/agent-forge/e2e/narrow.spec.ts index 5041b65a2..3e876db04 100644 --- a/apps/agent-forge/e2e/narrow.spec.ts +++ b/apps/agent-forge/e2e/narrow.spec.ts @@ -77,6 +77,9 @@ test('at 1280px the three-column layout has no drawer toggles', async ({ page }) await open(page, 1280, 800); expect(await horizontalOverflow(page)).toBe(0); await expect(page.getByRole('navigation', { name: 'Agents and node palette' })).toBeVisible(); + // The docked Inspector starts collapsed (nothing selected) and opens from its canvas-edge toggle. + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); + await page.getByRole('button', { name: 'Show inspector' }).click(); await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible(); await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); @@ -99,3 +102,92 @@ test('#/playground at 375px stacks into one column with nothing scrolling sidewa expect(box && box.x >= 0 && box.x + box.width <= 375, `${name} is on-screen`).toBe(true); } }); + +/** Creates a saved support-bot agent (trigger -> llm -> tool -> output) and waits for its first fit. */ +async function createSupportBot(page: Page, name: string) { + await page.getByRole('button', { name: 'New agent', exact: true }).click(); + await page.locator('#new-agent-name').fill(name); + await page.locator('#new-agent-template').selectOption('support-bot'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(page.locator('.crumbs b')).toHaveText(name); + await expect(page.locator('[data-node-type="output"]')).toBeVisible(); + await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0); +} + +/** Every node card inside the canvas pane, and each title's on-screen size (computed font-size x viewport zoom). */ +async function canvasFit(page: Page) { + return page.evaluate(() => { + const pane = document.querySelector('.react-flow')!.getBoundingClientRect(); + const viewport = document.querySelector('.react-flow__viewport')!; + const zoom = new DOMMatrix(getComputedStyle(viewport).transform).a; + const cards = [...document.querySelectorAll('.rf-node')]; + return { + count: cards.length, + outside: cards + .filter((el) => { + const r = el.getBoundingClientRect(); + return r.left < pane.left || r.right > pane.right || r.top < pane.top || r.bottom > pane.bottom; + }) + .map((el) => el.dataset.nodeType), + titlePx: Math.min(...cards.map((el) => parseFloat(getComputedStyle(el.querySelector('.rf-node-title')!).fontSize) * zoom)), + }; + }); +} + +test('at 1280x800 the default graph opens whole and legible, and selecting a node keeps it in view', async ({ page }) => { + await open(page, 1280, 800); + await createSupportBot(page, `fit-${Date.now()}`); + // Measurement guard: all four nodes inside the pane, titles at >= 10px on screen. + await expect.poll(async () => (await canvasFit(page)).outside).toEqual([]); + const fit = await canvasFit(page); + expect(fit.count).toBe(4); + expect(fit.titlePx).toBeGreaterThanOrEqual(10); + + // Selecting the right-most node opens the Inspector, and the canvas pans so the node stays visible. + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); + await page.locator('[data-node-type="output"]').click(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible(); + const outputInPane = () => + page.locator('[data-node-type="output"]').evaluate((el) => { + const node = el.getBoundingClientRect(); + const pane = el.closest('.react-flow')!.getBoundingClientRect(); + return node.left >= pane.left && node.right <= pane.right; + }); + await expect.poll(outputInPane).toBe(true); + + // Clicking the empty pane deselects: the Inspector folds away again. + await page.locator('.react-flow__pane').click({ position: { x: 300, y: 60 } }); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); +}); + +test('an explicit Show inspector is remembered across reloads; Hide folds it back', async ({ page }) => { + await open(page, 1280, 800); + await page.getByRole('button', { name: 'Show inspector' }).click(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible(); + await page.getByRole('button', { name: 'Hide inspector' }).click(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); + await page.reload(); + await expect(page.getByRole('button', { name: 'Show inspector' })).toBeVisible(); + await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden(); +}); + +for (const [width, height] of [ + [1440, 900], + [1920, 1080], + [860, 800], +] as const) { + test(`at ${width}x${height} the default graph opens whole and legible`, async ({ page }) => { + // Created at 1280 (below 860 the rail is a drawer), then reopened at the target size. + await open(page, 1280, 800); + const name = `fit-${width}-${Date.now()}`; + await createSupportBot(page, name); + await page.setViewportSize({ width, height }); + await page.reload(); + await expect(page.locator('.crumbs b')).toHaveText(name); + await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0); + await expect.poll(async () => (await canvasFit(page)).outside).toEqual([]); + expect((await canvasFit(page)).titlePx).toBeGreaterThanOrEqual(10); + }); +} diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx index 47694b797..ffa756855 100644 --- a/apps/agent-forge/src/canvas/AgentNode.tsx +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -6,7 +6,7 @@ import { StatusPill } from '../ui/StatusPill'; import { NODE_KINDS } from './nodeKinds'; /** - * Custom ReactFlow node renderer for every `AgentGraphNodeType`: a 240px card + * Custom ReactFlow node renderer for every `AgentGraphNodeType`: a 208px 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 diff --git a/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts b/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts index 29c4f8182..5fbc6aa40 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: 280, y: 260 }); + expect(viewportCentrePosition([0, 0, 1], 800, 600)).toEqual({ x: 296, 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 - 120, y: 125 - 40 }); + expect(viewportCentrePosition([100, 50, 2], 800, 600)).toEqual({ x: 150 - 104, y: 125 - 40 }); }); it('survives a zero zoom before the pane has measured', () => { - expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -120, y: -40 }); + expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -104, y: -40 }); }); }); diff --git a/apps/agent-forge/src/canvas/layout.ts b/apps/agent-forge/src/canvas/layout.ts index d1a017e5c..cf3b39102 100644 --- a/apps/agent-forge/src/canvas/layout.ts +++ b/apps/agent-forge/src/canvas/layout.ts @@ -1,7 +1,8 @@ import dagre from 'dagre'; import type { AgentGraphSpec } from '../graph/types'; -const NODE_WIDTH = 240; +/** `.rf-node`'s width (agents.css). */ +const NODE_WIDTH = 208; const NODE_HEIGHT = 96; /** @@ -16,7 +17,8 @@ export function autoLayout(graph: AgentGraphSpec): AgentGraphSpec { if (graph.nodes.length === 0) return graph; const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 90 }); + // ranksep 48: the same 256px column pitch specToGraph.ts lays a fresh graph out on. + g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 48 }); g.setDefaultEdgeLabel(() => ({})); for (const node of graph.nodes) { diff --git a/apps/agent-forge/src/canvas/paletteActions.ts b/apps/agent-forge/src/canvas/paletteActions.ts index 412b201b7..0bebaad76 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: 240, height: 80 }; +const NODE_SIZE = { width: 208, 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 e6ceb8d24..a731e9de7 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -11,6 +11,7 @@ import { getNodesBounds, getViewportForBounds, useReactFlow, + useStore, useStoreApi, type EdgeProps, type Connection, @@ -20,7 +21,7 @@ import { type NodeChange, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { Copy, LayoutGrid, Maximize, type LucideIcon } from 'lucide-react'; +import { Copy, LayoutGrid, Maximize, PanelRightClose, PanelRightOpen, 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'; @@ -122,13 +123,21 @@ const FIT_VIEW_OPTIONS = { padding: 0.15, maxZoom: 1 }; /** * The automatic fits (on load, on resize, after auto layout) stop zooming out - * here, so node text stays legible; a graph too big for the pane at this zoom - * opens pinned to its top-left corner (where the trigger sits) instead of - * centred. The user can still zoom out to MIN_ZOOM, or press Fit view. + * here, so node text stays legible (13px titles render at >= ~10px); a graph + * too big for the pane at this zoom opens pinned to its top-left corner (where + * the trigger sits) instead of centred. The user can still zoom out to + * MIN_ZOOM, or press Fit view. */ const AUTO_FIT_MIN_ZOOM = 0.75; /** The inset (screen px) of a pinned graph's left/top edge. */ const AUTO_FIT_INSET = 40; +/** + * The automatic fits' margin round the graph: a fixed 32 screen px rather + * than Fit view's proportional 15%, which would cost the default four-stage + * graph ~0.1 of zoom in a 1280px-wide builder (the difference between + * fitting whole above AUTO_FIT_MIN_ZOOM and being cut off). + */ +const AUTO_FIT_PADDING = '32px'; /** * React Flow's default floor (0.5) is too high for a 4-column graph in the @@ -334,6 +343,15 @@ function useDropHandlers(graph: AgentGraphSpec, setGraph: SetGraph, flashError: return { onDragOver, onDrop }; } +/** + * A viewport animation's duration, or none under `prefers-reduced-motion: + * reduce` - every pan/fit animation on the canvas goes through this. + */ +function motionDuration(ms: number): { duration: number } | undefined { + const reduce = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + return reduce ? undefined : { duration: ms }; +} + /** * An automatic fit: the whole graph when it fits at AUTO_FIT_MIN_ZOOM or more * (never past 1:1), else that zoom with the graph's top-left corner in view. @@ -347,10 +365,10 @@ function useLegibleFit() { const { width, height } = store.getState(); if (nodes.length === 0 || !width || !height) return; const bounds = getNodesBounds(nodes); - const fit = getViewportForBounds(bounds, width, height, AUTO_FIT_MIN_ZOOM, 1, FIT_VIEW_OPTIONS.padding); + const fit = getViewportForBounds(bounds, width, height, AUTO_FIT_MIN_ZOOM, 1, AUTO_FIT_PADDING); const x = bounds.width * fit.zoom > width ? AUTO_FIT_INSET - bounds.x * fit.zoom : fit.x; const y = bounds.height * fit.zoom > height ? AUTO_FIT_INSET - bounds.y * fit.zoom : fit.y; - void setViewport({ x, y, zoom: fit.zoom }, duration ? { duration } : undefined); + void setViewport({ x, y, zoom: fit.zoom }, duration ? motionDuration(duration) : undefined); }, [getNodes, setViewport, store] ); @@ -400,6 +418,56 @@ function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: Meas return graph.nodes.length > 0 && fittedKey !== loadKey; } +/** A screen-space box: left/top edge and size. */ +interface ScreenBox { + left: number; + top: number; + width: number; + height: number; +} + +/** The margin (screen px) a selected node is kept from the pane's edges. */ +const SELECTION_MARGIN = 24; + +/** The shift along one axis that brings [start, start + size) inside [margin, pane - margin]; the start wins when it can't all fit. */ +function shiftIntoRange(start: number, size: number, pane: number): number { + if (start < SELECTION_MARGIN) return SELECTION_MARGIN - start; + const overflow = start + size - (pane - SELECTION_MARGIN); + return overflow > 0 ? -Math.min(overflow, start - SELECTION_MARGIN) : 0; +} + +/** How far to pan (screen px) so `node` sits inside a `paneWidth` x `paneHeight` pane; zero when it already does. */ +export function panIntoView(node: ScreenBox, paneWidth: number, paneHeight: number): { dx: number; dy: number } { + return { dx: shiftIntoRange(node.left, node.width, paneWidth), dy: shiftIntoRange(node.top, node.height, paneHeight) }; +} + +/** + * Selecting a node opens the Inspector, which narrows the pane from the right + * (AgentsScreen.tsx): pan, at the same zoom, just enough to keep the selected + * node in view - on selection and when the pane's width changes under it (the + * Inspector opening or closing), not on height changes (dragging the bottom + * drawer's splitter must not snap the view back). + */ +function useKeepSelectionInView(selectedNodeId: string | undefined) { + const { getInternalNode, getViewport, setViewport } = useReactFlow(); + const store = useStoreApi(); + const paneWidth = useStore((s) => s.width); + useEffect(() => { + const paneHeight = store.getState().height; + const node = selectedNodeId ? getInternalNode(selectedNodeId) : undefined; + if (!node?.measured.width || !node.measured.height || !paneWidth || !paneHeight) return; + const { x, y, zoom } = getViewport(); + const box = { + left: node.internals.positionAbsolute.x * zoom + x, + top: node.internals.positionAbsolute.y * zoom + y, + width: node.measured.width * zoom, + height: node.measured.height * zoom, + }; + const { dx, dy } = panIntoView(box, paneWidth, paneHeight); + if (dx !== 0 || dy !== 0) void setViewport({ x: x + dx, y: y + dy, zoom }, motionDuration(150)); + }, [selectedNodeId, paneWidth, store, getInternalNode, getViewport, setViewport]); +} + /** Toolbar actions (auto layout, fit view, duplicate) plus the Ctrl/Cmd+D shortcut. */ function useToolbarActions(setGraph: SetGraph, selectedNodeId: string | undefined) { const { fitView } = useReactFlow(); @@ -411,7 +479,7 @@ function useToolbarActions(setGraph: SetGraph, selectedNodeId: string | undefine } function handleFitView() { - fitView({ ...FIT_VIEW_OPTIONS, duration: 200 }); + fitView({ ...FIT_VIEW_OPTIONS, ...motionDuration(200) }); } const handleDuplicate = useCallback(() => { @@ -491,6 +559,38 @@ function CanvasToolbar({ ); } +/** What the canvas needs to show the docked Inspector's Show/Hide toggle (AgentsScreen.tsx owns the state). */ +export interface InspectorToggleProps { + inspectorCollapsed?: boolean; + onToggleInspector?: () => void; +} + +/** + * The docked Inspector's Show/Hide toggle, on the canvas's top-right edge + * next to the Inspector it opens. Wide layout only (agents.css): below 860px + * the top bar's Inspector button opens it as a drawer. + */ +function InspectorToggle({ inspectorCollapsed = false, onToggleInspector }: InspectorToggleProps) { + if (!onToggleInspector) return null; + const label = inspectorCollapsed ? 'Show inspector' : 'Hide inspector'; + const Icon = inspectorCollapsed ? PanelRightOpen : PanelRightClose; + return ( + + ); +} + /** The MiniMap (bottom-right) and zoom/fit Controls (bottom-left), both themed in agents.css. */ function CanvasNavigator() { return ( @@ -545,7 +645,7 @@ function useCanvasElements() { return { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, nodes, edges, measured, setMeasured, setSelectedEdgeId }; } -function CanvasInner() { +function CanvasInner(inspectorToggle: InspectorToggleProps) { const { agentId, graph, setGraph, selectedNodeId, setSelectedNodeId, nodes, edges, measured, setMeasured, setSelectedEdgeId } = useCanvasElements(); const { connectError, flashError } = useConnectError(); @@ -555,6 +655,7 @@ function CanvasInner() { const { onDragOver, onDrop } = useDropHandlers(graph, setGraph, flashError); const { handleAutoLayout, handleFitView, handleDuplicate } = useToolbarActions(setGraph, selectedNodeId); useFitViewOnResize(); + useKeepSelectionInView(selectedNodeId); const fitting = useFitViewOnLoad(agentId, graph, measured); return ( @@ -565,6 +666,7 @@ function CanvasInner() { onFitView={handleFitView} onDuplicate={handleDuplicate} /> + {connectError && (
@@ -601,6 +703,6 @@ function CanvasInner() { * adopters at `@xyflow/react` (same team, actively released); see the * epic's PR description for the full rationale. */ -export function CanvasArea() { - return ; +export function CanvasArea(props: InspectorToggleProps) { + return ; } diff --git a/apps/agent-forge/src/components/__tests__/panIntoView.test.ts b/apps/agent-forge/src/components/__tests__/panIntoView.test.ts new file mode 100644 index 000000000..ac5dcaf08 --- /dev/null +++ b/apps/agent-forge/src/components/__tests__/panIntoView.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { panIntoView } from '../CanvasArea'; + +// A 540x500 pane (the 1280px builder with the Inspector open); the margin kept from each edge is 24px. +describe('panIntoView', () => { + it('leaves a node that is already inside the pane alone', () => { + expect(panIntoView({ left: 100, top: 100, width: 160, height: 60 }, 540, 500)).toEqual({ dx: 0, dy: 0 }); + }); + + it('pans left just enough to bring a node cut off on the right back in', () => { + // The Output node at 700..860 once the Inspector narrows the pane: its right edge lands at 540 - 24. + expect(panIntoView({ left: 700, top: 200, width: 160, height: 60 }, 540, 500)).toEqual({ dx: -344, dy: 0 }); + }); + + it('pans right / down for a node off the left / top edge', () => { + expect(panIntoView({ left: -50, top: -10, width: 160, height: 60 }, 540, 500)).toEqual({ dx: 74, dy: 34 }); + }); + + it('keeps the left edge in view when the node is wider than the pane', () => { + expect(panIntoView({ left: 300, top: 100, width: 800, height: 60 }, 540, 500)).toEqual({ dx: -276, dy: 0 }); + }); +}); diff --git a/apps/agent-forge/src/graph/specToGraph.ts b/apps/agent-forge/src/graph/specToGraph.ts index e04a46b7e..97fdcf71b 100644 --- a/apps/agent-forge/src/graph/specToGraph.ts +++ b/apps/agent-forge/src/graph/specToGraph.ts @@ -21,7 +21,13 @@ function isSerializedHookArray(value: unknown): value is SerializedHook[] { ); } -const COLUMN_WIDTH = 288; +/** + * One pipeline stage per column: a 208px card (`.rf-node`) plus a 48px gap + * for the edge. Compact so the default trigger -> llm -> tool -> output graph + * fits a 1280px-wide builder whole at a legible zoom (CanvasArea.tsx's + * auto-fit). Specs store no positions, so every opened agent is laid out here. + */ +const COLUMN_WIDTH = 256; const ROW_HEIGHT = 140; /** diff --git a/apps/agent-forge/src/screens/agents/AgentsScreen.tsx b/apps/agent-forge/src/screens/agents/AgentsScreen.tsx index d7e77ff27..9a705b5a0 100644 --- a/apps/agent-forge/src/screens/agents/AgentsScreen.tsx +++ b/apps/agent-forge/src/screens/agents/AgentsScreen.tsx @@ -6,7 +6,9 @@ import { Inspector } from '../../components/Inspector'; import { BottomDrawer } from '../../components/BottomDrawer'; import { NARROW_QUERY, useMediaQuery } from '../../components/useMediaQuery'; import { useShell } from '../../shell/AppShell'; +import { useAppState } from '../../state/AppState'; import { useAgentRouteSync } from './useAgentRouteSync'; +import { useInspectorCollapsed } from './useInspectorCollapsed'; /** * Below 860px the rail and the Inspector are slide-over drawers, @@ -40,18 +42,20 @@ function useSidePanels() { /** * `#/agents` and `#/agents/:id` - the builder: agent rail, canvas, Inspector and the run drawer, * under the Build toolbar. The hash names the open agent (see `useAgentRouteSync`). + * The docked Inspector collapses while nothing is selected (`useInspectorCollapsed`). */ export function AgentsScreen() { useAgentRouteSync(); const panels = useSidePanels(); - const shellClass = `shell${panels.open ? ` ${panels.open}-open` : ''}`; + const inspector = useInspectorCollapsed(useAppState().selectedNodeId); + const shellClass = `shell${panels.open ? ` ${panels.open}-open` : ''}${inspector.collapsed ? ' inspector-collapsed' : ''}`; return (

Agent Forge

- + {panels.open && diff --git a/apps/agent-forge/src/screens/agents/__tests__/useInspectorCollapsed.test.tsx b/apps/agent-forge/src/screens/agents/__tests__/useInspectorCollapsed.test.tsx new file mode 100644 index 000000000..46f7e8a75 --- /dev/null +++ b/apps/agent-forge/src/screens/agents/__tests__/useInspectorCollapsed.test.tsx @@ -0,0 +1,101 @@ +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { INSPECTOR_COLLAPSED_KEY, useInspectorCollapsed } from '../useInspectorCollapsed'; +import { breakStorage, installMemoryStorage } from '../../../ui/__tests__/memoryStorage'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; +let latest: { collapsed: boolean; toggle: () => void }; + +function Probe({ selected }: { selected?: string }) { + latest = useInspectorCollapsed(selected); + return null; +} + +function render(selected?: string) { + if (!root) root = createRoot(document.createElement('div')); + act(() => root!.render(createElement(Probe, { selected }))); +} + +beforeEach(() => { + installMemoryStorage(); +}); +afterEach(() => { + act(() => root?.unmount()); + root = undefined; +}); + +describe('useInspectorCollapsed', () => { + it('is collapsed with nothing selected and opens on selection, without storing anything', () => { + render(); + expect(latest.collapsed).toBe(true); + render('llm-1'); + expect(latest.collapsed).toBe(false); + render(undefined); + expect(latest.collapsed).toBe(true); + expect(window.localStorage.getItem(INSPECTOR_COLLAPSED_KEY)).toBeNull(); + }); + + it('remembers an explicit Show, so deselecting keeps it open', () => { + render(); + act(() => latest.toggle()); + expect(latest.collapsed).toBe(false); + expect(window.localStorage.getItem(INSPECTOR_COLLAPSED_KEY)).toBe('0'); + render('llm-1'); + render(undefined); + expect(latest.collapsed).toBe(false); + }); + + it('starts open when Show was remembered from an earlier visit', () => { + window.localStorage.setItem(INSPECTOR_COLLAPSED_KEY, '0'); + render(); + expect(latest.collapsed).toBe(false); + }); + + it('still toggles when storage throws', () => { + breakStorage(); + render(); + act(() => latest.toggle()); + expect(latest.collapsed).toBe(false); + }); + + it('Hide closes it over a selection until the next selection opens it again', () => { + render('llm-1'); + act(() => latest.toggle()); + expect(latest.collapsed).toBe(true); + expect(window.localStorage.getItem(INSPECTOR_COLLAPSED_KEY)).toBe('1'); + render('tool-2'); + expect(latest.collapsed).toBe(false); + }); +}); + +describe('useInspectorCollapsed after a Hide', () => { + beforeEach(() => { + installMemoryStorage(); + }); + + it('reopens when the same node is selected again after the selection moved on', () => { + render('llm-1'); + act(() => latest.toggle()); + expect(latest.collapsed).toBe(true); + render(undefined); + render('llm-1'); + expect(latest.collapsed).toBe(false); + }); + + it('opens on the very render that selects a node (no effect step)', () => { + const seen: boolean[] = []; + function Recorder({ selected }: { selected?: string }) { + seen.push(useInspectorCollapsed(selected).collapsed); + return null; + } + const r = createRoot(document.createElement('div')); + act(() => r.render(createElement(Recorder, {}))); + seen.length = 0; + act(() => r.render(createElement(Recorder, { selected: 'llm-1' }))); + expect(seen.every((c) => c === false)).toBe(true); + act(() => r.unmount()); + }); +}); diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index 3990fd033..451bf20a8 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -22,6 +22,19 @@ .inspector { border-left: 1px solid var(--border); } +/* + * Nothing selected (or the viewer pressed Hide inspector): the docked + * Inspector column folds away and the canvas takes its width + * (useInspectorCollapsed.ts). Wide layout only - below 860px it is a drawer. + */ +@media (min-width: 861px) { + .shell.inspector-collapsed { + grid-template-columns: 220px minmax(0, 1fr); + } + .shell.inspector-collapsed .inspector { + display: none; + } +} /* ---------- Left rail (LeftRail.tsx) ---------- */ /* The Agents/Nodes tablist, drawn as a segmented control. */ @@ -437,6 +450,24 @@ button.breakpoint-toggle { width: 30px; min-height: 30px; } +/* + * The docked Inspector's Show/Hide toggle (CanvasArea.tsx), level with the + * toolbar on the right: icon + "Inspector" while collapsed, icon-only when open. + */ +.canvas-inspector-toggle.btn-sm { + position: absolute; + right: var(--space-3); + top: var(--space-3); + z-index: 5; + min-height: 36px; + padding: 0 var(--space-3) 0 10px; + gap: 6px; + font-size: 13px; +} +.canvas-inspector-toggle.btn-icon.btn-sm { + width: 36px; + padding: 0; +} /* Empty-canvas onboarding (canvas/EmptyCanvas.tsx), centred over the pane. */ .canvas-empty { @@ -621,10 +652,14 @@ button.breakpoint-toggle { display: none; } -/* Custom node chrome (AgentNode.tsx): a fixed 240px card. */ +/* + * Custom node chrome (AgentNode.tsx): a fixed 208px card - compact so the + * default four-stage graph fits a 1280px-wide builder at a legible zoom + * (keep in step with canvas/layout.ts, paletteActions.ts and specToGraph.ts). + */ .rf-node { position: relative; - width: 240px; + width: 208px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border); @@ -803,6 +838,9 @@ button.breakpoint-toggle { /* 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 { + /* Level with the header row (1px border + 44px head), not the card's middle: + cards differ in height, and centred ports made a straight pipeline jog. */ + top: 23px; z-index: 1; width: 10px; height: 10px; @@ -1108,6 +1146,9 @@ button.breakpoint-toggle { } @media (max-width: 860px) { + .canvas-inspector-toggle.btn-sm { + display: none; + } .shell { grid-template-columns: minmax(0, 1fr); overflow: hidden; diff --git a/apps/agent-forge/src/screens/agents/useInspectorCollapsed.ts b/apps/agent-forge/src/screens/agents/useInspectorCollapsed.ts new file mode 100644 index 000000000..e14fa0934 --- /dev/null +++ b/apps/agent-forge/src/screens/agents/useInspectorCollapsed.ts @@ -0,0 +1,43 @@ +import { useCallback, useState } from 'react'; +import { readStored, writeStored } from '../../components/storedValue'; + +/** localStorage key for the viewer's explicit Show/Hide inspector choice: "1" hidden, "0" shown. */ +export const INSPECTOR_COLLAPSED_KEY = 'forge.inspector.collapsed'; + +/** The viewer's explicit choice, or collapsed when they never made one. */ +function collapsedWithNothingSelected(): boolean { + return readStored(INSPECTOR_COLLAPSED_KEY) !== '0'; +} + +/** + * Whether the docked Inspector column is collapsed (wide layout only; below + * 860px it is a drawer the top bar opens). With nothing selected it is + * collapsed - so the canvas gets the width to show the whole graph legibly - + * unless the viewer pressed Show inspector, which is remembered. Selecting a + * node always opens it, as before; deselecting returns to the remembered + * choice. `toggle` is the explicit Show/Hide, and the only thing stored. + * + * Derived during render (no effect), so the Inspector opens in the same paint + * as the selection that opens it. + */ +export function useInspectorCollapsed(selectedNodeId: string | undefined): { collapsed: boolean; toggle: () => void } { + const [whenEmpty, setWhenEmpty] = useState(collapsedWithNothingSelected); + // Hide pressed over the current selection: hidden until the selection changes. + const [hiddenOverSelection, setHiddenOverSelection] = useState(false); + const [seenSelection, setSeenSelection] = useState(selectedNodeId); + if (seenSelection !== selectedNodeId) { + // React's "adjust state when a prop changes" pattern: re-renders before paint, no effect. + setSeenSelection(selectedNodeId); + setHiddenOverSelection(false); + } + const collapsed = selectedNodeId ? hiddenOverSelection && seenSelection === selectedNodeId : whenEmpty; + + const toggle = useCallback(() => { + const next = !collapsed; + writeStored(INSPECTOR_COLLAPSED_KEY, next ? '1' : '0'); + setWhenEmpty(next); + setHiddenOverSelection(next); + }, [collapsed]); + + return { collapsed, toggle }; +} diff --git a/apps/agent-forge/src/state/useAgentDocument.ts b/apps/agent-forge/src/state/useAgentDocument.ts index 6a7c1319e..37a5ace4a 100644 --- a/apps/agent-forge/src/state/useAgentDocument.ts +++ b/apps/agent-forge/src/state/useAgentDocument.ts @@ -25,10 +25,6 @@ function deriveSpec(graph: AgentGraphSpec, fallback: AgentSpec): AgentSpec { } } -function firstLlmNodeId(graph: AgentGraphSpec): string | undefined { - return graph.nodes.find((n) => n.type === 'llm')?.id; -} - interface AutosaveDeps { store: AgentStore; agentId: string; @@ -74,18 +70,23 @@ interface DocumentSetters { setDirty: (dirty: boolean) => void; } -/** Opens `nextSpec` / `nextGraph` as agent `id`; `select` selects its llm node, `clean` clears the dirty flag. */ +/** + * Opens `nextSpec` / `nextGraph` as agent `id` with nothing selected - so the + * Inspector stays collapsed and the canvas gets the width to show the whole + * graph legibly (AgentsScreen.tsx); `keepSelection` leaves the selection as + * it is (a rename), `clean` clears the dirty flag. + */ function showAgent( doc: DocumentSetters, id: string, nextSpec: AgentSpec, nextGraph: AgentGraphSpec, - { select = true, clean = true }: { select?: boolean; clean?: boolean } = {} + { keepSelection = false, clean = true }: { keepSelection?: boolean; clean?: boolean } = {} ): void { doc.setAgentId(id); doc.setGraphState(nextGraph); doc.lastValidSpec.current = nextSpec; - if (select) doc.setSelectedNodeId(firstLlmNodeId(nextGraph)); + if (!keepSelection) doc.setSelectedNodeId(undefined); if (clean) doc.setDirty(false); } @@ -136,7 +137,7 @@ async function renameSavedAgent({ store, agents, agentId, refreshAgents, doc }: const renamed: AgentSpec = { ...current, name: toId }; await store.save(toId, renamed); await store.remove(fromId); - if (fromId === agentId) showAgent(doc, toId, renamed, specToGraph(renamed), { select: false }); + if (fromId === agentId) showAgent(doc, toId, renamed, specToGraph(renamed), { keepSelection: true }); await refreshAgents(); } diff --git a/docs/agent-forge.md b/docs/agent-forge.md index f5120af32..bd114df3d 100644 --- a/docs/agent-forge.md +++ b/docs/agent-forge.md @@ -47,7 +47,10 @@ toolbar (the agent's name and run status, the model badge, then Debug, Stop, Run, Import, Export and Save), the agent rail (your saved agents plus a node/hook palette), the canvas, the Inspector (right), and a bottom drawer with Chat, Logs, Trace, Output, -History and Settings tabs. The other screens are one click away in the +History and Settings tabs. An agent opens with nothing selected and the +whole graph fitted in view; the Inspector stays folded away until you +select a node, or press **Show inspector** (the panel icon at the canvas's +top-right), which is remembered. The other screens are one click away in the navigation; see [Navigation](#navigation). Below 860px wide the navigation becomes a drawer opened from the diff --git a/llms-full.txt b/llms-full.txt index 703420529..594f4bf08 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1950,7 +1950,10 @@ toolbar (the agent's name and run status, the model badge, then Debug, Stop, Run, Import, Export and Save), the agent rail (your saved agents plus a node/hook palette), the canvas, the Inspector (right), and a bottom drawer with Chat, Logs, Trace, Output, -History and Settings tabs. The other screens are one click away in the +History and Settings tabs. An agent opens with nothing selected and the +whole graph fitted in view; the Inspector stays folded away until you +select a node, or press **Show inspector** (the panel icon at the canvas's +top-right), which is remembered. The other screens are one click away in the navigation; see [Navigation](#navigation). Below 860px wide the navigation becomes a drawer opened from the