diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index acbe2af..3c34312 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -25,6 +25,14 @@ import {
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
+/**
+ * What "fit" means on this canvas: the view a flow opens with, and the one the
+ * fit button returns to. The generous padding keeps the graph clear of the
+ * chrome floating over it, and the cap stops a two-node flow from being blown
+ * up past legibility.
+ */
+export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
+
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
@@ -49,6 +57,7 @@ export function FlowDock({
variants={slideUp}
initial="hidden"
animate="visible"
+ exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
@@ -85,7 +94,7 @@ export function FlowDock({
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
- onClick={() => fitView({ duration: 300 })}
+ onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index f40151e..b0b8097 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -19,6 +19,7 @@ import {
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
+import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
@@ -40,7 +41,7 @@ import useCustomToast from "@/hooks/useCustomToast"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
-import { FlowDock } from "./FlowDock"
+import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs"
@@ -608,6 +609,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
)
const selected = definitions.find((node) => node.id === selectedId) ?? null
+ // A panel is the view you are working in: the bars would only compete with
+ // it, so they step aside until it closes. On a phone the panel covers them
+ // anyway, and its own close button is the way back.
+ const panelOpen = Boolean(selected) || flowPanelOpen
return (
<>
@@ -646,7 +651,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
fitView
- fitViewOptions={{ maxZoom: 1, padding: 0.25 }}
+ fitViewOptions={FIT_VIEW}
minZoom={0.25}
maxZoom={2}
nodeDragThreshold={5}
@@ -660,30 +665,34 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
- {editorExpanded ? null : (
- {
- setSelectedId(null)
- setFlowPanelOpen(true)
- }}
- />
- )}
+
+ {panelOpen ? null : (
+ {
+ setSelectedId(null)
+ setFlowPanelOpen(true)
+ }}
+ />
+ )}
- {editorExpanded ? null : (
- setPaletteOpen(true)}
- onRun={() => {
- flush()
- runMutation.mutate()
- }}
- onFocusNode={focusNode}
- />
- )}
+ {panelOpen ? null : (
+ setPaletteOpen(true)}
+ onRun={() => {
+ flush()
+ runMutation.mutate()
+ }}
+ onFocusNode={focusNode}
+ />
+ )}
+
{definitions.length === 0 ? (
@@ -857,7 +866,11 @@ export function FlowEditor({ flowName }: { flowName: string }) {
return (
- {/* Remounting per flow keeps canvas state from leaking between them. */}
+ {/*
+ * Remounting per flow keeps canvas state from leaking between them, and
+ * it is what makes `fitView` run once per flow: xyflow queues the fit on
+ * mount and resolves it as soon as the nodes have been measured.
+ */}
)
diff --git a/frontend/src/components/Flow/FlowTabs.tsx b/frontend/src/components/Flow/FlowTabs.tsx
index baf39e8..050342d 100644
--- a/frontend/src/components/Flow/FlowTabs.tsx
+++ b/frontend/src/components/Flow/FlowTabs.tsx
@@ -130,6 +130,7 @@ export function FlowTabs({
variants={slideUp}
initial="hidden"
animate="visible"
+ exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
>
@@ -200,7 +201,7 @@ export function FlowTabs({
{!connected
? "Reconnecting to the engine"
: saving
- ? "Saving"
+ ? "Saving"
: "All changes saved"}
diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx
index 5901a3f..5f649db 100644
--- a/frontend/src/components/Flow/NodePanel.tsx
+++ b/frontend/src/components/Flow/NodePanel.tsx
@@ -459,14 +459,21 @@ export function NodePanel({
onClose={onClose}
header={
node ? (
-
- onChange({ ...node, title: event.target.value })
- }
- />
+
+ {/* Where the node lives: the flow is the namespace of its
+ messages, so it reads like the folder holding them. */}
+
+ /{flow}/
+
+
+ onChange({ ...node, title: event.target.value })
+ }
+ />
+