Add the flow editor: canvas, node panel and live values

The browser half of M3. Flows open on a full-bleed canvas with their chrome
floating over it: flow tabs top, dock bottom, node settings in a panel on the
right that leaves the graph visible and running behind it.

- Connections are derived, not stored. A node declares the messages it reads
  and publishes; every matching pair draws an edge, so two producers of one
  message converge on their consumer. Dragging output to input is shorthand
  for pointing that input at the producer's message, and asks before it
  replaces an existing one.
- Values land on the edges as they flow, over a websocket that feeds a store
  outside React, so a value arriving re-renders its own chip and nothing else.
  Clicking an edge shows the last payload and when it arrived.
- Node source is edited in Monaco, loaded only when a panel opens and themed
  from the design tokens.
- Edits autosave; identical documents are skipped server-side, so a quiet
  canvas writes nothing.
- Validation from the API shows on the node it belongs to and is summarised in
  the dock, where each entry pans to its node.
- Works on a phone: touch-connect, 44px dock targets, and the node panel
  becomes a full-screen sheet.

Two new tokens (--status-success, --font-mono) are mirrored in the website repo
and recorded in DESIGN-GUIDELINES.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 18:10:50 +02:00
co-authored by Claude Fable 5
parent 06a4506767
commit 8c82549cf6
40 changed files with 5027 additions and 66 deletions
@@ -0,0 +1,113 @@
import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import type { FlowSummary, NodeTypeInfo } from "@/client"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
/**
* ⌘K: add a node, jump to another flow, or run the current one, without
* reaching for the dock.
*/
export function CommandPalette({
open,
onOpenChange,
nodeTypes,
flows,
onAddNode,
onRun,
}: {
open: boolean
onOpenChange: (open: boolean) => void
nodeTypes: NodeTypeInfo[]
flows: FlowSummary[]
onAddNode: (type: string) => void
onRun: () => void
}) {
const navigate = useNavigate()
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
onOpenChange(!open)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onOpenChange])
// Picking an item re-renders the canvas, which can interrupt the dialog's
// exit animation and leave its overlay swallowing clicks. Unmounting the
// dialog outright is deterministic; the palette does not need to fade out.
if (!open) return null
const close = (action: () => void) => {
onOpenChange(false)
action()
}
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Commands"
description="Add a node, switch flow, or run"
>
<CommandInput placeholder="Add a node, switch flow, run…" />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
<CommandGroup heading="Add a node">
{nodeTypes.map((nodeType) => (
<CommandItem
key={nodeType.type}
value={`${nodeType.title} ${nodeType.description}`}
onSelect={() => close(() => onAddNode(nodeType.type))}
>
<span className="flex flex-col">
<span>{nodeType.title}</span>
<span className="text-xs text-muted-foreground">
{nodeType.description}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
{flows.length > 0 ? (
<CommandGroup heading="Flows">
{flows.map((flow) => (
<CommandItem
key={flow.name}
value={`flow ${flow.name} ${flow.title}`}
onSelect={() =>
close(() =>
navigate({
to: "/flows/$flowName",
params: { flowName: flow.name },
}),
)
}
>
{flow.title || flow.name}
</CommandItem>
))}
</CommandGroup>
) : null}
<CommandGroup heading="Actions">
<CommandItem value="run flow" onSelect={() => close(onRun)}>
Run this flow
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
@@ -0,0 +1,83 @@
import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { displayName } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
function relativeTime(ts: number | null | undefined): string {
if (!ts) return "not seen yet"
const seconds = Math.max(0, Math.round(Date.now() / 1000 - ts))
if (seconds < 5) return "just now"
if (seconds < 60) return `${seconds}s ago`
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
return `${Math.round(seconds / 3600)}h ago`
}
export type InspectedEdge = {
message: string
x: number
y: number
}
/**
* What last travelled along an edge. The value is whatever the producing node
* published, shown as it was serialised.
*/
export function EdgeInspector({
edge,
flow,
onClose,
onUnbind,
}: {
edge: InspectedEdge | null
flow: string
onClose: () => void
onUnbind: (message: string) => void
}) {
const live = useLiveValue(edge?.message)
if (!edge) return null
return (
<Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{ position: "fixed", left: edge.x, top: edge.y }}
className="size-0"
/>
<PopoverContent
align="center"
className="w-72 p-3"
data-testid="edge-inspector"
>
<p className="font-mono text-xs text-muted-foreground">
{displayName(flow, edge.message)}
</p>
{live === undefined ? (
<p className="mt-2 text-sm text-muted-foreground">
Nothing has come through yet. Run the flow to see a value here.
</p>
) : (
<>
<ScrollArea className="mt-2 max-h-48">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{JSON.stringify(live.value, null, 2)}
</pre>
</ScrollArea>
<p className="mt-2 text-xs text-muted-foreground">
{relativeTime(live.ts)}
</p>
</>
)}
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 w-full justify-start text-xs text-muted-foreground"
onClick={() => onUnbind(edge.message)}
>
Disconnect
</Button>
</PopoverContent>
</Popover>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Loader2,
Maximize2,
Play,
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"
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
*/
export function FlowDock({
issues,
running,
onAddNode,
onRun,
onFocusNode,
}: {
issues: ValidationIssue[]
running: boolean
onAddNode: () => void
onRun: () => void
onFocusNode: (nodeId: string) => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
return (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
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({ 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" />
<Button
variant="brand"
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={onRun}
disabled={running}
data-testid="run-flow"
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</motion.div>
)
}
+521
View File
@@ -0,0 +1,521 @@
import {
Background,
BackgroundVariant,
type Connection,
type Node as FlowCanvasNode,
ReactFlow,
ReactFlowProvider,
useNodesState,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { type FlowDef_Input, FlowsService, type NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import useCustomToast from "@/hooks/useCustomToast"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowTabs } from "./FlowTabs"
import { LiveEdge } from "./LiveEdge"
import { NodePanel } from "./NodePanel"
import "./flow.css"
import { liveStore } from "./liveStore"
import {
flowQueryOptions,
flowsQueryOptions,
nodeTypesQueryOptions,
useAutosave,
} from "./queries"
import { useFlowSocket } from "./useFlowSocket"
const nodeTypes = { flow: FlowNode }
const edgeTypes = { live: LiveEdge }
type Rebind = {
nodeId: string
port: string
from: string
to: string
}
/** Step a new node off any node already sitting at that spot. */
function freePosition(
nodes: NodeDef_Input[],
start: { x: number; y: number },
): { x: number; y: number } {
const position = { ...start }
// Roughly a node's footprint, so a nudged node clears the one below it.
const occupied = () =>
nodes.some(
(node) =>
Math.abs((node.position?.x ?? 0) - position.x) < 220 &&
Math.abs((node.position?.y ?? 0) - position.y) < 80,
)
while (occupied()) {
position.x += 48
position.y += 96
}
return position
}
/** A name that does not collide with the nodes already on the canvas. */
function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
const taken = new Set(existing.map((node) => node.id))
for (let index = 1; ; index += 1) {
const candidate = index === 1 ? type : `${type}_${index}`
if (!taken.has(candidate)) return candidate
}
}
function FlowEditorInner({ flowName }: { flowName: string }) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { screenToFlowPosition, fitView } = useReactFlow()
const updateNodeInternals = useUpdateNodeInternals()
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName))
const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions())
const { save, flush, mutation: saving } = useAutosave(flowName)
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
() => detail.definition.nodes ?? [],
)
const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes(
detail.definition.nodes ?? [],
)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
const [rebind, setRebind] = useState<Rebind | null>(null)
const issues = detail.issues ?? []
// Keep the latest document in a ref so autosave never captures a stale copy.
const latest = useRef<FlowDef_Input>(detail.definition)
const commit = useCallback(
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
const placed = nodes.map((node) => {
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
return canvas ? { ...node, position: canvas.position } : node
})
setDefinitions(placed)
const next: FlowDef_Input = { ...detail.definition, nodes: placed }
latest.current = next
save(next)
},
[canvasNodes, detail.definition, save],
)
const typeLabels = useMemo(
() => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])),
[nodeTypeInfo],
)
const issuesByNode = useMemo(() => {
const map = new Map<string, string[]>()
for (const issue of issues) {
if (!issue.node) continue
const list = map.get(issue.node) ?? []
list.push(issue.message)
map.set(issue.node, list)
}
return map
}, [issues])
// Canvas nodes carry the definition so the node component can render it.
const renderedNodes = useMemo(
() =>
canvasNodes.map((node) => {
const definition = definitions.find((entry) => entry.id === node.id)
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
return {
...node,
selected: node.id === selectedId,
data: {
definition: definition ?? { id: node.id },
flow: flowName,
typeLabel:
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
issues: nodeIssues.length,
issueText: nodeIssues.join("\n"),
} satisfies FlowNodeData,
}
}),
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
)
// Edges follow from the name bindings, so they are derived, never stored.
const key = bindingsKey(definitions)
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
const edges = useMemo(
() => deriveEdges(definitions, flowName),
[key, flowName],
)
// Editing ports adds and removes handles. React Flow measures those once, so
// it has to be told, or an edge to a brand-new handle never gets drawn.
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
useEffect(() => {
updateNodeInternals(definitions.map((node) => node.id))
}, [key, updateNodeInternals])
const runMutation = useMutation({
mutationFn: () =>
FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }),
onSuccess: (state) => {
liveStore.setValues(
Object.fromEntries(
Object.entries(state.values ?? {}).map(([name, value]) => [
name,
{ value: value.value, ts: value.ts ?? null },
]),
),
)
},
onError: () =>
showErrorToast("The flow could not run. Check the node errors."),
})
const sourceMutation = useMutation({
mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) =>
FlowsService.saveNodeSource({
name: flowName,
nodeId,
requestBody: { code },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["flows", flowName] })
},
})
const addNode = useCallback(
(type: string) => {
const id = uniqueNodeId(definitions, type)
// Drop it where the user is looking, but never on top of another node.
const position = freePosition(
definitions,
screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
}),
)
const node: NodeDef_Input = {
id,
type,
position,
params: {},
requires: [],
provides: [],
}
const nextDefinitions = [...definitions, node]
const nextCanvas = [
...canvasNodes,
{ id, type: "flow", position, data: {} } as FlowCanvasNode,
]
setCanvasNodes(nextCanvas)
commit(nextDefinitions, nextCanvas)
setSelectedId(id)
},
[canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes],
)
const updateNode = useCallback(
(next: NodeDef_Input) => {
commit(definitions.map((node) => (node.id === next.id ? next : node)))
},
[commit, definitions],
)
const deleteNodes = useCallback(
(ids: string[]) => {
const remaining = definitions.filter((node) => !ids.includes(node.id))
setCanvasNodes(canvasNodes.filter((node) => !ids.includes(node.id)))
commit(remaining)
if (selectedId && ids.includes(selectedId)) setSelectedId(null)
},
[canvasNodes, commit, definitions, selectedId, setCanvasNodes],
)
const applyBinding = useCallback(
(nodeId: string, port: string, message: string) => {
commit(
definitions.map((node) =>
node.id === nodeId
? {
...node,
requires: (node.requires ?? []).map((spec) =>
portOf(spec) === port
? { ...spec, name: message, port }
: spec,
),
}
: node,
),
)
},
[commit, definitions],
)
/**
* Dragging output to input is shorthand for "consume what that node
* publishes": it points the input at the producer's message name.
*/
const onConnect = useCallback(
(connection: Connection) => {
const producer = definitions.find((node) => node.id === connection.source)
const consumer = definitions.find((node) => node.id === connection.target)
if (!producer || !consumer) return
const outSpec = (producer.provides ?? []).find(
(spec) => portOf(spec) === connection.sourceHandle,
)
const inSpec = (consumer.requires ?? []).find(
(spec) => portOf(spec) === connection.targetHandle,
)
if (!outSpec?.name || !inSpec) return
if (inSpec.name && inSpec.name !== outSpec.name) {
setRebind({
nodeId: consumer.id,
port: portOf(inSpec),
from: inSpec.name,
to: outSpec.name,
})
return
}
applyBinding(consumer.id, portOf(inSpec), outSpec.name)
},
[definitions, applyBinding],
)
const unbind = useCallback(
(message: string) => {
const qualified = qualify(flowName, message)
commit(
definitions.map((node) => ({
...node,
requires: (node.requires ?? []).map((spec) =>
qualify(flowName, spec.name ?? "") === qualified
? { ...spec, name: "", port: portOf(spec) }
: spec,
),
})),
)
setInspected(null)
},
[commit, definitions, flowName],
)
const focusNode = useCallback(
(qualifiedId: string) => {
const id = qualifiedId.startsWith(`${flowName}.`)
? qualifiedId.slice(flowName.length + 1)
: qualifiedId
fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 })
setSelectedId(id)
},
[fitView, flowName],
)
const selected = definitions.find((node) => node.id === selectedId) ?? null
return (
<>
<ReactFlow
nodes={renderedNodes}
edges={edges}
onNodesChange={onNodesChange}
onNodeDragStop={(_event, _node, dragged) =>
commit(definitions, mergeDragged(canvasNodes, dragged))
}
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
onNodeClick={(_event, node) => setSelectedId(node.id)}
onPaneClick={() => {
setSelectedId(null)
setInspected(null)
}}
onEdgeClick={(event, edge) => {
setInspected({
message: (edge.data as { message: string }).message,
x: event.clientX,
y: event.clientY,
})
}}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ maxZoom: 1, padding: 0.25 }}
minZoom={0.25}
maxZoom={2}
nodeDragThreshold={5}
connectionRadius={30}
connectOnClick
autoPanOnConnect
edgesReconnectable={false}
deleteKeyCode={["Backspace", "Delete"]}
className="h-full w-full"
>
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow>
<FlowTabs
flows={flows.data}
active={flowName}
saving={saving.isPending}
/>
<FlowDock
issues={issues}
running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)}
onRun={() => {
flush()
runMutation.mutate()
}}
onFocusNode={focusNode}
/>
{definitions.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-6" />
</span>
<p className="text-lg font-medium">This flow is empty</p>
<p className="max-w-xs text-sm text-muted-foreground">
Add a node to get started. Press K, or use the plus in the bar
below.
</p>
</div>
</div>
) : null}
<NodePanel
node={selected}
flow={flowName}
nodeTypes={nodeTypeInfo ?? []}
onChange={updateNode}
onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
}}
onClose={() => {
flush()
setSelectedId(null)
}}
onDelete={() => selected && deleteNodes([selected.id])}
/>
<EdgeInspector
edge={inspected}
flow={flowName}
onClose={() => setInspected(null)}
onUnbind={unbind}
/>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
nodeTypes={nodeTypeInfo ?? []}
flows={flows.data}
onAddNode={addNode}
onRun={() => runMutation.mutate()}
/>
<Dialog
open={Boolean(rebind)}
onOpenChange={(open) => !open && setRebind(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Change what this input reads?</DialogTitle>
<DialogDescription>
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
{rebind?.to} instead?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRebind(null)}>
Keep {rebind?.from}
</Button>
<Button
onClick={() => {
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
setRebind(null)
}}
>
Read {rebind?.to}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
function mergeDragged(
nodes: FlowCanvasNode[],
dragged: FlowCanvasNode[],
): FlowCanvasNode[] {
const moved = new Map(dragged.map((node) => [node.id, node.position]))
return nodes.map((node) =>
moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node,
)
}
/** Seed xyflow's own node state once; it owns positions while you drag. */
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
return useNodesState<FlowCanvasNode>(
definitions.map((node) => ({
id: node.id,
type: "flow",
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
data: {},
})),
)
}
export function FlowEditor({ flowName }: { flowName: string }) {
const navigate = useNavigate()
const onAuthFailure = useCallback(() => {
navigate({ to: "/login" })
}, [navigate])
useFlowSocket(onAuthFailure)
useEffect(() => {
return () => liveStore.reset()
}, [])
return (
<ReactFlowProvider>
{/* Remounting per flow keeps canvas state from leaking between them. */}
<FlowEditorInner key={flowName} flowName={flowName} />
</ReactFlowProvider>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
AlertCircle,
Braces,
Clock,
Code2,
Database,
Globe,
Radio,
} from "lucide-react"
import { memo } from "react"
import type { MessageSpec, NodeDef_Input } from "@/client"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges"
import { useNodeStatus } from "./liveStore"
const NODE_ICONS = {
python: Code2,
mqtt: Radio,
http: Globe,
influxdb: Database,
delay: Clock,
mlp: Braces,
} as const
const STATUS_STYLES = {
running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" },
error: { dot: "bg-destructive", label: "Failed" },
} as const
export type FlowNodeData = {
definition: NodeDef_Input
flow: string
typeLabel: string
issues: number
issueText: string
[key: string]: unknown
}
/** Vertically distribute handles so several ports stay reachable. */
function handleOffset(index: number, total: number): string {
if (total <= 1) return "50%"
const span = 60
return `${50 - span / 2 + (span / (total - 1)) * index}%`
}
function PortHandles({
specs,
type,
position,
}: {
specs: MessageSpec[]
type: "source" | "target"
position: Position
}) {
return (
<>
{specs.map((spec, index) => {
const port = portOf(spec)
return (
<Handle
key={`${type}-${port}`}
id={port}
type={type}
position={position}
className={cn(
"!bg-card !border-muted-foreground/60",
!spec.name && "unbound",
)}
style={{ top: handleOffset(index, specs.length) }}
/>
)
})}
</>
)
}
function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, issues, issueText } =
data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`)
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
const status = live?.status === "active" ? undefined : live?.status
const style = status
? STATUS_STYLES[status as keyof typeof STATUS_STYLES]
: undefined
return (
<div
className={cn(
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
selected && "border-primary shadow-e2",
)}
>
<PortHandles
specs={definition.requires ?? []}
type="target"
position={Position.Left}
/>
<div className="flex items-center gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{definition.title || definition.id}
</span>
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
{typeLabel}
</span>
</span>
{style ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className={cn("size-2 shrink-0 rounded-full", style.dot)}
aria-label={style.label}
/>
</TooltipTrigger>
<TooltipContent>{live?.error ?? style.label}</TooltipContent>
</Tooltip>
) : null}
</div>
{issues > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground">
<AlertCircle className="size-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{issueText}</TooltipContent>
</Tooltip>
) : null}
<PortHandles
specs={definition.provides ?? []}
type="source"
position={Position.Right}
/>
</div>
)
}
export const FlowNode = memo(FlowNodeComponent)
+194
View File
@@ -0,0 +1,194 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { Check, Loader2, 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,
}: {
flows: FlowSummary[]
active: string
saving: boolean
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const connected = useLiveConnection()
return (
<>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-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" />
<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(
"shrink-0 snap-start 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}
</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>
<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"
: "All changes saved"}
</TooltipContent>
</Tooltip>
</motion.div>
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
+84
View File
@@ -0,0 +1,84 @@
import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
useStore,
} from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import type { FlowEdgeData } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
/** Below this zoom the value chips would be unreadable, so they step aside. */
const CHIP_MIN_ZOOM = 0.5
function formatValue(value: unknown): string {
if (typeof value === "number") {
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
if (typeof value === "string") return value
return JSON.stringify(value) ?? ""
}
function LiveEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps) {
const { message } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef<number | null>(null)
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
// Restart the stroke animation whenever a newer message lands.
useEffect(() => {
if (!live?.ts || live.ts === lastTs.current) return
lastTs.current = live.ts
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), 300)
return () => clearTimeout(timer)
}, [live?.ts])
return (
<>
<BaseEdge
id={id}
path={path}
className={cn(pulsing && "edge-live")}
style={selected ? { stroke: "var(--primary)" } : undefined}
/>
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
<EdgeLabelRenderer>
<div
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
className="pointer-events-none absolute max-w-[140px] truncate rounded-full border border-border bg-card/80 px-2 py-0.5 font-mono text-xs text-muted-foreground backdrop-blur-md"
>
{formatValue(live.value)}
</div>
</EdgeLabelRenderer>
) : null}
</>
)
}
export const LiveEdge = memo(LiveEdgeComponent)
@@ -0,0 +1,54 @@
import Editor from "@monaco-editor/react"
import { useEffect, useMemo, useState } from "react"
import { useTheme } from "@/components/theme-provider"
import { Skeleton } from "@/components/ui/skeleton"
import { monacoFontFamily, setupMonaco } from "./monacoSetup"
/**
* The embedded Python editor. Highlighting and bracket handling only: what the
* code actually does is checked by the engine, which reports load and run
* errors back onto the node.
*/
export default function NodeEditor({
value,
onChange,
}: {
value: string
onChange: (next: string) => void
}) {
const { resolvedTheme } = useTheme()
const [ready, setReady] = useState(false)
useEffect(() => {
setupMonaco()
setReady(true)
}, [])
const fontFamily = useMemo(() => (ready ? monacoFontFamily() : ""), [ready])
if (!ready) return <Skeleton className="h-full w-full rounded-md" />
return (
<Editor
language="python"
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
value={value}
onChange={(next) => onChange(next ?? "")}
loading={<Skeleton className="h-full w-full rounded-md" />}
options={{
fontFamily,
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
lineNumbersMinChars: 3,
padding: { top: 12, bottom: 12 },
renderLineHighlight: "line",
overviewRulerLanes: 0,
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
}}
/>
)
}
+409
View File
@@ -0,0 +1,409 @@
import { useQuery } from "@tanstack/react-query"
import { X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { lazy, Suspense, useEffect, useRef, useState } from "react"
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { useIsMobile } from "@/hooks/useMobile"
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
import { nodeSourceQueryOptions } from "./queries"
const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
const SECTION =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
const panelSlide = {
hidden: { opacity: 0, x: 16 },
visible: {
opacity: 1,
x: 0,
transition: { duration: duration.base, ease: easeEmphasized },
},
exit: {
opacity: 0,
x: 16,
transition: { duration: duration.fast, ease: easeStandard },
},
}
function PortList({
title,
specs,
flow,
emptyHint,
onChange,
}: {
title: string
specs: MessageSpec[]
flow: string
emptyHint: string
onChange: (next: MessageSpec[]) => void
}) {
const update = (index: number, patch: Partial<MessageSpec>) => {
const next = specs.map((spec, i) =>
i === index ? { ...spec, ...patch } : spec,
)
onChange(next)
}
return (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<span className={SECTION}>{title}</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
>
Add
</Button>
</div>
{specs.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyHint}</p>
) : null}
{specs.map((spec, index) => (
<div key={`port-${index}`} className="flex items-center gap-1.5">
<Input
value={spec.name ?? ""}
placeholder={`name in ${flow}`}
aria-label="Message name"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) =>
update(index, { name: event.target.value, port: "" })
}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) => update(index, { dtype: value as DType })}
>
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => onChange(specs.filter((_, i) => i !== index))}
>
<X />
</Button>
</div>
))}
</div>
)
}
/** A small form built from the node type's declared parameters. */
function ParamsForm({
schema,
params,
onChange,
}: {
schema: Record<string, unknown> | undefined
params: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
}) {
const properties = (schema?.properties ?? {}) as Record<
string,
{ type?: string; title?: string; default?: unknown }
>
const entries = Object.entries(properties)
if (entries.length === 0) return null
const set = (key: string, value: unknown) =>
onChange({ ...params, [key]: value })
return (
<div className="grid gap-3">
<span className={SECTION}>Settings</span>
{entries.map(([key, property]) => {
const value = params[key] ?? property.default ?? ""
const label = property.title ?? key
if (property.type === "boolean") {
return (
<div key={key} className="flex items-center justify-between gap-2">
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
{label}
</Label>
<Switch
id={`param-${key}`}
checked={Boolean(value)}
onCheckedChange={(checked) => set(key, checked)}
/>
</div>
)
}
if (property.type === "object" || property.type === "array") {
return null
}
const numeric =
property.type === "integer" || property.type === "number"
return (
<div key={key} className="grid gap-1.5">
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
{label}
</Label>
<Input
id={`param-${key}`}
className="h-8 text-sm"
type={numeric ? "number" : "text"}
value={String(value)}
onChange={(event) =>
set(
key,
numeric ? Number(event.target.value) : event.target.value,
)
}
/>
</div>
)
})}
</div>
)
}
function PanelBody({
node,
flow,
nodeType,
onChange,
onSaveSource,
onClose,
onDelete,
}: {
node: NodeDef_Input
flow: string
nodeType: NodeTypeInfo | undefined
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
onDelete: () => void
}) {
const hasSource = nodeType?.has_source ?? node.type === "python"
const { data: source } = useQuery({
...nodeSourceQueryOptions(flow, node.id),
enabled: hasSource,
})
const [code, setCode] = useState<string | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<string | null>(null)
const save = useRef(onSaveSource)
save.current = onSaveSource
const editCode = (next: string) => {
setCode(next)
pending.current = next
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = null
save.current(next)
}, 1000)
}
// Closing the panel must not lose the last keystrokes.
useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current)
if (pending.current !== null) save.current(pending.current)
}
}
}, [])
return (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
<Input
value={node.title || node.id}
aria-label="Node name"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) => onChange({ ...node, title: event.target.value })}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={onClose}
aria-label="Close"
>
<X />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="grid gap-5 p-4">
<PortList
title="Consumes"
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
onChange={(requires) => onChange({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
onChange={(provides) => onChange({ ...node, provides })}
/>
<ParamsForm
schema={nodeType?.params_schema}
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
/>
</div>
{hasSource ? (
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<span className={SECTION}>Code</span>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
<div className="h-full w-full animate-pulse bg-muted" />
}
>
<NodeEditor
value={code ?? source?.code ?? ""}
onChange={editCode}
/>
</Suspense>
</div>
</div>
) : null}
</div>
<div className="shrink-0 border-t border-border px-4 py-3">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
>
Delete node
</Button>
</div>
</>
)
}
/**
* Node settings, floating over the canvas so the graph stays visible and live.
* On a phone there is no room for that, so it becomes a full-screen sheet.
*/
export function NodePanel({
node,
flow,
nodeTypes,
onChange,
onSaveSource,
onClose,
onDelete,
}: {
node: NodeDef_Input | null
flow: string
nodeTypes: NodeTypeInfo[]
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
onDelete: () => void
}) {
const isMobile = useIsMobile()
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
useEffect(() => {
if (!node) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [node, onClose])
if (isMobile) {
return (
<Sheet open={Boolean(node)} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
// The panel header carries its own close button, and opening should
// not drop the caret into the node's name.
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetTitle className="sr-only">Node settings</SheetTitle>
{node ? (
<PanelBody
key={node.id}
node={node}
flow={flow}
nodeType={nodeType}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
onDelete={onDelete}
/>
) : null}
</SheetContent>
</Sheet>
)
}
return (
<AnimatePresence>
{node ? (
<motion.aside
key={node.id}
variants={panelSlide}
initial="hidden"
animate="visible"
exit="exit"
role="complementary"
aria-label="Node settings"
data-testid="node-panel"
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
>
<PanelBody
node={node}
flow={flow}
nodeType={nodeType}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
onDelete={onDelete}
/>
</motion.aside>
) : null}
</AnimatePresence>
)
}
@@ -0,0 +1,91 @@
import type { Edge } from "@xyflow/react"
import type { MessageSpec, NodeDef_Input } from "@/client"
/** Resolve a message name against its flow: bare names belong to this flow. */
export function qualify(flow: string, name: string): string {
if (!name) return ""
return name.includes(".") ? name : `${flow}.${name}`
}
/** The flow a qualified message belongs to. */
export function flowOf(message: string): string {
return message.split(".", 1)[0]
}
/** How a message reads inside its own flow. */
export function displayName(flow: string, message: string): string {
return message.startsWith(`${flow}.`)
? message.slice(flow.length + 1)
: message
}
export function portOf(spec: MessageSpec): string {
return spec.port || spec.name?.split(".").pop() || ""
}
export type FlowEdgeData = {
message: string
flow: string
[key: string]: unknown
}
/**
* Turn name bindings into canvas edges.
*
* Nodes never point at each other: a node declares the messages it consumes,
* and every node providing that message is upstream of it. Two producers of one
* message therefore draw two edges converging on the same input.
*/
export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
const producers = new Map<string, { node: string; port: string }[]>()
for (const node of nodes) {
for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
const list = producers.get(message) ?? []
list.push({ node: node.id, port: portOf(spec) })
producers.set(message, list)
}
}
const edges: Edge[] = []
for (const node of nodes) {
for (const spec of node.requires ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
const targetPort = portOf(spec)
for (const producer of producers.get(message) ?? []) {
if (producer.node === node.id) continue
edges.push({
id: `${producer.node}:${producer.port}->${node.id}:${targetPort}`,
source: producer.node,
sourceHandle: producer.port,
target: node.id,
targetHandle: targetPort,
type: "live",
data: { message, flow } satisfies FlowEdgeData,
})
}
}
}
return edges
}
/**
* A cheap fingerprint of everything edges depend on, so dragging a node (which
* replaces the array every frame) does not re-derive them.
*/
export function bindingsKey(nodes: NodeDef_Input[]): string {
return nodes
.map(
(node) =>
`${node.id}|${(node.requires ?? [])
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
.join(",")}|${(node.provides ?? [])
.map((s) => `${portOf(s)}=${s.name ?? ""}`)
.join(",")}`,
)
.join(";")
}
+71
View File
@@ -0,0 +1,71 @@
/*
* React Flow, routed through the design tokens.
*
* Scoped to `.react-flow` rather than added to index.css, so the canvas needs no
* tokens of its own and follows the theme in both modes without a `colorMode`
* prop. See the root DESIGN-GUIDELINES.md → Shells.
*/
.react-flow {
--xy-background-color: var(--background);
--xy-background-pattern-dots-color: color-mix(
in srgb,
var(--muted-foreground) 30%,
transparent
);
--xy-edge-stroke: color-mix(
in srgb,
var(--muted-foreground) 55%,
transparent
);
--xy-edge-stroke-selected: var(--primary);
--xy-edge-stroke-width: 1.5;
--xy-handle-background-color: var(--card);
--xy-handle-border-color: var(--muted-foreground);
--xy-selection-background-color: color-mix(
in srgb,
var(--primary) 8%,
transparent
);
--xy-selection-border: 1px solid var(--primary);
--xy-attribution-background-color: transparent;
}
/* A message arriving lights its edge, then decays back to rest. */
@media (prefers-reduced-motion: no-preference) {
.react-flow__edge-path.edge-live {
animation: edge-pulse var(--duration-slow) var(--ease-emphasized);
}
@keyframes edge-pulse {
from {
stroke: var(--primary);
stroke-width: 2.5;
}
to {
stroke: var(--xy-edge-stroke);
stroke-width: var(--xy-edge-stroke-width);
}
}
}
/* Handles are neutral: the one brand-secondary affordance here is Run. */
.react-flow__handle {
width: 12px;
height: 12px;
border-width: 2px;
}
.react-flow__handle.unbound {
border-style: dashed;
border-color: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
}
.react-flow__handle-connecting {
border-color: var(--primary);
}
.react-flow__node:focus-visible,
.react-flow__node:focus {
outline: none;
}
+113
View File
@@ -0,0 +1,113 @@
import { useSyncExternalStore } from "react"
/**
* Live engine state, deliberately outside React Query.
*
* Values can arrive many times a second. Keeping them here, with one subscriber
* set per key, means a changing value re-renders the chip showing it and
* nothing else.
*/
export type LiveValue = { value: unknown; ts: number | null }
export type LiveStatus = {
status: "active" | "error" | "running" | "success"
error?: string | null
}
type Listener = () => void
const values = new Map<string, LiveValue>()
const statuses = new Map<string, LiveStatus>()
const listeners = new Map<string, Set<Listener>>()
let connected = false
const connectionListeners = new Set<Listener>()
function notify(key: string) {
for (const listener of listeners.get(key) ?? []) listener()
}
function subscribeKey(key: string, listener: Listener) {
let set = listeners.get(key)
if (!set) {
set = new Set()
listeners.set(key, set)
}
set.add(listener)
return () => {
set.delete(listener)
if (set.size === 0) listeners.delete(key)
}
}
export const liveStore = {
setValue(name: string, value: LiveValue) {
values.set(name, value)
notify(`value:${name}`)
},
setValues(entries: Record<string, LiveValue>) {
for (const [name, value] of Object.entries(entries)) {
values.set(name, value)
notify(`value:${name}`)
}
},
getValue(name: string) {
return values.get(name)
},
setStatus(nodeId: string, status: LiveStatus) {
statuses.set(nodeId, status)
notify(`status:${nodeId}`)
},
setStatuses(
entries: { id: string; status: string; error?: string | null }[],
) {
for (const entry of entries) {
statuses.set(entry.id, {
status: entry.status as LiveStatus["status"],
error: entry.error,
})
notify(`status:${entry.id}`)
}
},
getStatus(nodeId: string) {
return statuses.get(nodeId)
},
setConnected(next: boolean) {
if (connected === next) return
connected = next
for (const listener of connectionListeners) listener()
},
isConnected() {
return connected
},
reset() {
for (const key of values.keys()) notify(`value:${key}`)
values.clear()
for (const key of statuses.keys()) notify(`status:${key}`)
statuses.clear()
},
}
export function useLiveValue(name: string | undefined): LiveValue | undefined {
return useSyncExternalStore(
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
() => (name ? values.get(name) : undefined),
)
}
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
return useSyncExternalStore(
(listener) => subscribeKey(`status:${nodeId}`, listener),
() => statuses.get(nodeId),
)
}
export function useLiveConnection(): boolean {
return useSyncExternalStore(
(listener) => {
connectionListeners.add(listener)
return () => connectionListeners.delete(listener)
},
() => connected,
)
}
@@ -0,0 +1,70 @@
/**
* Monaco, self-hosted and themed from the design tokens.
*
* Only the base editor and the Python grammar are pulled in; the other seventy
* languages would triple the chunk for nothing. This module is imported by
* NodeEditor alone, which the panel loads lazily.
*/
import { loader } from "@monaco-editor/react"
import * as monaco from "monaco-editor/editor/editor.api"
// Only Python registers itself; `basic-languages` would pull in all seventy.
import "monaco-editor/languages/definitions/python/register"
import editorWorker from "monaco-editor/editor/editor.worker?worker"
let configured = false
function defineThemes() {
const themes = [
{ id: "fluksio-light", base: "vs" as const },
{ id: "fluksio-dark", base: "vs-dark" as const },
]
for (const theme of themes) {
const dark = theme.id.endsWith("dark")
const background = dark ? "#1a1a1a" : "#ffffff"
const foreground = dark ? "#f5f5f5" : "#333232"
const muted = dark ? "#a3a3a3" : "#6b6b6b"
const accent = dark ? "#262626" : "#f5f5f5"
const primary = dark ? "#7ba3b8" : "#4a7189"
const success = dark ? "#87b596" : "#5e8b6d"
monaco.editor.defineTheme(theme.id, {
base: theme.base,
inherit: true,
rules: [
{ token: "keyword", foreground: primary.slice(1) },
{ token: "string", foreground: success.slice(1) },
{ token: "comment", foreground: muted.slice(1), fontStyle: "italic" },
{ token: "number", foreground: foreground.slice(1) },
],
colors: {
"editor.background": background,
"editor.foreground": foreground,
"editorLineNumber.foreground": muted,
"editorLineNumber.activeForeground": foreground,
"editor.lineHighlightBackground": accent,
"editorCursor.foreground": primary,
"editor.selectionBackground": `${primary}33`,
"editorIndentGuide.background1": accent,
"editorWidget.background": background,
"editorGutter.background": background,
},
})
}
}
export function setupMonaco(): typeof monaco {
if (configured) return monaco
configured = true
// Self-hosted: no CDN fetch, so the editor works on a LAN install.
self.MonacoEnvironment = { getWorker: () => new editorWorker() }
loader.config({ monaco })
defineThemes()
return monaco
}
export const monacoFontFamily = (): string =>
getComputedStyle(document.documentElement)
.getPropertyValue("--font-mono")
.trim() || "ui-monospace, monospace"
+101
View File
@@ -0,0 +1,101 @@
import {
type UseMutationResult,
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import { useCallback, useEffect, useRef } from "react"
import { type FlowDef_Input, FlowsService } from "@/client"
export const flowKeys = {
all: ["flows"] as const,
detail: (name: string) => ["flows", name] as const,
source: (name: string, nodeId: string) =>
["flows", name, "source", nodeId] as const,
nodeTypes: ["flows", "node-types"] as const,
}
export const flowsQueryOptions = () => ({
queryKey: flowKeys.all,
queryFn: () => FlowsService.readFlows(),
})
export const flowQueryOptions = (name: string) => ({
queryKey: flowKeys.detail(name),
queryFn: () => FlowsService.readFlow({ name }),
})
export const nodeTypesQueryOptions = () => ({
queryKey: flowKeys.nodeTypes,
queryFn: () => FlowsService.readNodeTypes(),
staleTime: Number.POSITIVE_INFINITY,
})
export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({
queryKey: flowKeys.source(name, nodeId),
queryFn: () => FlowsService.readNodeSource({ name, nodeId }),
})
const AUTOSAVE_DELAY = 800
/**
* Saves the flow a moment after the last edit, and immediately when the editor
* needs the server to be current (closing a panel, switching flow, running).
*
* Identical documents are skipped server-side, so a quiet canvas writes nothing.
*/
export function useAutosave(name: string): {
save: (definition: FlowDef_Input) => void
flush: () => void
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
} {
const queryClient = useQueryClient()
const pending = useRef<FlowDef_Input | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mutation = useMutation({
mutationFn: (definition: FlowDef_Input) =>
FlowsService.saveFlow({ name, requestBody: definition }),
onSuccess: (detail) => {
// Write the server's answer straight into the cache: invalidating would
// pull the document back out from under edits still in flight.
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
})
const flush = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
const definition = pending.current
pending.current = null
if (definition) {
mutation.mutate(definition)
}
}, [mutation])
const save = useCallback(
(definition: FlowDef_Input) => {
pending.current = definition
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, AUTOSAVE_DELAY)
},
[flush],
)
// Leaving the tab is the last chance to persist what is still queued.
useEffect(() => {
const onHidden = () => {
if (document.visibilityState === "hidden") flush()
}
document.addEventListener("visibilitychange", onHidden)
return () => {
document.removeEventListener("visibilitychange", onHidden)
if (timer.current) clearTimeout(timer.current)
}
}, [flush])
return { save, flush, mutation }
}
@@ -0,0 +1,114 @@
import { useEffect, useRef } from "react"
import { OpenAPI } from "@/client"
import { liveStore } from "./liveStore"
const RECONNECT_MIN = 1000
const RECONNECT_MAX = 30000
type FlowEvent =
| {
type: "snapshot"
values: Record<string, { value: unknown; ts: number | null }>
nodes: { id: string; status: string; error?: string | null }[]
}
| { type: "message_value"; name: string; value: unknown; ts: number }
| { type: "node_executed"; node: string }
| { type: "node_error"; node: string; error: string }
| { type: "node_status"; node: string; status: string; error?: string | null }
| {
type: "pipeline_rebuilt"
nodes: { id: string; status: string; error?: string | null }[]
}
function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin)
const url = new URL("/api/v1/flows/ws", base)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
// Browsers cannot set headers on a websocket handshake, so the token rides
// in the query string.
url.searchParams.set("token", localStorage.getItem("access_token") ?? "")
return url.toString()
}
/**
* Keeps one socket open for the editor, feeding the live store.
*
* @param onAuthFailure called when the server rejects the token, so the caller
* can send the user back to the login screen.
*/
export function useFlowSocket(onAuthFailure?: () => void): void {
const socket = useRef<WebSocket | null>(null)
const retry = useRef(RECONNECT_MIN)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const closed = useRef(false)
useEffect(() => {
closed.current = false
const connect = () => {
if (closed.current) return
const ws = new WebSocket(socketUrl())
socket.current = ws
ws.onopen = () => {
retry.current = RECONNECT_MIN
liveStore.setConnected(true)
}
ws.onmessage = (event) => {
const message: FlowEvent = JSON.parse(event.data)
switch (message.type) {
case "snapshot":
liveStore.setValues(message.values)
liveStore.setStatuses(message.nodes)
break
case "message_value":
liveStore.setValue(message.name, {
value: message.value,
ts: message.ts,
})
break
case "node_executed":
liveStore.setStatus(message.node, { status: "success" })
break
case "node_error":
liveStore.setStatus(message.node, {
status: "error",
error: message.error,
})
break
case "node_status":
liveStore.setStatus(message.node, {
status: message.status as "active" | "error",
error: message.error,
})
break
case "pipeline_rebuilt":
liveStore.setStatuses(message.nodes)
break
}
}
ws.onclose = (event) => {
liveStore.setConnected(false)
if (closed.current) return
if (event.code === 1008) {
onAuthFailure?.()
return
}
timer.current = setTimeout(connect, retry.current)
retry.current = Math.min(retry.current * 2, RECONNECT_MAX)
}
}
connect()
return () => {
closed.current = true
if (timer.current) clearTimeout(timer.current)
socket.current?.close()
liveStore.setConnected(false)
}
}, [onAuthFailure])
}
@@ -1,4 +1,4 @@
import { Briefcase, Home, Users } from "lucide-react"
import { Briefcase, Home, Users, Workflow } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
@@ -14,6 +14,7 @@ import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: Briefcase, title: "Items", path: "/items" },
]
+4 -1
View File
@@ -36,7 +36,10 @@ export function Main({ items }: MainProps) {
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const isActive = currentPath === item.path
const isActive =
item.path === "/"
? currentPath === "/"
: currentPath.startsWith(item.path)
return (
<SidebarMenuItem key={item.title}>
+182
View File
@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-e2 outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }