Flow inputs and outputs are visible and editable in the UI

A flow's inputs are the messages it takes from outside — a dashboard control,
a run, the API — and its outputs are what a batch run reports. Both existed in
the document and in the engine, and neither had any UI: the values looked
hard-coded on the canvas and the Run button always used the declared defaults.

The canvas now draws each as a labelled endpoint, the way it already draws a
dashboard tile or another flow, skipping an input something else already
accounts for. The flow panel edits them — mode, name, type, starting value,
and for a live flow the value it currently holds with a way to put a new one
in. Pressing Run on a batch flow asks for its parameters first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
This commit is contained in:
2026-08-20 18:10:16 +02:00
co-authored by Claude Opus 5
parent 3508713e85
commit 2abeae7f9c
10 changed files with 644 additions and 22 deletions
@@ -0,0 +1,341 @@
import { useMutation } from "@tanstack/react-query"
import { ArrowUpFromLine, Plus, X } from "lucide-react"
import type { DType, FlowDef_Input, FlowInput_Input } from "@/client"
import { MessagesService } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { DTYPES } from "./NodePanel"
import { PANEL_SECTION } from "./SidePanel"
import { ValuePreview } from "./ValuePreview"
/**
* A typed literal, read back from what was typed.
*
* Half-finished input is normal while editing — "-", "1.", a JSON object with
* one brace — so anything that does not parse yet is kept as text rather than
* replaced with a zero the person did not type.
*/
export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
if (raw === "") return null
if (dtype === "int" || dtype === "float") {
const parsed = Number(raw)
return Number.isNaN(parsed) ? raw : parsed
}
if (dtype === "bool") return raw === "true"
if (dtype === "str") return raw
try {
return JSON.parse(raw)
} catch {
return raw
}
}
/** The same value as something a text field can hold. */
export function asText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/** Putting a declared value into the running graph, credited to the input. */
function usePublishInput() {
return useMutation({
mutationFn: ({ message, value }: { message: string; value: unknown }) =>
MessagesService.publishMessage({
name: message,
requestBody: {
value,
source_kind: "flow",
// Matches the endpoint id the canvas builds, so that label pulses.
source_id: `input:${message}`,
source_label: message,
source_detail: "input",
},
}),
})
}
function InputRow({
flow,
declared,
live,
onChange,
onRemove,
}: {
flow: string
declared: FlowInput_Input
/** Whether the engine is running this flow, so a value can be put into it. */
live: boolean
onChange: (next: FlowInput_Input) => void
onRemove: () => void
}) {
const spec = declared.spec ?? {}
const name = spec.name ?? ""
const message = qualify(flow, name)
const current = useLiveValue(message)
const publish = usePublishInput()
return (
<div className="grid gap-1.5">
<div className="flex items-center gap-1.5">
<Input
defaultValue={name}
placeholder="name"
aria-label="Input name"
className="h-8 flex-1 text-sm"
onBlur={(event) =>
onChange({
...declared,
spec: { ...spec, name: event.target.value.trim() },
})
}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(next) =>
onChange({ ...declared, spec: { ...spec, dtype: next as DType } })
}
>
<SelectTrigger className="!h-8 w-[86px] text-sm" aria-label="Type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{spec.dtype === "bool" ? (
<Select
value={declared.initial === true ? "true" : "false"}
onValueChange={(next) =>
onChange({ ...declared, initial: next === "true" })
}
>
<SelectTrigger
className="!h-8 flex-1 text-sm"
aria-label="Starting value"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={asText(declared.initial)}
placeholder="starts at"
aria-label="Starting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...declared,
initial: parseByDtype(spec.dtype, event.target.value),
})
}
/>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove input"
onClick={onRemove}
>
<X />
</Button>
</div>
{live && name ? (
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
<span className="shrink-0">now</span>
{current === undefined ? (
<span className="flex-1"></span>
) : (
<span className="min-w-0 flex-1">
<ValuePreview value={current.value} dtype={spec.dtype} />
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Publish this value now"
disabled={publish.isPending || declared.initial === null}
onClick={() =>
publish.mutate({ message, value: declared.initial })
}
>
<ArrowUpFromLine />
</Button>
</TooltipTrigger>
<TooltipContent>Put this value into the flow now</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
)
}
/**
* What the flow takes from outside, and what a run of it reports.
*
* An input is a message no node here computes — a control writes it, a run
* passes it in, an agent publishes it — so the flow declares it and says what
* it starts from. Without that the node reading it waits for something nothing
* provides, which is what the canvas reports.
*/
export function BoundarySections({
flow,
definition,
running,
onChange,
}: {
flow: string
definition: FlowDef_Input
/** Whether the engine is running this flow, so it holds values worth showing. */
running: boolean
onChange: (next: FlowDef_Input) => void
}) {
const inputs = definition.inputs ?? []
const batch = definition.mode === "batch"
const outputs = definition.outputs ?? []
// A run reads its parameters into a namespace of its own, so what the engine
// holds for a batch flow is not what any run of it saw.
const live = running && !batch
// Everything the flow computes, which is what a result can be made of.
const provided = [
...new Set(
(definition.nodes ?? []).flatMap((node) =>
(node.provides ?? []).map((spec) => spec.name ?? "").filter(Boolean),
),
),
].sort()
const setInputs = (next: FlowInput_Input[]) =>
onChange({ ...definition, inputs: next })
return (
<>
<div className="grid gap-3">
<span className={PANEL_SECTION}>Mode</span>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{batch
? "Runs when a run asks it to, from its inputs to its outputs. Nothing here is subscribed or scheduled."
: "Runs continuously: subscriptions, schedules and webhooks are live."}
</p>
<Select
value={definition.mode ?? "live"}
onValueChange={(next) =>
onChange({ ...definition, mode: next as "live" | "batch" })
}
>
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="live">live</SelectItem>
<SelectItem value="batch">batch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={PANEL_SECTION}>Inputs</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
data-testid="add-flow-input"
onClick={() =>
setInputs([...inputs, { spec: { name: "", dtype: "float" } }])
}
>
<Plus />
Add
</Button>
</div>
{inputs.length === 0 ? (
<p className="text-sm text-muted-foreground">
Messages that arrive from outside a dashboard control, a run, the
API and the value the flow starts from.
</p>
) : null}
{inputs.map((declared, index) => (
// Keyed by position: renaming an input must not remount its row.
<InputRow
key={`input-${index}`}
flow={flow}
declared={declared}
live={live}
onChange={(next) =>
setInputs(inputs.map((one, at) => (at === index ? next : one)))
}
onRemove={() =>
setInputs(inputs.filter((_one, at) => at !== index))
}
/>
))}
</div>
{batch ? (
<div className="grid gap-3">
<span className={PANEL_SECTION}>Result</span>
<p className="text-sm text-muted-foreground">
{provided.length === 0
? "Nothing is produced here yet, so a run has nothing to report."
: "What a run reports when it finishes. Everything else it computed goes with it."}
</p>
{provided.map((name) => (
<label
key={name}
htmlFor={`output-${name}`}
className="flex items-center gap-2 font-mono text-sm"
>
<Checkbox
id={`output-${name}`}
checked={outputs.includes(name)}
onCheckedChange={(checked) =>
onChange({
...definition,
outputs: checked
? [...outputs, name]
: outputs.filter((one) => one !== name),
})
}
/>
{name}
</label>
))}
</div>
) : null}
</>
)
}