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:
co-authored by
Claude Opus 5
parent
2bc9f684e8
commit
bcdc90edbb
@@ -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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
@@ -200,7 +201,7 @@ export function FlowTabs({
|
|||||||
{!connected
|
{!connected
|
||||||
? "Reconnecting to the engine"
|
? "Reconnecting to the engine"
|
||||||
: saving
|
: saving
|
||||||
? "Saving"
|
? "Saving"
|
||||||
: "All changes saved"}
|
: "All changes saved"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user