Say what caused a value, and draw what is not a node

Moving a dashboard slider lit up an edge between two nodes that had done
nothing. The canvas pulsed on the message's timestamp alone, and a message
has no idea who published it — so it credited whichever node happened to
be drawn as a producer.

That was never only about dashboards. Two nodes producing one message
pulsed both their edges whichever fired, and a message produced in another
flow changed with nothing on screen to account for it at all.

Values now carry their cause: a node, a dashboard widget, another flow, an
agent or an API caller. An edge pulses only for the producer that actually
published, and the edge inspector says where a value came from when it did
not come from a node.

What is not a node in this flow is now drawn as one — a label rather than
a card, because a dashboard with twenty tiles would otherwise bury the
logic the canvas exists to show. That covers cross-flow wiring too, which
is the link in/out affordance that has been missing.

They are never part of the document. They join at render, after everything
that reads or writes the canvas nodes, so an autosave, an undo or a delete
cannot reach them — with a Playwright test that drags a node and asserts
the stored flow still holds exactly what it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
2026-08-16 15:51:54 +02:00
co-authored by Claude Fable 5
parent 3fa9141eb9
commit 75c26ef000
25 changed files with 1072 additions and 42 deletions
+27 -18
View File
@@ -127,7 +127,7 @@ function Unbound() {
// Display
// ---------------------------------------------------------------------------
function StatWidget({ widget }: { widget: WidgetDef }) {
function StatWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
@@ -154,7 +154,7 @@ function StatWidget({ widget }: { widget: WidgetDef }) {
* The number is always written out as well: a reading that only exists as an
* angle is unreadable to anyone who cannot judge one.
*/
function GaugeWidget({ widget }: { widget: WidgetDef }) {
function GaugeWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
@@ -229,7 +229,7 @@ function GaugeWidget({ widget }: { widget: WidgetDef }) {
*
* Enough for the labels and notes a dashboard carries, and not worth a parser.
*/
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
@@ -263,7 +263,7 @@ function MarkdownWidget({ widget }: { widget: WidgetDef }) {
// ---------------------------------------------------------------------------
/** Publishing, with the value shown as sent until the engine confirms it. */
function usePublish(widget: WidgetDef) {
function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = config(widget)
const target = text(cfg.target)
const publish = usePublishMessage()
@@ -273,15 +273,22 @@ function usePublish(widget: WidgetDef) {
live,
send: (value: unknown) => {
if (!target) return
publish.mutate({ name: target, value })
publish.mutate({
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
})
},
pending: publish.isPending,
}
}
function ButtonWidget({ widget }: { widget: WidgetDef }) {
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, send, pending } = usePublish(widget)
const { target, send, pending } = usePublish(widget, dashboard)
if (!target) return <Unbound />
return (
<Button
@@ -295,8 +302,8 @@ function ButtonWidget({ widget }: { widget: WidgetDef }) {
)
}
function SwitchWidget({ widget }: { widget: WidgetDef }) {
const { target, live, send } = usePublish(widget)
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const { target, live, send } = usePublish(widget, dashboard)
if (!target) return <Unbound />
return (
<div className="flex items-center justify-between gap-2">
@@ -310,9 +317,9 @@ function SwitchWidget({ widget }: { widget: WidgetDef }) {
)
}
function SliderWidget({ widget }: { widget: WidgetDef }) {
function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const { target, live, send } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const step = num(cfg.step, 1)
@@ -357,9 +364,9 @@ function SliderWidget({ widget }: { widget: WidgetDef }) {
)
}
function InputWidget({ widget }: { widget: WidgetDef }) {
function InputWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const { target, live, send } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
@@ -384,9 +391,9 @@ function InputWidget({ widget }: { widget: WidgetDef }) {
)
}
function DropdownWidget({ widget }: { widget: WidgetDef }) {
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const { target, live, send } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
@@ -420,8 +427,10 @@ function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
// ---------------------------------------------------------------------------
export type WidgetProps = { widget: WidgetDef; dashboard: string }
const RENDERERS: Partial<
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
> = {
stat: StatWidget,
gauge: GaugeWidget,
@@ -433,7 +442,7 @@ const RENDERERS: Partial<
dropdown: DropdownWidget,
}
export function WidgetBody({ widget }: { widget: WidgetDef }) {
export function WidgetBody({ widget, dashboard }: WidgetProps) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
@@ -442,5 +451,5 @@ export function WidgetBody({ widget }: { widget: WidgetDef }) {
</p>
)
}
return <Renderer widget={widget} />
return <Renderer widget={widget} dashboard={dashboard} />
}