Clicking a flow input label opens a popover with its declaration and current value, and a way into the flow settings

This commit is contained in:
2026-10-15 16:42:28 +02:00
parent 6d0fd53d08
commit 9836f3ff5f
4 changed files with 169 additions and 6 deletions
+5 -3
View File
@@ -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.
@@ -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)))
+34 -1
View File
@@ -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<InspectedEdge | null>(null)
const [inspectedInput, setInspectedInput] = useState<InspectedInput | null>(
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<ReadonlySet<string>>(
@@ -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}
/>
<InputInspector
input={inspectedInput}
flow={flowName}
onClose={() => setInspectedInput(null)}
onEdit={() => {
setInspectedInput(null)
setSelectedId(null)
setFlowPanelOpen(true)
}}
/>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
@@ -0,0 +1,128 @@
import { Pencil } from "lucide-react"
import type { FlowInput_Input } from "@/client"
import { Marquee } from "@/components/Common/Marquee"
import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { displayName, qualify } from "./deriveEdges"
import { formatScalar, relativeTime } from "./EdgeInspector"
import { useLiveValue } from "./liveStore"
import { MessageSparkline } from "./MessageSparkline"
import { ValuePreview } from "./ValuePreview"
export type InspectedInput = {
/** The declared input, as the flow document holds it. */
declared: FlowInput_Input
/** The label clicked, in viewport pixels, which the popover hangs off. */
rect: { left: number; top: number; width: number; height: number }
}
/**
* One flow input, read where its label sits: what it is declared as, and what
* it holds now. Editing it is the flow panel's job, one press away.
*/
export function InputInspector({
input,
flow,
onClose,
onEdit,
}: {
input: InspectedInput | null
flow: string
onClose: () => 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 (
<Popover open onOpenChange={(open) => !open && onClose()}>
<PopoverAnchor
style={{ position: "fixed", ...input.rect }}
className="pointer-events-none"
/>
<PopoverContent
align="center"
collisionPadding={8}
className="w-72 p-3"
data-testid="input-inspector"
>
<div className="flex items-center gap-1.5 text-sm">
<Marquee text={name} className="flex-1 font-mono font-medium" />
<span className="shrink-0 text-xs text-muted-foreground">
{input.declared.origin ?? "run"} input
</span>
<Button
variant="ghost"
size="icon-sm"
className="-my-1 -mr-1 shrink-0 text-muted-foreground"
onClick={onEdit}
aria-label="Edit in the flow settings"
title="Edit in the flow settings"
data-testid="edit-input"
>
<Pencil />
</Button>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-muted-foreground">Type</dt>
<dd className="font-mono">{spec.dtype ?? "float"}</dd>
<dt className="text-muted-foreground">Starts at</dt>
<dd className="min-w-0 font-mono">
{initial === null || initial === undefined ? (
<span className="text-muted-foreground">nothing</span>
) : (
<ValuePreview value={initial} dtype={spec.dtype} />
)}
</dd>
<dt className="text-muted-foreground">Now</dt>
<dd className="flex min-w-0 items-baseline gap-2">
{live === undefined ? (
<span className="text-muted-foreground">nothing yet</span>
) : scalar === null ? (
<ValuePreview
value={live.value}
name={qualify(flow, name)}
dtype={spec.dtype}
className="min-w-0 flex-1 font-mono"
/>
) : (
<span className="min-w-0 truncate font-mono font-medium">
{scalar}
</span>
)}
{live?.ts ? (
<span className="shrink-0 text-muted-foreground">
{relativeTime(live.ts)}
</span>
) : null}
</dd>
</dl>
{typeof live?.value === "number" || typeof live?.value === "boolean" ? (
<div className="mt-2">
<MessageSparkline
flow={flow}
name={displayName(flow, qualify(flow, name))}
dtype={spec.dtype}
/>
</div>
) : null}
{live?.source && live.source.kind !== "node" ? (
<p className="mt-2 text-sm text-muted-foreground">
Last set from {live.source.label}
{live.source.detail ? ` (${live.source.detail})` : ""}.
</p>
) : null}
</PopoverContent>
</Popover>
)
}