From a9f91db619b9106d6387c3d40946126291fb64dd Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 08:43:16 +0200 Subject: [PATCH] Grow the flow editor in from the centre, hide the empty brain band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's mount-time fitView animated from React Flow's default viewport, which read as the graph swiping in from the corner on every open. The first fit is instant now, later ones stay animated, and a scaleIn wrapper gives the same entrance the brain view has — with a re-measure on completion so the handle bounds are not stored mid-scale. Home only renders the brain band once some flow has nodes, so a fresh install no longer reserves a screenful of empty space above the flows card. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA --- frontend/src/components/Flow/FlowEditor.tsx | 153 ++++++++++++-------- frontend/src/routes/_layout/index.tsx | 5 +- 2 files changed, 97 insertions(+), 61 deletions(-) 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 ? (