diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 454550a..b391f33 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -20,6 +20,7 @@ import { } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Workflow } from "lucide-react" +import { motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { @@ -40,6 +41,7 @@ import { } from "@/components/ui/dialog" import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" +import { scaleIn } from "@/lib/motion" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" @@ -507,9 +509,18 @@ function FlowEditorInner({ // A relayout can put a new node outside the viewport, and turning the graph // on its side moves everything. Both want the whole flow back in view. + // The first fit is instant: an animated one travels from React Flow's + // default viewport to the content, which is the whole flow visibly sliding + // in from the corner every time one is opened. Later fits move from + // somewhere the user was already looking, so those stay animated. + const fitted = useRef(false) // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render. useEffect(() => { - fitView({ ...FIT_VIEW, duration: 300 }) + const frame = requestAnimationFrame(() => { + fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW) + fitted.current = true + }) + return () => cancelAnimationFrame(frame) }, [direction, definitions.length, external.nodes.length, fitView]) const runMutation = useMutation({ @@ -877,68 +888,90 @@ function FlowEditorInner({ return ( <> - - deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) + {/* + * Grows in from the centre rather than arriving mid-pan, the same + * entrance the brain view uses. React Flow reads a node's handle bounds + * out of the DOM once and never again, and a reading taken mid-scale is + * stored a few percent short for good — `LiveEdge` draws off the + * `sourceX`/`targetX` that come from those handles, so every edge would + * land short of its port forever. Remeasuring once the wrapper is back + * at `scale: 1` is what makes the entrance safe. + */} + + updateNodeInternals([ + ...definitions.map((node) => node.id), + ...external.nodes.map((node) => node.id), + ]) } - onNodeClick={(_event, node) => { - // An endpoint is somewhere else's: opening its panel here would - // offer to edit a node this flow does not contain. - if (!isDocumentNode(node)) { - openEndpoint(node.id) - return - } - setFlowPanelOpen(false) - setSelectedId(node.id) - }} - onPaneClick={() => { - // Clicking the canvas is how you put a panel away, whichever one it - // is: the graph is what you went back to look at. - setSelectedId(null) - setFlowPanelOpen(false) - setEditorExpanded(false) - setInspected(null) - }} - onEdgeClick={(event, edge) => { - const label = (id: string) => { - const node = definitions.find((entry) => entry.id === id) - return node?.title || node?.id || id - } - setInspected({ - message: (edge.data as { message: string }).message, - from: label(edge.source), - to: label(edge.target), - sourceId: edge.source, - x: event.clientX, - y: event.clientY, - }) - }} - onConnect={onConnect} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - proOptions={{ hideAttribution: true }} - fitView - fitViewOptions={FIT_VIEW} - // Low enough that the fit can always show the whole graph. A phone is - // 390px wide and a rank of several nodes is thousands, so a floor of - // 0.25 left the fit silently short and the flow running off screen. - minZoom={0.1} - maxZoom={2} - // The graph places itself. Nothing here is arranged by hand, which is - // what keeps a flow small enough to read at a glance. - nodesDraggable={false} - connectionRadius={30} - connectOnClick - autoPanOnConnect - edgesReconnectable={false} - deleteKeyCode={["Backspace", "Delete"]} className="h-full w-full" > - - + + deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) + } + onNodeClick={(_event, node) => { + // An endpoint is somewhere else's: opening its panel here would + // offer to edit a node this flow does not contain. + if (!isDocumentNode(node)) { + openEndpoint(node.id) + return + } + setFlowPanelOpen(false) + setSelectedId(node.id) + }} + onPaneClick={() => { + // Clicking the canvas is how you put a panel away, whichever one it + // is: the graph is what you went back to look at. + setSelectedId(null) + setFlowPanelOpen(false) + setEditorExpanded(false) + setInspected(null) + }} + onEdgeClick={(event, edge) => { + const label = (id: string) => { + const node = definitions.find((entry) => entry.id === id) + return node?.title || node?.id || id + } + setInspected({ + message: (edge.data as { message: string }).message, + from: label(edge.source), + to: label(edge.target), + sourceId: edge.source, + x: event.clientX, + y: event.clientY, + }) + }} + onConnect={onConnect} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + proOptions={{ hideAttribution: true }} + fitView + fitViewOptions={FIT_VIEW} + // Low enough that the fit can always show the whole graph. A phone is + // 390px wide and a rank of several nodes is thousands, so a floor of + // 0.25 left the fit silently short and the flow running off screen. + minZoom={0.1} + maxZoom={2} + // The graph places itself. Nothing here is arranged by hand, which is + // what keeps a flow small enough to read at a glance. + nodesDraggable={false} + connectionRadius={30} + connectOnClick + autoPanOnConnect + edgesReconnectable={false} + deleteKeyCode={["Backspace", "Delete"]} + className="h-full w-full" + > + + + {/* * The bars keep their own lane rather than making way for a panel: they diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index c0ccca1..d4932de 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -116,7 +116,10 @@ function Dashboard() { // Every section here is free to shrink instead. See DESIGN-GUIDELINES.md // → Responsive.
- + {/* Nothing wired up yet means nothing to draw, and the band would still + hold a screenful of empty space above the flows card. Node count + rather than flow count: a flow made a minute ago has none. */} + {flows.some((flow) => (flow.node_count ?? 0) > 0) ? : null} {isPending ? (