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.
Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
This commit is contained in:
@@ -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<number | null>(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<number | null>(null)
|
||||
|
||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||
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 ? (
|
||||
<Input
|
||||
value={naming?.index === index ? naming.text : own}
|
||||
placeholder={portName({ name: spec.name }) || "port"}
|
||||
aria-label={reads ? "Argument name" : "Port name"}
|
||||
aria-invalid={Boolean(problem)}
|
||||
autoFocus={opened === index}
|
||||
title="The name the node sees this port by. Empty follows the message."
|
||||
className="h-11 min-w-0 flex-1 font-mono text-sm md:h-8"
|
||||
onChange={(event) => 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)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex min-w-0 flex-1 items-center gap-1"
|
||||
data-testid="port-follows"
|
||||
>
|
||||
<span
|
||||
className="min-w-0 truncate px-3 font-mono text-sm text-muted-foreground"
|
||||
title={`The node sees this port as ${code || "its message's name"}, following the message.`}
|
||||
>
|
||||
{code || "message name"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Name the ${reads ? "argument" : "port"} differently`}
|
||||
title="Name it differently from the message"
|
||||
onClick={() => setOpened(index)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
|
||||
const messageName = (
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`message in ${flow}`}
|
||||
autoFocus={index === freshIndex}
|
||||
className={reads ? undefined : "flex-[1_1_10rem]"}
|
||||
onChange={(name) =>
|
||||
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.
|
||||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
value={naming?.index === index ? naming.text : own}
|
||||
placeholder={portName({ name: spec.name }) || "port"}
|
||||
aria-label="Port name"
|
||||
aria-invalid={Boolean(problem)}
|
||||
title="The name the node sees this port by. Empty follows the message."
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) =>
|
||||
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}
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) =>
|
||||
@@ -381,7 +452,7 @@ function PortList({
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 w-[104px] text-sm"
|
||||
className="!h-11 w-[104px] text-sm md:!h-8"
|
||||
aria-label="Type"
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -394,95 +465,108 @@ function PortList({
|
||||
))}
|
||||
</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}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
size="icon-touch"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => {
|
||||
onRemoved?.(spec)
|
||||
setOpened(null)
|
||||
onChange(specs.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{/* Wraps for a list port, whose item type would otherwise take
|
||||
the room the name needs. */}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<CornerDownRight
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`message in ${flow}`}
|
||||
autoFocus={index === freshIndex}
|
||||
onChange={(name) =>
|
||||
update(index, {
|
||||
name,
|
||||
port: ownsName(spec) ? spec.port : "",
|
||||
})
|
||||
}
|
||||
onRenamed={onRenamed}
|
||||
/>
|
||||
<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-14 text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { interval: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
{streamable ? (
|
||||
<Button
|
||||
variant={spec.stream ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Streaming output"
|
||||
aria-pressed={spec.stream ?? false}
|
||||
title={
|
||||
"Published repeatedly while the node runs: a curve rather than " +
|
||||
"a result. A run keeps every value it takes."
|
||||
{reads ? (
|
||||
<span className="flex min-w-0 flex-[1_1_10rem] items-center gap-1.5">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
as
|
||||
</span>
|
||||
{codeName}
|
||||
</span>
|
||||
) : (
|
||||
messageName
|
||||
)}
|
||||
{/* Together, so a narrow row moves all of them and not one. */}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{spec.dtype === "list" ? (
|
||||
<Select
|
||||
value={spec.item ?? "record"}
|
||||
onValueChange={(value) =>
|
||||
update(index, { item: value as DType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-11 w-[92px] text-sm md:!h-8"
|
||||
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-11 w-14 text-sm md:h-8"
|
||||
onChange={(event) =>
|
||||
update(index, { interval: Number(event.target.value) || 0 })
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!spec.stream && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => update(index, { stream: !spec.stream })}
|
||||
>
|
||||
<Activity />
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
{/* An input's own flags (it reads without waking the node) go
|
||||
here, as an output's streaming toggle does. */}
|
||||
{side === "provides" ? (
|
||||
<Button
|
||||
variant={spec.stream ? "secondary" : "ghost"}
|
||||
size="icon-touch"
|
||||
aria-label="Streaming output"
|
||||
aria-pressed={spec.stream ?? false}
|
||||
title={
|
||||
"Published repeatedly while the node runs: a curve rather than " +
|
||||
"a result. A run keeps every value it takes."
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!spec.stream && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => update(index, { stream: !spec.stream })}
|
||||
>
|
||||
<Activity />
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{problem ? (
|
||||
<p className="text-xs text-destructive">{problem}</p>
|
||||
) : clash ? (
|
||||
<p
|
||||
className="text-xs text-status-warning"
|
||||
data-testid="port-clash"
|
||||
>
|
||||
Another {reads ? "input" : "output"} here is also called {code}.
|
||||
Name one of them differently.
|
||||
</p>
|
||||
) : null}
|
||||
{spec.name ? (
|
||||
<MessageSparkline
|
||||
@@ -649,6 +733,8 @@ function DevicePortList({
|
||||
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
|
||||
onConfig: (port: string, next: Record<string, unknown>) => 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 ? (
|
||||
<Select
|
||||
value={port || undefined}
|
||||
onValueChange={(next) => {
|
||||
// The device says what it carries, so the type follows.
|
||||
const picked = offered.find((one) => one.port === next)
|
||||
onChange(
|
||||
specs.map((one, at) =>
|
||||
at === index
|
||||
? {
|
||||
...one,
|
||||
port: next,
|
||||
dtype: picked?.dtype ?? one.dtype,
|
||||
}
|
||||
: one,
|
||||
),
|
||||
port ? [port, next] : undefined,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-11 min-w-0 flex-1 font-mono text-sm md:!h-8"
|
||||
aria-label={`Port of ${name}`}
|
||||
>
|
||||
<SelectValue placeholder="Pick a port" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* One the report has dropped stays pickable as it is. */}
|
||||
{port && !offered.some((one) => one.port === port) ? (
|
||||
<SelectItem value={port}>{port}</SelectItem>
|
||||
) : null}
|
||||
{offered.map((one) => (
|
||||
<SelectItem key={one.port} value={one.port}>
|
||||
{one.port}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
// Remounted with whatever was saved, so a refused name snaps back.
|
||||
key={port}
|
||||
defaultValue={port}
|
||||
placeholder="Port, e.g. gpio.4"
|
||||
aria-label={`Port of ${name}`}
|
||||
autoComplete="off"
|
||||
className="h-11 min-w-0 flex-1 font-mono text-sm md:h-8"
|
||||
onBlur={(event) => {
|
||||
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 = (
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`message in ${flow}`}
|
||||
autoFocus={false}
|
||||
onChange={(next) => 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 (
|
||||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{offered ? (
|
||||
<Select
|
||||
value={port || undefined}
|
||||
onValueChange={(next) => {
|
||||
// The device says what it carries, so the type follows.
|
||||
const picked = offered.find((one) => one.port === next)
|
||||
onChange(
|
||||
specs.map((one, at) =>
|
||||
at === index
|
||||
? {
|
||||
...one,
|
||||
port: next,
|
||||
dtype: picked?.dtype ?? one.dtype,
|
||||
}
|
||||
: one,
|
||||
),
|
||||
port ? [port, next] : undefined,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 min-w-0 flex-1 font-mono text-sm"
|
||||
aria-label={`Port of ${name}`}
|
||||
>
|
||||
<SelectValue placeholder="Pick a port" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* One the report has dropped stays pickable as it is. */}
|
||||
{port && !offered.some((one) => one.port === port) ? (
|
||||
<SelectItem value={port}>{port}</SelectItem>
|
||||
) : null}
|
||||
{offered.map((one) => (
|
||||
<SelectItem key={one.port} value={one.port}>
|
||||
{one.port}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
// Remounted with whatever was saved, so a refused name snaps back.
|
||||
key={port}
|
||||
defaultValue={port}
|
||||
placeholder="Port, e.g. gpio.4"
|
||||
aria-label={`Port of ${name}`}
|
||||
autoComplete="off"
|
||||
className="h-8 min-w-0 flex-1 font-mono text-sm"
|
||||
onBlur={(event) => {
|
||||
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}
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) =>
|
||||
@@ -764,7 +863,7 @@ function DevicePortList({
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 w-[104px] text-sm"
|
||||
className="!h-11 w-[104px] text-sm md:!h-8"
|
||||
aria-label={`Type of ${name}`}
|
||||
>
|
||||
<SelectValue />
|
||||
@@ -779,8 +878,8 @@ function DevicePortList({
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
size="icon-touch"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Remove ${name}`}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
@@ -797,19 +896,22 @@ function DevicePortList({
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`message in ${flow}`}
|
||||
autoFocus={false}
|
||||
onChange={(next) => update(index, { name: next })}
|
||||
/>
|
||||
{follows ? (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
to
|
||||
</span>
|
||||
{portField}
|
||||
</span>
|
||||
) : (
|
||||
messageField
|
||||
)}
|
||||
{schema ? null : (
|
||||
<JsonField
|
||||
value={config[port] ?? {}}
|
||||
label={`Config of ${name}`}
|
||||
check={objectOnly}
|
||||
className="h-8 w-24 shrink-0"
|
||||
className="h-11 w-24 shrink-0 md:h-8"
|
||||
onChange={(next) => {
|
||||
if (port) onConfig(port, next as Record<string, unknown>)
|
||||
}}
|
||||
@@ -2100,6 +2202,7 @@ function PanelBody({
|
||||
<>
|
||||
<PortList
|
||||
title="Consumes"
|
||||
side="requires"
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
@@ -2108,6 +2211,7 @@ function PanelBody({
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
side="provides"
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
@@ -2117,7 +2221,6 @@ function PanelBody({
|
||||
portMove.current = null
|
||||
editNode(repoint({ ...node, provides }, from, to))
|
||||
}}
|
||||
streamable
|
||||
// Only the publishing side names a message; an input is as often
|
||||
// re-pointed at a different one as it is renamed.
|
||||
onRenamed={renamePort}
|
||||
|
||||
Reference in New Issue
Block a user