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
370 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|