From 9836f3ff5fbe771e1e9faa89e0c9a40e3a6cae79 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 16:42:28 +0200 Subject: [PATCH] Clicking a flow input label opens a popover with its declaration and current value, and a way into the flow settings --- docs/interface/flow-editor.md | 8 +- .../src/components/Flow/EdgeInspector.tsx | 4 +- frontend/src/components/Flow/FlowEditor.tsx | 35 ++++- .../src/components/Flow/InputInspector.tsx | 128 ++++++++++++++++++ 4 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/Flow/InputInspector.tsx diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 1cb2d9a..d5c222f 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -12,9 +12,11 @@ The graph runs left to right on desktop and top to bottom on phones. - **Endpoints** show flow inputs, dashboard widgets, panel I/O ports and nodes in other flows that exchange messages with this flow. A value passing lights one up in a sweep: left to right when it sends into the flow, right to left - when it reads out of it. Click a panel port to open its panel in the Panels - dialog. Click a node in another flow to open that flow on the label leading - back here, with its edges highlighted. + when it reads out of it. +- **Clicking an endpoint** shows a flow input's type, starting value and + current value, with a pencil that opens the flow settings to edit it. A panel + port opens its panel in the Panels dialog. A node in another flow opens that + flow on the label leading back here, with its edges highlighted. Values update over a websocket. A connection banner tells you when the editor cannot reach the engine. diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index e47ec39..02d1a60 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -11,7 +11,7 @@ import { useLiveValue } from "./liveStore" import { MessageSparkline } from "./MessageSparkline" import { ValuePreview } from "./ValuePreview" -function relativeTime(ts: number | null | undefined): string { +export function relativeTime(ts: number | null | undefined): string { if (!ts) return "not seen yet" const seconds = Math.max(0, Math.round(Date.now() / 1000 - ts)) if (seconds < 5) return "just now" @@ -24,7 +24,7 @@ function relativeTime(ts: number | null | undefined): string { * A value short enough to share the summary row, or `null` for the objects and * arrays that need the payload view instead. */ -function formatScalar(value: unknown): string | null { +export function formatScalar(value: unknown): string | null { // Three decimals, trailing zeros dropped: enough precision to be useful, never // wide enough to wrap the row. if (typeof value === "number") return String(Number(value.toFixed(3))) diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index c6906b6..42a4bf6 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -73,6 +73,7 @@ import { import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" +import { InputInspector, type InspectedInput } from "./InputInspector" import { LiveEdge } from "./LiveEdge" import { type Direction, layoutGraph, type NodeShape } from "./layout" import { NodePanel } from "./NodePanel" @@ -293,6 +294,9 @@ function FlowEditorInner({ ) const [paletteOpen, setPaletteOpen] = useState(false) const [inspected, setInspected] = useState(null) + const [inspectedInput, setInspectedInput] = useState( + null, + ) // The edges are derived, so xyflow's own selection would be thrown away on // every rebuild: the ids live here instead and are marked on at render. const [selectedEdges, setSelectedEdges] = useState>( @@ -1221,9 +1225,26 @@ function FlowEditorInner({ onNodesDelete={(deleted) => deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } - onNodeClick={(_event, node) => { + onNodeClick={(event, node) => { // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. + // An input is read where it sits; editing it is one press on. + const declared = node.id.startsWith("input:") + ? (flowDoc.inputs ?? []).find( + (one) => + `input:${qualify(flowName, one.spec?.name ?? "")}` === + node.id, + ) + : undefined + if (declared) { + const { left, top, width, height } = + event.currentTarget.getBoundingClientRect() + setInspectedInput({ + declared, + rect: { left, top, width, height }, + }) + return + } if (!isDocumentNode(node)) { const wire = edges.find( (edge) => edge.source === node.id || edge.target === node.id, @@ -1244,6 +1265,7 @@ function FlowEditorInner({ setFlowPanelOpen(false) setEditorExpanded(false) setInspected(null) + setInspectedInput(null) }} onEdgeClick={(event, edge) => { const label = (id: string) => { @@ -1424,6 +1446,17 @@ function FlowEditorInner({ onUnbind={unbind} /> + setInspectedInput(null)} + onEdit={() => { + setInspectedInput(null) + setSelectedId(null) + setFlowPanelOpen(true) + }} + /> + void + onEdit: () => void +}) { + const spec = input?.declared.spec ?? {} + const name = spec.name ?? "" + const live = useLiveValue(input ? qualify(flow, name) : undefined) + + if (!input) return null + + const initial = input.declared.initial + const scalar = live === undefined ? null : formatScalar(live.value) + + return ( + !open && onClose()}> + + +
+ + + {input.declared.origin ?? "run"} input + + + + +
+
Type
+
{spec.dtype ?? "float"}
+
Starts at
+
+ {initial === null || initial === undefined ? ( + nothing + ) : ( + + )} +
+
Now
+
+ {live === undefined ? ( + nothing yet + ) : scalar === null ? ( + + ) : ( + + {scalar} + + )} + {live?.ts ? ( + + {relativeTime(live.ts)} + + ) : null} +
+
+ + {typeof live?.value === "number" || typeof live?.value === "boolean" ? ( +
+ +
+ ) : null} + + {live?.source && live.source.kind !== "node" ? ( +

+ Last set from {live.source.label} + {live.source.detail ? ` (${live.source.detail})` : ""}. +

+ ) : null} + + + ) +}