Rework the dashboards onto the flow canvas

One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.

Dashboards gain the rest of M4's visualization work:

- widgets are picked by clicking them, with the header as the drag
  handle so a slider still slides and a switch still flips while
  editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
  mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
  from the stored history plus the live socket tail, coloured from the
  new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
  footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
  at all, carries the same red dot a failing node does; the picker
  records the type it bound and WidgetDef refuses a mismatch on save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 17:13:10 +02:00
co-authored by Claude Fable 5
parent 74bb956805
commit 0af09eedbe
24 changed files with 1799 additions and 1558 deletions
@@ -0,0 +1,31 @@
import { motion } from "motion/react"
import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { slideUp, transitions } from "@/lib/motion"
/**
* What you are looking at, floating top-centre over a full-bleed canvas.
*
* Reference rather than navigation: switching to another flow or dashboard is
* what the overviews are for, so this bar carries the name and nothing that
* takes you away from it. Shared by the flow editor and the dashboards so the
* two shells read as one.
*/
export function CanvasTitle({ children }: { children: ReactNode }) {
return (
<motion.div
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"
>
{/* The sidebar carries its own collapse control; a phone has no sidebar
on screen to carry it. */}
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
{children}
</motion.div>
)
}
+73
View File
@@ -1,12 +1,15 @@
import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Check,
Loader2,
Maximize2,
Pause,
Pencil,
Play,
PlayCircle,
Plus,
WifiOff,
ZoomIn,
ZoomOut,
} from "lucide-react"
@@ -28,6 +31,7 @@ import {
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
@@ -40,6 +44,10 @@ 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 — so the top of the
* canvas is left to say which flow this is.
*/
export function FlowDock({
flow,
@@ -47,25 +55,36 @@ export function FlowDock({
running,
enabled,
paused,
saving,
hasDraft,
publishing,
logs,
onAddNode,
onRun,
onTogglePause,
onFocusNode,
onEditFlow,
onPublish,
}: {
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
onFocusNode: (nodeId: string) => void
onEditFlow: () => void
onPublish: () => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
const connected = useLiveConnection()
return (
<motion.div
@@ -219,6 +238,60 @@ export function FlowDock({
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<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>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{!connected ? (
<WifiOff className="size-3.5" />
) : saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
)
}
+14 -13
View File
@@ -40,6 +40,7 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
@@ -54,7 +55,6 @@ import {
import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs"
import { LiveEdge } from "./LiveEdge"
import { NodePanel } from "./NodePanel"
import "./flow.css"
@@ -934,18 +934,11 @@ function FlowEditorInner({
panelOpen && !editorExpanded && "md:right-[27rem]",
)}
>
<FlowTabs
flows={flows.data}
active={flowName}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={() => void publishFlow()}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
/>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{flowDoc.title || flowName}
</span>
</CanvasTitle>
<FlowDock
flow={flowName}
@@ -953,6 +946,14 @@ function FlowEditorInner({
running={runMutation.isPending}
enabled={detail.enabled ?? true}
paused={paused}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
onPublish={() => void publishFlow()}
logs={{
open: logsOpen,
node: logsNode,
-239
View File
@@ -1,239 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { Check, Loader2, Pencil, Plus, WifiOff } from "lucide-react"
import { motion } from "motion/react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type FlowSummary, FlowsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { SidebarTrigger } from "@/components/ui/sidebar"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useLiveConnection } from "./liveStore"
import { flowKeys } from "./queries"
const nameSchema = z.object({
name: z
.string()
.min(1, "Give the flow a name")
.regex(
/^[a-z][a-z0-9_]*$/,
"Lowercase letters, digits and underscores, starting with a letter",
),
})
type NameForm = z.infer<typeof nameSchema>
function NewFlowDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const form = useForm<NameForm>({
resolver: zodResolver(nameSchema),
defaultValues: { name: "" },
})
const mutation = useMutation({
mutationFn: (values: NameForm) =>
FlowsService.saveFlow({
name: values.name,
requestBody: { name: values.name, nodes: [], inputs: [] },
}),
onSuccess: (_data, values) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
onOpenChange(false)
form.reset()
navigate({ to: "/flows/$flowName", params: { flowName: values.name } })
},
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="flow-name">Name</Label>
<Input
id="flow-name"
data-testid="flow-name-input"
placeholder="heating"
autoComplete="off"
{...form.register("name")}
/>
{form.formState.errors.name ? (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
) : null}
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating…" : "Create flow"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/**
* Flow switcher, floating top-centre over the canvas. Each flow is a chip:
* transparent at rest, filled when it is the one you are looking at.
*/
export function FlowTabs({
flows,
active,
saving,
hasDraft,
publishing,
onPublish,
onEditFlow,
}: {
flows: FlowSummary[]
active: string
saving: boolean
hasDraft: boolean
publishing: boolean
onPublish: () => void
onEditFlow: () => void
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const connected = useLiveConnection()
return (
<>
<motion.div
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"
>
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{flows.map((flow) => (
<Link
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className={cn(
"flex shrink-0 snap-start items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
flow.name === active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</Link>
))}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={() => setDialogOpen(true)}
aria-label="New flow"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>New flow</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{!connected ? (
<WifiOff className="size-3.5" />
) : saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="shrink-0 rounded-full"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}