Give the panel the canvas, and open a flow already fitted

Opening a node or the flow settings now takes the flowbar and the dock
away, leaving the graph and the panel; they come back on close, leaving
by the same slideUp they arrive with. The panel header names the flow
before the node, `/heating/`, which is how messages are qualified
everywhere else.

Opening a flow already fitted, and pressing Fit to screen, disagreed:
one used a 0.25 padding capped at 1x, the other xyflow's defaults, so
the button visibly jumped and a small flow opened at 100% in an empty
canvas. Both read one constant now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:40:09 +02:00
co-authored by Claude Opus 5
parent 2bc9f684e8
commit bcdc90edbb
4 changed files with 66 additions and 36 deletions
+10 -1
View File
@@ -25,6 +25,14 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion" 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 * The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet. * affordance on this view; everything else stays quiet.
@@ -49,6 +57,7 @@ export function FlowDock({
variants={slideUp} variants={slideUp}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="exit"
transition={transitions.emphasized} 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))]" 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" variant="ghost"
size="icon" size="icon"
className="size-11 text-muted-foreground md:size-8" 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" aria-label="Fit the flow to the screen"
> >
<Maximize2 /> <Maximize2 />
+39 -26
View File
@@ -19,6 +19,7 @@ import {
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react" import { Workflow } from "lucide-react"
import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
@@ -40,7 +41,7 @@ import useCustomToast from "@/hooks/useCustomToast"
import { CommandPalette } from "./CommandPalette" import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { FlowDock } from "./FlowDock" import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel" import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs" import { FlowTabs } from "./FlowTabs"
@@ -608,6 +609,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
) )
const selected = definitions.find((node) => node.id === selectedId) ?? null 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 ( return (
<> <>
@@ -646,7 +651,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
fitView fitView
fitViewOptions={{ maxZoom: 1, padding: 0.25 }} fitViewOptions={FIT_VIEW}
minZoom={0.25} minZoom={0.25}
maxZoom={2} maxZoom={2}
nodeDragThreshold={5} nodeDragThreshold={5}
@@ -660,30 +665,34 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
<CanvasBackground /> <CanvasBackground />
</ReactFlow> </ReactFlow>
{editorExpanded ? null : ( <AnimatePresence>
<FlowTabs {panelOpen ? null : (
flows={flows.data} <FlowTabs
active={flowName} key="flow-tabs"
saving={saving.isPending} flows={flows.data}
onEditFlow={() => { active={flowName}
setSelectedId(null) saving={saving.isPending}
setFlowPanelOpen(true) onEditFlow={() => {
}} setSelectedId(null)
/> setFlowPanelOpen(true)
)} }}
/>
)}
{editorExpanded ? null : ( {panelOpen ? null : (
<FlowDock <FlowDock
issues={issues} key="flow-dock"
running={runMutation.isPending} issues={issues}
onAddNode={() => setPaletteOpen(true)} running={runMutation.isPending}
onRun={() => { onAddNode={() => setPaletteOpen(true)}
flush() onRun={() => {
runMutation.mutate() flush()
}} runMutation.mutate()
onFocusNode={focusNode} }}
/> onFocusNode={focusNode}
)} />
)}
</AnimatePresence>
{definitions.length === 0 ? ( {definitions.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -857,7 +866,11 @@ export function FlowEditor({ flowName }: { flowName: string }) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
{/* 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.
*/}
<FlowEditorInner key={flowName} flowName={flowName} /> <FlowEditorInner key={flowName} flowName={flowName} />
</ReactFlowProvider> </ReactFlowProvider>
) )
@@ -130,6 +130,7 @@ export function FlowTabs({
variants={slideUp} variants={slideUp}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="exit"
transition={transitions.emphasized} 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" 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"
> >
+15 -8
View File
@@ -459,14 +459,21 @@ export function NodePanel({
onClose={onClose} onClose={onClose}
header={ header={
node ? ( node ? (
<Input <div className="flex min-w-0 flex-1 items-center gap-1.5">
value={node.title || node.id} {/* Where the node lives: the flow is the namespace of its
aria-label="Node name" messages, so it reads like the folder holding them. */}
className="h-8 flex-1 text-sm font-medium" <span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
onChange={(event) => /{flow}/
onChange({ ...node, title: event.target.value }) </span>
} <Input
/> value={node.title || node.id}
aria-label="Node name"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) =>
onChange({ ...node, title: event.target.value })
}
/>
</div>
) : null ) : null
} }
footer={ footer={