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 ? (