Grow the flow editor in from the centre, hide the empty brain band

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 08:43:16 +02:00
co-authored by Claude Opus 5
parent cd8fbbcc8c
commit 86e02b49a3
2 changed files with 97 additions and 61 deletions
+93 -60
View File
@@ -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 (
<>
<ReactFlow
nodes={shownNodes}
edges={edges}
onNodesChange={trackMeasured}
onNodesDelete={(deleted) =>
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.
*/}
<motion.div
variants={scaleIn}
initial="hidden"
animate="visible"
onAnimationComplete={() =>
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"
>
<CanvasBackground />
</ReactFlow>
<ReactFlow
nodes={shownNodes}
edges={edges}
onNodesChange={trackMeasured}
onNodesDelete={(deleted) =>
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"
>
<CanvasBackground />
</ReactFlow>
</motion.div>
{/*
* The bars keep their own lane rather than making way for a panel: they
+4 -1
View File
@@ -116,7 +116,10 @@ function Dashboard() {
// Every section here is free to shrink instead. See DESIGN-GUIDELINES.md
// → Responsive.
<div className="grid gap-6 [&>*]:min-w-0">
<BrainView />
{/* 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) ? <BrainView /> : null}
<Card className="gap-0 py-0">
{isPending ? (