Offer settings from other nodes, help with cron, follow the ports

Suggest a parameter's value from the nodes of the same type in every flow,
the way message names are already offered; secrets stay out of it. Explain
the cron fields, and derive the expression an interval asks for. Keep an
untouched function scaffold in step with the node's ports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:58:19 +02:00
co-authored by Claude Fable 5
parent b5949938f0
commit 761a006a2d
2 changed files with 305 additions and 78 deletions
+244 -64
View File
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Maximize2, Minimize2, X } from "lucide-react" import { Maximize2, Minimize2, X } from "lucide-react"
import { lazy, Suspense, useEffect, useRef, useState } from "react" import {
type ComponentProps,
lazy,
Suspense,
useEffect,
useRef,
useState,
} from "react"
import { import {
type DType, type DType,
@@ -37,6 +44,7 @@ import {
libraryQueryOptions, libraryQueryOptions,
nodeSourceQueryOptions, nodeSourceQueryOptions,
secretsQueryOptions, secretsQueryOptions,
useParamSuggestions,
} from "./queries" } from "./queries"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
@@ -52,6 +60,93 @@ const SECTION = PANEL_SECTION
/** Settings the engine reads itself, so they are not the author's to name. */ /** Settings the engine reads itself, so they are not the author's to name. */
const RESERVED_PARAMS = new Set(["synchronous"]) 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. * A message name, typed freely or picked from the names already in play.
* *
@@ -74,77 +169,29 @@ function MessageNameInput({
/** The name as it was before this edit, once the field is done with. */ /** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void onRenamed?: (previous: string, next: string) => void
}) { }) {
const [open, setOpen] = useState(false)
// Every keystroke commits, so a rename is only a rename once the user is // Every keystroke commits, so a rename is only a rename once the user is
// finished with the field. // finished with the field.
const before = useRef(value) const before = useRef(value)
const matches = suggestions.filter(
(name) =>
name !== value && name.toLowerCase().includes(value.toLowerCase()),
)
return ( return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}> <SuggestInput
<PopoverAnchor asChild>
<Input
value={value} value={value}
suggestions={suggestions}
empty="No matching message."
placeholder={placeholder} placeholder={placeholder}
aria-label="Message name" aria-label="Message name"
autoComplete="off"
// A port added by hand is meant to be named right away. // A port added by hand is meant to be named right away.
autoFocus={autoFocus} autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm" className="h-8 flex-1 font-mono text-sm"
onChange={onChange}
onFocus={() => { onFocus={() => {
before.current = value before.current = value
setOpen(true)
}} }}
onBlur={() => { onBlur={() => {
setOpen(false)
if (before.current !== value) onRenamed?.(before.current, value) if (before.current !== value) onRenamed?.(before.current, value)
before.current = value before.current = value
}} }}
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>No matching message.</CommandEmpty>
<CommandGroup>
{matches.map((name) => (
<CommandItem
key={name}
value={name}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(name)
setOpen(false)
}}
onSelect={() => {
onChange(name)
setOpen(false)
}}
>
{name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) )
} }
@@ -423,18 +470,75 @@ function FreeParamsForm({
) )
} }
/**
* 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. */ /** A small form built from the node type's declared parameters. */
function ParamsForm({ function ParamsForm({
type,
schema, schema,
params, params,
onChange, onChange,
}: { }: {
type: string | undefined
schema: Record<string, unknown> | undefined schema: Record<string, unknown> | undefined
params: Record<string, unknown> params: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void onChange: (next: Record<string, unknown>) => void
}) { }) {
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
const properties = (schema?.properties ?? {}) as Record< const properties = (schema?.properties ?? {}) as Record<
string, string,
{ {
@@ -446,6 +550,12 @@ function ParamsForm({
} }
> >
const entries = Object.entries(properties) 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 if (entries.length === 0) return null
const set = (key: string, value: unknown) => const set = (key: string, value: unknown) =>
@@ -519,23 +629,26 @@ function ParamsForm({
<Label htmlFor={`param-${key}`} className="text-sm font-normal"> <Label htmlFor={`param-${key}`} className="text-sm font-normal">
{label} {label}
</Label> </Label>
<Input <SuggestInput
id={`param-${key}`} id={`param-${key}`}
className="h-8 text-sm" className="h-8 text-sm"
type={numeric ? "number" : "text"} type={numeric ? "number" : "text"}
value={String(value)} value={String(value)}
onChange={(event) => suggestions={suggestions[key] ?? []}
set( empty="Nothing like that in use yet."
key, onChange={(next) => set(key, numeric ? Number(next) : next)}
numeric ? Number(event.target.value) : event.target.value,
)
}
/> />
{property.description ? ( {property.description ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{property.description} {property.description}
</p> </p>
) : null} ) : null}
{key === "cron" ? (
<CronHelp
params={params}
onPick={(expression) => set(key, expression)}
/>
) : null}
</div> </div>
) )
})} })}
@@ -645,6 +758,57 @@ function SharingSection({
) )
} }
/** 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: "{}",
}
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({ function PanelBody({
node, node,
flow, flow,
@@ -699,6 +863,21 @@ function PanelBody({
}, 1000) }, 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, // ⌘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. // so the binding is too. Anywhere else on the canvas it publishes the flow.
useShortcuts( useShortcuts(
@@ -729,7 +908,7 @@ function PanelBody({
flow={flow} flow={flow}
emptyHint="Nothing yet. Add a message this node reads." emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes} suggestions={suggestions.consumes}
onChange={(requires) => onChange({ ...node, requires })} onChange={(requires) => editNode({ ...node, requires })}
/> />
<PortList <PortList
title="Provides" title="Provides"
@@ -737,12 +916,13 @@ function PanelBody({
flow={flow} flow={flow}
emptyHint="Nothing yet. Add a message this node publishes." emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides} suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })} onChange={(provides) => editNode({ ...node, provides })}
// Only the publishing side names a message; an input is as often // Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed. // re-pointed at a different one as it is renamed.
onRenamed={onRenameMessage} onRenamed={onRenameMessage}
/> />
<ParamsForm <ParamsForm
type={node.type}
schema={nodeType?.params_schema} schema={nodeType?.params_schema}
params={node.params ?? {}} params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })} onChange={(params) => onChange({ ...node, params })}
+47
View File
@@ -1,6 +1,8 @@
import { import {
type UseMutationResult, type UseMutationResult,
useMutation, useMutation,
useQueries,
useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
@@ -62,6 +64,51 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
queryFn: () => FlowsService.readMessageHistory({ name, message }), queryFn: () => FlowsService.readMessageHistory({ name, message }),
}) })
/** Other flows change rarely; suggestions do not need them fresh to the second. */
const SUGGEST_STALE = 5 * 60 * 1000
/**
* Values already in use by nodes of the same type, keyed by parameter.
*
* The second MQTT node points at the same broker as the first one, so the
* settings are worth offering rather than making someone type them again.
*
* Only plain values are collected: a secret is stored as a reference object
* and stays out of this, which is the point of the secret picker.
*/
export function useParamSuggestions(
type: string | undefined,
): Record<string, string[]> {
const { data: flows } = useQuery({
...flowsQueryOptions(),
staleTime: SUGGEST_STALE,
})
// ponytail: reads every flow to collect them, sharing the editor's own cache
// entries; an aggregate endpoint if a big installation makes that hurt.
const details = useQueries({
queries: (type ? (flows?.data ?? []) : []).map((flow) => ({
...flowQueryOptions(flow.name),
staleTime: SUGGEST_STALE,
})),
})
const seen: Record<string, Set<string>> = {}
for (const { data } of details) {
for (const node of data?.definition.nodes ?? []) {
if (node.type !== type) continue
for (const [key, value] of Object.entries(node.params ?? {})) {
if (value === null || value === "" || typeof value === "object")
continue
if (!seen[key]) seen[key] = new Set()
seen[key].add(String(value))
}
}
}
return Object.fromEntries(
Object.entries(seen).map(([key, values]) => [key, [...values].sort()]),
)
}
const AUTOSAVE_DELAY = 800 const AUTOSAVE_DELAY = 800
/** How long to wait for a save in flight before sending the next one. */ /** How long to wait for a save in flight before sending the next one. */
const RETRY_DELAY = 100 const RETRY_DELAY = 100