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 }) + } + /> +
) : null } footer={