Files
app/frontend/src/components/Flow/NodePanel.tsx
T
stroblme 3b4450c211 flow: structured dtypes, and the widgets that read them
A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.
2026-08-17 15:06:45 +02:00

1131 lines
34 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Maximize2, Minimize2, X } from "lucide-react"
import {
type ComponentProps,
lazy,
Suspense,
useEffect,
useRef,
useState,
} from "react"
import {
type DType,
FlowsService,
type MessageSpec,
type NodeDef_Input,
type NodeTypeInfo,
} from "@/client"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { MessageSparkline } from "./MessageSparkline"
import {
flowKeys,
libraryQueryOptions,
nodeSourceQueryOptions,
secretsQueryOptions,
useParamSuggestions,
} from "./queries"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = [
"float",
"int",
"str",
"bool",
"json",
"series",
"record",
"list",
]
/** What a list may hold. One declared level: no list of lists. */
const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"]
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
const NO_SECRET = "__none__"
const SECTION = PANEL_SECTION
/** Settings the engine reads itself, so they are not the author's to name. */
const RESERVED_PARAMS = new Set(["synchronous"])
/**
* A text field that offers what is already in use elsewhere.
*
* Typing stays free — the list is a shortcut, never a constraint — but picking
* beats typing wherever two things only work together when they match.
*/
function SuggestInput({
value,
suggestions,
empty,
onChange,
onFocus,
onBlur,
...props
}: {
value: string
suggestions: string[]
/** What to say when nothing matches what is typed. */
empty: string
onChange: (next: string) => void
} & Omit<ComponentProps<typeof Input>, "value" | "onChange">) {
const [open, setOpen] = useState(false)
const matches = suggestions.filter(
(item) =>
item !== value && item.toLowerCase().includes(value.toLowerCase()),
)
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<Input
value={value}
autoComplete="off"
{...props}
onFocus={(event) => {
setOpen(true)
onFocus?.(event)
}}
onBlur={(event) => {
setOpen(false)
onBlur?.(event)
}}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false)
}}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
<CommandEmpty>{empty}</CommandEmpty>
<CommandGroup>
{matches.map((item) => (
<CommandItem
key={item}
value={item}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(item)
setOpen(false)
}}
onSelect={() => {
onChange(item)
setOpen(false)
}}
>
{item}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
/**
* A message name, typed freely or picked from the names already in play.
*
* The suggestions are the point: a message only connects when both ends spell
* it the same way, so choosing beats typing.
*/
function MessageNameInput({
value,
suggestions,
placeholder,
autoFocus,
onChange,
onRenamed,
}: {
value: string
suggestions: string[]
placeholder: string
autoFocus: boolean
onChange: (next: string) => void
/** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void
}) {
// Every keystroke commits, so a rename is only a rename once the user is
// finished with the field.
const before = useRef(value)
return (
<SuggestInput
value={value}
suggestions={suggestions}
empty="No matching message."
placeholder={placeholder}
aria-label="Message name"
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onChange={onChange}
onFocus={() => {
before.current = value
}}
onBlur={() => {
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
/>
)
}
function PortList({
title,
specs,
flow,
emptyHint,
suggestions,
onChange,
onRenamed,
}: {
title: string
specs: MessageSpec[]
flow: string
emptyHint: string
suggestions: string[]
onChange: (next: MessageSpec[]) => void
onRenamed?: (previous: string, next: string) => void
}) {
// The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null)
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-3">
<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={() => {
setFreshIndex(specs.length)
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="grid gap-1">
<div className="flex items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
onRenamed={onRenamed}
/>
<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>
{spec.dtype === "list" ? (
<Select
value={spec.item ?? "record"}
onValueChange={(value) =>
update(index, { item: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
type="number"
min={0}
step="any"
value={spec.interval ? String(spec.interval) : ""}
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-16 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => onChange(specs.filter((_, i) => i !== index))}
>
<X />
</Button>
</div>
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
</div>
))}
</div>
)
}
/** The value types a free-form setting can hold, and how to read one back. */
const FREE_TYPES = ["text", "number", "on/off", "json"] as const
type FreeType = (typeof FREE_TYPES)[number]
function freeTypeOf(value: unknown): FreeType {
if (typeof value === "boolean") return "on/off"
if (typeof value === "number") return "number"
if (value !== null && typeof value === "object") return "json"
return "text"
}
function castTo(type: FreeType, raw: string): unknown {
if (type === "number") return Number(raw) || 0
if (type === "on/off") return raw === "true"
if (type === "json") {
try {
return JSON.parse(raw)
} catch {
// Half-typed JSON is normal while editing; keep the text until it parses.
return raw
}
}
return raw
}
function asText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/**
* Settings a node type does not declare.
*
* A function node's parameters are its author's to name — they arrive in
* `process` as whatever was put here — so there is no schema to render and the
* keys are typed in alongside the values.
*/
function FreeParamsForm({
params,
reserved,
onChange,
}: {
params: Record<string, unknown>
reserved: Set<string>
onChange: (next: Record<string, unknown>) => void
}) {
const [freshKey, setFreshKey] = useState<string | null>(null)
const entries = Object.entries(params).filter(([key]) => !reserved.has(key))
const rename = (from: string, to: string) => {
if (to === from) return
// Rebuilt rather than patched, so the settings keep the order they were
// typed in instead of jumping around as one is renamed.
const next: Record<string, unknown> = {}
for (const [key, value] of Object.entries(params)) {
next[key === from ? to : key] = value
}
onChange(next)
}
return (
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>Settings</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
data-testid="add-param"
onClick={() => {
let name = "setting"
for (let i = 2; name in params; i++) name = `setting${i}`
setFreshKey(name)
onChange({ ...params, [name]: "" })
}}
>
Add
</Button>
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Values your code reads from <code>params</code>.
</p>
) : null}
{entries.map(([key, value], index) => {
const type = freeTypeOf(value)
return (
// Keyed by position, not by name: renaming a setting must not
// remount its row and take the half-typed value with it.
<div key={`param-${index}`} className="flex items-center gap-1.5">
<Input
defaultValue={key}
placeholder="name"
aria-label="Setting name"
autoFocus={key === freshKey}
className="h-8 flex-1 text-sm"
onBlur={(event) => rename(key, event.target.value.trim() || key)}
/>
<Select
value={type}
onValueChange={(next) =>
onChange({
...params,
[key]: castTo(next as FreeType, asText(value)),
})
}
>
<SelectTrigger
className="!h-8 w-[86px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{type === "on/off" ? (
<Switch
checked={value === true}
aria-label="Value"
onCheckedChange={(checked) =>
onChange({ ...params, [key]: checked })
}
/>
) : (
<Input
value={asText(value)}
placeholder="value"
aria-label="Setting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...params,
[key]: castTo(type, event.target.value),
})
}
/>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove setting"
onClick={() => {
const next = { ...params }
delete next[key]
onChange(next)
}}
>
<X />
</Button>
</div>
)
})}
</div>
)
}
/**
* The cron expression that fires as often as `seconds` asks for, if there is
* one. Only divisors of an hour or a day line up — a step of seven minutes
* would jump from :56 back to :00, which is not every seven minutes.
*/
function cronFromInterval(seconds: unknown): string | null {
const value = Number(seconds)
if (!Number.isFinite(value) || value <= 0) return null
const minutes = value / 60
// Cron's finest grain is the minute.
if (!Number.isInteger(minutes)) return null
if (minutes === 1) return "* * * * *"
if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null
const hours = minutes / 60
if (!Number.isInteger(hours)) return null
if (hours === 1) return "0 * * * *"
if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null
return hours === 24 ? "0 0 * * *" : null
}
/** What the five fields mean, and the schedule the interval beside them asks for. */
function CronHelp({
params,
onPick,
}: {
params: Record<string, unknown>
onPick: (expression: string) => void
}) {
const derived = cronFromInterval(params.interval)
return (
<>
<p className="text-xs text-muted-foreground">
Five fields —{" "}
<span className="font-mono">
minute hour day-of-month month weekday
</span>
. <span className="font-mono">*</span> is every,{" "}
<span className="font-mono">*/5</span> every fifth,{" "}
<span className="font-mono">1-5</span> a range.
</p>
{derived && params.cron !== derived ? (
<Button
variant="ghost"
size="sm"
className="h-7 justify-self-start text-xs text-muted-foreground"
data-testid="use-derived-cron"
onClick={() => onPick(derived)}
>
Every {String(params.interval)}s is
<span className="font-mono">{derived}</span>
</Button>
) : null}
</>
)
}
/** A small form built from the node type's declared parameters. */
function ParamsForm({
type,
schema,
params,
onChange,
}: {
type: string | undefined
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
description?: string
default?: unknown
"x-secret"?: boolean
}
>
const entries = Object.entries(properties)
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
// A type with nothing to fill in has nothing to suggest, and the other flows
// are not worth reading for it.
const suggestions = useParamSuggestions(entries.length ? type : undefined)
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>
)
}
// A credential is stored once and referenced, so it never ends up in
// flow.json where the whole team can read it.
if (property["x-secret"]) {
const reference = (params[key] ?? null) as { $secret?: string } | null
return (
<div key={key} className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Select
value={reference?.$secret ?? ""}
onValueChange={(name) =>
set(key, name === NO_SECRET ? null : { $secret: name })
}
>
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
<SelectValue placeholder="Pick a stored secret" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SECRET}>None</SelectItem>
{secrets.map((secret) => (
<SelectItem key={secret} value={secret}>
{secret}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{secrets.length
? property.description ||
"Stored in the secrets store, never in the flow file."
: "No secrets stored yet."}
</p>
</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>
<SuggestInput
id={`param-${key}`}
className="h-8 text-sm"
type={numeric ? "number" : "text"}
value={String(value)}
suggestions={suggestions[key] ?? []}
empty="Nothing like that in use yet."
onChange={(next) => set(key, numeric ? Number(next) : next)}
/>
{property.description ? (
<p className="text-xs text-muted-foreground">
{property.description}
</p>
) : null}
{key === "cron" ? (
<CronHelp
params={params}
onPick={(expression) => set(key, expression)}
/>
) : null}
</div>
)
})}
</div>
)
}
/**
* Sharing a node moves its code to the library, where other flows can point at
* it. Each flow keeps its own ports and settings; only the code is common, so
* one fix reaches all of them.
*/
function SharingSection({
flow,
node,
onShared,
}: {
flow: string
node: NodeDef_Input
onShared: () => void
}) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: library } = useQuery(libraryQueryOptions())
const [name, setName] = useState("")
const shared = node.source_ref
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
const done = () => {
queryClient.invalidateQueries({ queryKey: flowKeys.library })
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
onShared()
}
const share = useMutation({
mutationFn: (libName: string) =>
FlowsService.shareNode({
name: flow,
nodeId: node.id,
requestBody: { lib_name: libName },
}),
onSuccess: done,
onError: () =>
showErrorToast("That name is taken, or is not a valid name."),
})
const unshare = useMutation({
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
onSuccess: done,
onError: () => showErrorToast("The node could not be unshared."),
})
if (shared) {
return (
<div className="grid gap-3">
<span className={SECTION}>Shared</span>
<p className="text-sm text-muted-foreground">
Runs <span className="font-mono">{shared}</span> from the library
{usages.length > 1
? `, along with ${usages.length - 1} other node${
usages.length === 2 ? "" : "s"
}`
: ""}
. Editing the code here changes it everywhere.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={unshare.isPending}
onClick={() => unshare.mutate()}
data-testid="unshare-node"
>
Keep a private copy
</Button>
</div>
)
}
return (
<div className="grid gap-3">
<span className={SECTION}>Reuse</span>
<p className="text-sm text-muted-foreground">
Move this node's code to the library so other flows can run it too.
</p>
<div className="flex items-center gap-1.5">
<Input
value={name}
placeholder="read_temperature"
aria-label="Shared name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
/>
<Button
size="sm"
className="h-8"
disabled={!name || share.isPending}
onClick={() => share.mutate(name)}
data-testid="share-node"
>
Share
</Button>
</div>
</div>
)
}
/** The value a fresh output returns, so the scaffold runs as written. */
const PLACEHOLDER: Record<DType, string> = {
float: "0.0",
int: "0",
bool: "False",
str: '""',
json: "{}",
series: '{"lines": []}',
record: "{}",
list: "[]",
}
const SCAFFOLD_DOC =
'"""A new node. Return a dict keyed by your output ports."""'
const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
/**
* Exactly what `scaffoldFor` writes, for any set of ports — and the source a
* node starts life with, which is that with no ports at all.
*
* Nothing else matches, which is what makes it safe to overwrite: a returned
* value that is not one of the placeholders is somebody's code.
*/
const SCAFFOLD_SHAPE = (() => {
const value = Object.values(PLACEHOLDER).map(quote).join("|")
const entry = `"[^"]+": (?:${value})`
return new RegExp(
`^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`,
)
})()
/** The name the node function sees; the engine derives it the same way. */
const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
/** A `process` that takes this node's inputs and returns its outputs. */
function scaffoldFor(node: NodeDef_Input): string {
const args = [
...new Set(
(node.requires ?? [])
.map(portName)
// Anything else cannot be a keyword argument, so it cannot be a port.
.filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"),
),
]
const returns = (node.provides ?? [])
.filter((spec) => portName(spec))
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join(
", ",
)}):\n return {${returns.join(", ")}}\n`
}
function PanelBody({
node,
flow,
nodeType,
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
}: {
node: NodeDef_Input
flow: string
nodeType: NodeTypeInfo | undefined
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => 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
/** Send what is typed now rather than a second from now. */
const saveNow = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current !== null) save.current(pending.current)
}
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)
}
/**
* Change the node, and keep an untouched scaffold in step with its ports.
*
* Only code that is still exactly what this panel generates is rewritten —
* one edit of your own and it is yours, ports or no ports. Shared code is
* never rewritten either: the other flows using it have their own ports.
*/
const editNode = (next: NodeDef_Input) => {
onChange(next)
const current = code ?? source?.code
if (!hasSource || next.source_ref || current === undefined) return
const wanted = scaffoldFor(next)
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
}
// ⌘S in the editor means "apply this code"; the editor's own state is here,
// so the binding is too. Anywhere else on the canvas it publishes the flow.
useShortcuts(
{
"mod+s": (event) => {
if (inCodeEditor(event.target)) saveNow()
},
},
["mod+s"],
)
// 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={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
<PortList
title="Consumes"
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => editNode({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => editNode({ ...node, provides })}
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={onRenameMessage}
/>
<ParamsForm
type={node.type}
schema={nodeType?.params_schema}
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
/>
{nodeType?.free_params ? (
<FreeParamsForm
params={node.params ?? {}}
reserved={RESERVED_PARAMS}
onChange={(params) => onChange({ ...node, params })}
/>
) : null}
{hasSource ? (
<div className="grid gap-1.5">
<Label htmlFor="node-timeout" className={SECTION}>
Timeout
</Label>
<Input
id="node-timeout"
type="number"
min={0}
step="any"
className="h-8 text-sm"
placeholder="30 (default)"
value={node.timeout ? String(node.timeout) : ""}
onChange={(event) =>
onChange({
...node,
timeout: Number(event.target.value) || null,
})
}
/>
<p className="text-xs text-muted-foreground">
Seconds this code may run before it is stopped. Above 60 the
engine may deliver the same work again while it is still running.
</p>
</div>
) : null}
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}
</div>
{hasSource ? (
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<div className="flex items-center justify-between">
<span className={SECTION}>
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
</span>
<Button
variant="ghost"
size="icon-sm"
className="hidden text-muted-foreground md:inline-flex"
onClick={onToggleExpand}
aria-label={
expanded ? "Collapse the editor" : "Expand the editor"
}
data-testid="toggle-editor-size"
>
{expanded ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
<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}
</>
)
}
/**
* 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.
*/
/** Message names worth offering on each side of a node. */
export type PortSuggestions = { consumes: string[]; provides: string[] }
export function NodePanel({
node,
flow,
nodeTypes,
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
onClose,
onDelete,
}: {
node: NodeDef_Input | null
flow: string
nodeTypes: NodeTypeInfo[]
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => void
onClose: () => void
onDelete: () => void
}) {
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
return (
<SidePanel
open={Boolean(node)}
label="Node settings"
testId="node-panel"
bodyKey={node?.id ?? "none"}
expanded={expanded}
onClose={onClose}
header={
node ? (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{/* Where the node lives: the flow is the namespace of its
messages, so it reads like the folder holding them. */}
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
/{flow}/
</span>
<PanelTitle
value={node.title || node.id}
label="Node name"
onConfirm={(title) => onChange({ ...node, title })}
/>
</div>
) : null
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
>
Delete node
</Button>
}
>
{node ? (
<PanelBody
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
expanded={expanded}
onChange={onChange}
onRenameMessage={onRenameMessage}
onSaveSource={onSaveSource}
onShared={onShared}
onToggleExpand={onToggleExpand}
/>
) : null}
</SidePanel>
)
}