Files
app/frontend/src/components/Flow/FlowDock.tsx
T
stroblmeandClaude Opus 5 bb90a24b90 Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
2026-08-17 17:35:14 +02:00

370 lines
11 KiB
TypeScript

import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Check,
Loader2,
Maximize2,
Pause,
Pencil,
Play,
PlayCircle,
Plus,
StepForward,
WifiOff,
X,
ZoomIn,
ZoomOut,
} from "lucide-react"
import { motion } from "motion/react"
import type { ValidationIssue } from "@/client"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { type LogsFilter, LogsPanel } from "./LogsPanel"
import { useLiveConnection } from "./liveStore"
/**
* 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.
*
* Everything the flow bar used to carry is here too — whether the work is
* saved, the flow's own settings, and putting it live or throwing it away —
* so the top of the canvas is left to say which flow this is.
*
* It wraps rather than overflows, and drops the zoom controls on a phone; see
* DESIGN-GUIDELINES.md → Responsive.
*/
export function FlowDock({
flow,
issues,
running,
enabled,
paused,
saving,
hasDraft,
publishing,
logs,
onAddNode,
onRun,
onTogglePause,
onStep,
stepping,
onFocusNode,
onEditFlow,
onPublish,
onDiscard,
}: {
flow: string
issues: ValidationIssue[]
running: boolean
enabled: boolean
paused: boolean
saving: boolean
hasDraft: boolean
publishing: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void
onRun: () => void
onTogglePause: () => void
onStep: () => void
stepping: boolean
onFocusNode: (nodeId: string) => void
onEditFlow: () => void
onPublish: () => void
onDiscard: () => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
const connected = useLiveConnection()
return (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
// Capped and wrapping: the canvas shell clips, so an uncapped row would
// put the buttons at its ends out of reach on a phone rather than merely
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-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))]"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onAddNode}
aria-label="Add node"
data-testid="add-node"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (K)</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
{/* A phone pinches to zoom and the graph fits itself, so these three
would only be taking room the rest of the bar needs. */}
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => zoomOut()}
aria-label="Zoom out"
>
<ZoomOut />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
<Maximize2 />
</Button>
</TooltipTrigger>
<TooltipContent>Fit to screen</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => zoomIn()}
aria-label="Zoom in"
>
<ZoomIn />
</Button>
{issues.length > 0 ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-11 gap-1.5 text-destructive md:h-8"
data-testid="validation-summary"
>
<AlertCircle className="size-4" />
{issues.length}
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-80 p-2">
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Needs attention
</p>
<ul className="mt-1 grid gap-0.5">
{issues.map((issue) => (
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
<button
type="button"
className="w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent"
onClick={() => issue.node && onFocusNode(issue.node)}
disabled={!issue.node}
>
{issue.message}
</button>
</li>
))}
</ul>
</PopoverContent>
</Popover>
</>
) : null}
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<LogsPanel flow={flow} {...logs} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
paused && "text-primary",
)}
onClick={onTogglePause}
disabled={!enabled}
aria-label={paused ? "Resume flow" : "Pause flow"}
data-testid={paused ? "resume-flow" : "pause-flow"}
>
{paused ? <PlayCircle /> : <Pause />}
</Button>
</TooltipTrigger>
<TooltipContent>
{!enabled
? "This flow is stopped"
: paused
? "Let the flow carry on"
: "Hold the nodes; values still arrive"}
</TooltipContent>
</Tooltip>
{paused && enabled ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onStep}
disabled={stepping}
aria-label="Step the flow"
data-testid="step-flow"
>
<StepForward />
</Button>
</TooltipTrigger>
<TooltipContent>Let one held-back item through</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="brand"
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={onRun}
disabled={running || !enabled}
data-testid="run-flow"
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
{/* Throwing the edit away sits next to putting it live, and only exists
while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onDiscard}
aria-label="Discard the unpublished changes"
data-testid="discard-draft"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
) : null}
{/*
* Saved state and publish are one control: the glyph never moves, it
* simply stops being something you can press once there is nothing left
* to put live. A button that appears and disappears moved everything
* beside it just as the work was finished.
*/}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || !connected}
aria-label="Publish this flow"
data-testid="publish-flow"
>
{!connected ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
</motion.div>
)
}