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
225 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|