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:
@@ -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)))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user