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
+52 -13
View File
@@ -47,7 +47,13 @@ import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import {
bindingsKey,
boundaryKey,
deriveEdges,
portOf,
qualify,
} from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode"
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
@@ -57,6 +63,7 @@ import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge"
import { type Direction, layoutGraph } from "./layout"
import { NodePanel } from "./NodePanel"
import { RunDialog } from "./RunDialog"
import "./flow.css"
import { liveStore, useFlowPaused } from "./liveStore"
import {
@@ -228,6 +235,8 @@ function FlowEditorInner({
const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// A batch run is asked for its parameters before it is submitted.
const [runOpen, setRunOpen] = useState(false)
// Throwing an edit away is offered from the dock, so its confirmation lives
// here rather than inside the settings panel.
const [discardOpen, setDiscardOpen] = useState(false)
@@ -368,8 +377,9 @@ function FlowEditorInner({
)
// A cheap fingerprint of the wiring, and the only thing the layout depends
// on: what the graph looks like follows from what is wired to what.
const key = bindingsKey(definitions)
// on: what the graph looks like follows from what is wired to what. The
// flow's own boundary is drawn too, so a declared input counts as wiring.
const key = `${bindingsKey(definitions)}|${boundaryKey(flowDoc)}`
// Offer the names already in play: everything published is worth reading,
// and an input nobody provides yet is worth publishing.
@@ -395,7 +405,11 @@ function FlowEditorInner({
const openEndpoint = useCallback(
(id: string) => {
const [kind, rest] = id.split(":", 2)
if (kind === "dashboard") {
if (kind === "input" || kind === "output") {
// This flow's own boundary, declared in its settings.
setSelectedId(null)
setFlowPanelOpen(true)
} else if (kind === "dashboard") {
navigate({
to: "/dashboards/$name",
params: { name: (rest ?? "").split(":")[0] },
@@ -448,7 +462,12 @@ function FlowEditorInner({
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on.
const external = useMemo(() => {
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName)
const built = deriveEndpoints(
detail.endpoints ?? [],
definitions,
flowName,
flowDoc,
)
return {
...built,
nodes: built.nodes.map((node) => {
@@ -552,8 +571,8 @@ function FlowEditorInner({
}, [direction, definitions.length, external.nodes.length, fitView])
const runMutation = useMutation({
mutationFn: () =>
FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }),
mutationFn: (inputs: Record<string, unknown> = {}) =>
FlowsService.runFlow({ name: flowName, requestBody: { inputs } }),
onSuccess: (state) => {
liveStore.setValues(
Object.fromEntries(
@@ -568,6 +587,19 @@ function FlowEditorInner({
showErrorToast("The flow could not run. Check the node errors."),
})
/**
* Pressing Run.
*
* A batch flow is submitted as a run, and a run is identified by its
* parameters — so it asks for them rather than quietly using the defaults.
*/
const startRun = useCallback(async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
if (latest.current.mode === "batch") setRunOpen(true)
else runMutation.mutate({})
}, [flush, runMutation])
const enableMutation = useMutation({
mutationFn: (next: boolean) =>
next
@@ -1044,11 +1076,7 @@ function FlowEditorInner({
onClearNode: () => setLogsNode(null),
}}
onAddNode={() => setPaletteOpen(true)}
onRun={async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate()
}}
onRun={startRun}
onTogglePause={() => pauseMutation.mutate(!paused)}
onStep={() => stepMutation.mutate()}
stepping={stepMutation.isPending}
@@ -1125,7 +1153,18 @@ function FlowEditorInner({
flows={flows.data}
onAddNode={addNode}
onAddSharedNode={(libName) => addNode("python", libName)}
onRun={() => runMutation.mutate()}
onRun={startRun}
/>
<RunDialog
open={runOpen}
definition={flowDoc}
pending={runMutation.isPending}
onOpenChange={setRunOpen}
onRun={(params) => {
setRunOpen(false)
runMutation.mutate(params)
}}
/>
<Dialog