Files
app/frontend/src/components/Flow/FlowDock.tsx
T
stroblmeandClaude Fable 5 fa12ffd323 Copy nodes, bind the keys, and keep publish within reach
The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.

Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.

A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:43:41 +02:00

225 lines
6.5 KiB
TypeScript

import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Loader2,
Maximize2,
Pause,
Play,
PlayCircle,
Plus,
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"
/**
* 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.
*/
export function FlowDock({
flow,
issues,
running,
enabled,
paused,
logs,
onAddNode,
onRun,
onTogglePause,
onFocusNode,
}: {
flow: string
issues: ValidationIssue[]
running: boolean
enabled: boolean
paused: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void
onRun: () => void
onTogglePause: () => void
onFocusNode: (nodeId: string) => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
return (
<motion.div
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))]"
>
<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" />
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => zoomOut()}
aria-label="Zoom out"
>
<ZoomOut />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground 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="size-11 text-muted-foreground md:size-8"
onClick={() => zoomIn()}
aria-label="Zoom in"
>
<ZoomIn />
</Button>
{issues.length > 0 ? (
<>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<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" />
<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>
<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>
</motion.div>
)
}