From 2e0ba321246ef963f2ebf70d96ff563ae995564b Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 28 Oct 2026 11:04:15 +0100 Subject: [PATCH] Node panel: ports read message first on inputs, code name is text until it differs An input row now starts from its message and ends as the argument the code takes it as ("as prompt"); an output keeps its key on top and the message below. The name the code uses is muted text with a pencil while it follows the message's last segment, and a field only when it differs. Device Follows ports get the same order (message, then the port driven). A list port's item type moves to the second row so it no longer squeezes the name, two ports deriving one name get a warning, and the rows rebuilt use 44px touch targets on phones. The stored node spec is unchanged. --- docs/interface/flow-editor.md | 26 +- frontend/src/components/Flow/NodePanel.tsx | 437 +++++++++++++-------- frontend/tests/flows.spec.ts | 79 +++- 3 files changed, 366 insertions(+), 176 deletions(-) diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index b556d54..4cdd5c0 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -33,12 +33,18 @@ and open its settings. ## The node panel -**Consumes** and **Provides** declare the node's ports. Each has a port name, -payload type, bound message and delivery interval. Outputs also have a -streaming toggle. +**Consumes** and **Provides** declare the node's ports. Each has a bound +message, a port name, a payload type and a delivery interval. Outputs also have +a streaming toggle. + +A row reads top to bottom, from where a value starts to where it ends. An +input starts as a message and arrives in your code as an argument, so the +message is on top and the argument below it (*as* `prompt`). An output starts +as a key of what your code returns and ends as a message, so the key is on top. A **Device** node stands for a paired device instead: pick the device, then -its ports under **Publishes** and **Follows** (see +its ports under **Publishes** (the device's port, then the message) and +**Follows** (the message, then the port it drives; see [the device's own ports](dashboards.md#the-devices-own-ports)). A device that reports its ports offers them in a list with their type filled in and a form for the settings each one reads, and the command palette offers each one as a @@ -47,10 +53,14 @@ shortcut, such as *Hallway camera*. Message fields suggest existing names. Matching an output's message is how you connect an input. -A blank port-name field follows the message's last segment. Set a port name -to keep the argument name fixed while changing its message binding. Names use -letters, digits and `_`, cannot start with a digit, and must be unique among -the node's inputs or outputs. +A port name follows the message's last segment (`home.outdoor` becomes +`outdoor`) until you name it. While it follows, it is shown as muted text, and +the pencil beside it opens a field. A field only appears on its own when the +name differs from the message. Set a port name to keep the argument name fixed +while changing its message binding. Names use letters, digits and `_`, cannot +start with a digit, and must be unique among the node's inputs or outputs. Two +messages that end in the same segment, such as `a.level` and `b.level`, get a +warning until one of them is named differently. Renaming an output's message updates its consumers in the flow. Changing an input binding points that input at a different message. diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 74e2f95..f35dcef 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -5,6 +5,7 @@ import { CornerDownRight, Maximize2, Minimize2, + Pencil, Trash2, X, } from "lucide-react" @@ -231,6 +232,7 @@ function MessageNameInput({ suggestions, placeholder, autoFocus, + className, onChange, onRenamed, }: { @@ -238,6 +240,8 @@ function MessageNameInput({ suggestions: string[] placeholder: string autoFocus: boolean + /** Replaces the flex share, for a row that wraps. */ + className?: string onChange: (next: string) => void /** The name as it was before this edit, once the field is done with. */ onRenamed?: (previous: string, next: string) => void @@ -253,9 +257,13 @@ function MessageNameInput({ empty="No matching message." placeholder={placeholder} aria-label="Message name" + title={value || undefined} // A port added by hand is meant to be named right away. autoFocus={autoFocus} - className="h-8 flex-1 font-mono text-sm" + className={cn( + "h-11 min-w-0 font-mono text-sm md:h-8", + className ?? "flex-1", + )} onChange={onChange} onFocus={() => { before.current = value @@ -276,8 +284,19 @@ function portProblem(name: string, others: string[]): string | null { return null } +/** + * The ports of one side of a node, a row each. + * + * Data reads top to bottom. An input starts from a message and ends as an + * argument of the node's code, so the message is on top and the argument + * below it. An output starts as a key of what the code returns and ends as a + * message, so the key is on top. A name the code uses only gets a field of its + * own when it differs from the message's last segment, which is what it + * follows until it is named by hand; until then it is muted text. + */ function PortList({ title, + side, specs, flow, emptyHint, @@ -286,9 +305,10 @@ function PortList({ onRenamed, onPortRenamed, onRemoved, - streamable = false, }: { title: string + /** An input reads a message into the code, an output writes one out of it. */ + side: "requires" | "provides" specs: MessageSpec[] flow: string emptyHint: string @@ -299,9 +319,8 @@ function PortList({ onPortRenamed?: (from: string, to: string) => void /** The port about to be dropped, reported just before the shorter list. */ onRemoved?: (spec: MessageSpec) => void - /** Outputs only: a port a node publishes on repeatedly while it runs. */ - streamable?: boolean }) { + const reads = side === "requires" // The port just added, so its name field can take focus. const [freshIndex, setFreshIndex] = useState(null) // A port name is kept once its field is left, so what is typed lives here @@ -309,6 +328,8 @@ function PortList({ const [naming, setNaming] = useState<{ index: number; text: string } | null>( null, ) + // The port whose name was opened for naming by hand with the pencil. + const [opened, setOpened] = useState(null) const update = (index: number, patch: Partial) => { const next = specs.map((spec, i) => @@ -349,31 +370,81 @@ function PortList({ specs.filter((_, i) => i !== index).map(portName), ) : null + const code = portName(spec) + // Two ports that derive one name would only differ by what they carry. + const clash = + !problem && + code !== "" && + specs.some((other, i) => i !== index && portName(other) === code) + const hasField = own !== "" || opened === index || typed !== null + + const codeName = hasField ? ( + setNaming({ index, text: event.target.value })} + onBlur={() => { + if (typed !== null && !problem && typed !== own) { + const to = typed || portName({ name: spec.name }) + if (to !== portName(spec)) onPortRenamed?.(portName(spec), to) + update(index, { port: typed }) + } + setNaming(null) + setOpened(null) + }} + /> + ) : ( + + + {code || "message name"} + + + + ) + + const messageName = ( + + update(index, { + name, + port: ownsName(spec) ? spec.port : "", + }) + } + onRenamed={onRenamed} + /> + ) + return ( // The curve reads as its own thing rather than as part of the row // above it, so it gets a little air.
- - setNaming({ index, text: event.target.value }) - } - onBlur={() => { - if (typed !== null && !problem && typed !== own) { - const to = typed || portName({ name: spec.name }) - if (to !== portName(spec)) - onPortRenamed?.(portName(spec), to) - update(index, { port: typed }) - } - setNaming(null) - }} - /> + {reads ? messageName : codeName} - {spec.dtype === "list" ? ( - - ) : null}
-
+ {/* Wraps for a list port, whose item type would otherwise take + the room the name needs. */} +
- - update(index, { - name, - port: ownsName(spec) ? spec.port : "", - }) - } - onRenamed={onRenamed} - /> - - update(index, { interval: Number(event.target.value) || 0 }) - } - /> - {streamable ? ( - - ) : null} + /> + {/* An input's own flags (it reads without waking the node) go + here, as an output's streaming toggle does. */} + {side === "provides" ? ( + + ) : null} +
{problem ? (

{problem}

+ ) : clash ? ( +

+ Another {reads ? "input" : "output"} here is also called {code}. + Name one of them differently. +

) : null} {spec.name ? ( void onConfig: (port: string, next: Record) => void }) { + // An `out` port is one the device is driven through: it follows a message. + const follows = direction === "out" const offered = reported?.filter((port) => wiresAs(port, direction)) // An out port is read when the device asks for it, never a reason to run. const fresh = (port: ReportedPort | null): MessageSpec => ({ @@ -694,69 +780,82 @@ function DevicePortList({ Object.keys((said.config.properties as object) ?? {}).length ? said.config : undefined + // The device's own port: picked from what it reports, or typed. + const portField = offered ? ( + + ) : ( + { + const next = portOf(event.currentTarget.value) + event.currentTarget.value = next || port + if (next && next !== port) + onChange( + specs.map((one, at) => + at === index ? { ...one, port: next } : one, + ), + port ? [port, next] : undefined, + ) + }} + /> + ) + const messageField = ( + update(index, { name: next })} + /> + ) + // A port the device publishes is read top to bottom as its port, then + // the message. One it follows is a message, then the port it drives. return (
- {offered ? ( - - ) : ( - { - const next = portOf(event.currentTarget.value) - event.currentTarget.value = next || port - if (next && next !== port) - onChange( - specs.map((one, at) => - at === index ? { ...one, port: next } : one, - ), - port ? [port, next] : undefined, - ) - }} - /> - )} + {follows ? messageField : portField}