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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user