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
+75
View File
@@ -307,6 +307,53 @@ export const DashboardsPublicSchema = {
title: 'DashboardsPublic'
} as const;
export const EndpointSchema = {
properties: {
kind: {
type: 'string',
title: 'Kind'
},
id: {
type: 'string',
title: 'Id'
},
label: {
type: 'string',
title: 'Label'
},
detail: {
type: 'string',
title: 'Detail',
default: ''
},
provides: {
items: {
type: 'string'
},
type: 'array',
title: 'Provides',
default: []
},
requires: {
items: {
type: 'string'
},
type: 'array',
title: 'Requires',
default: []
}
},
type: 'object',
required: ['kind', 'id', 'label'],
title: 'Endpoint',
description: `Something wired into this flow that is not a node in it.
A dashboard control setting one of its messages, a tile showing one, or a
node in another flow on the far side of a dotted name. The canvas draws
these so a value never appears to come from nowhere — or worse, appears to
come from whichever node happens to be drawn as a producer.`
} as const;
export const FlowDef_InputSchema = {
properties: {
name: {
@@ -416,6 +463,14 @@ export const FlowDetailSchema = {
type: 'boolean',
title: 'Paused',
default: false
},
endpoints: {
items: {
'$ref': '#/components/schemas/Endpoint'
},
type: 'array',
title: 'Endpoints',
default: []
}
},
type: 'object',
@@ -1950,6 +2005,26 @@ export const app__api__routes__messages__PublishRequestSchema = {
properties: {
value: {
title: 'Value'
},
source_kind: {
type: 'string',
title: 'Source Kind',
default: 'api'
},
source_id: {
type: 'string',
title: 'Source Id',
default: ''
},
source_label: {
type: 'string',
title: 'Source Label',
default: ''
},
source_detail: {
type: 'string',
title: 'Source Detail',
default: ''
}
},
type: 'object',
+22
View File
@@ -29,6 +29,10 @@ export type app__api__routes__messages__MessageValue = {
export type app__api__routes__messages__PublishRequest = {
value: unknown;
source_kind?: string;
source_id?: string;
source_label?: string;
source_detail?: string;
};
/**
@@ -115,6 +119,23 @@ export type DashboardSummary = {
*/
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
/**
* Something wired into this flow that is not a node in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow on the far side of a dotted name. The canvas draws
* these so a value never appears to come from nowhere — or worse, appears to
* come from whichever node happens to be drawn as a producer.
*/
export type Endpoint = {
kind: string;
id: string;
label: string;
detail?: string;
provides?: Array<(string)>;
requires?: Array<(string)>;
};
/**
* One atomic flow.
*/
@@ -151,6 +172,7 @@ export type FlowDetail = {
has_draft?: boolean;
enabled?: boolean;
paused?: boolean;
endpoints?: Array<Endpoint>;
};
/**
@@ -173,6 +173,7 @@ export function DashboardEditor({
<SectionGrid
section={section}
dashboard={draft.name}
renderWidget={(widget) => (
<WidgetFrame
title={widget.title}
@@ -220,7 +221,7 @@ export function DashboardEditor({
className="min-h-0 flex-1 text-left"
onClick={() => setSelected(widget.id)}
>
<WidgetBody widget={widget} />
<WidgetBody widget={widget} dashboard={draft.name} />
</button>
</WidgetFrame>
)}
@@ -48,10 +48,13 @@ export function widgetStyle(widget: WidgetDef): React.CSSProperties {
export function SectionGrid({
section,
dashboard,
renderWidget,
className,
}: {
section: SectionDef_Output
/** Which dashboard this is, so an input widget can name itself. */
dashboard: string
renderWidget?: (widget: WidgetDef) => React.ReactNode
className?: string
}) {
@@ -72,7 +75,7 @@ export function SectionGrid({
renderWidget(widget)
) : (
<WidgetFrame title={widget.title}>
<WidgetBody widget={widget} />
<WidgetBody widget={widget} dashboard={dashboard} />
</WidgetFrame>
)}
</div>
@@ -122,6 +125,7 @@ export function DashboardView({
<SectionGrid
key={section.id}
section={section}
dashboard={dashboard.name}
renderWidget={renderWidget}
/>
))}
+32 -3
View File
@@ -47,10 +47,39 @@ export function useSaveDashboard(name: string) {
})
}
/** What an input widget does: put a value into the graph. */
/** What an input widget does: put a value into the graph.
*
* The widget names itself so the flow canvas can show the value arriving from
* here, rather than crediting whichever node is drawn as a producer.
*/
export function usePublishMessage() {
return useMutation({
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
MessagesService.publishMessage({ name, requestBody: { value } }),
mutationFn: ({
name,
value,
dashboard,
widget,
label,
kind,
}: {
name: string
value: unknown
dashboard?: string
widget?: string
label?: string
kind?: string
}) =>
MessagesService.publishMessage({
name,
requestBody: {
value,
source_kind: "dashboard",
// Matches the endpoint id the canvas builds for this widget.
source_id:
dashboard && widget ? `dashboard:${dashboard}:${widget}` : "",
source_label: label ?? widget ?? "Dashboard",
source_detail: kind ?? "",
},
}),
})
}
+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} />
}
@@ -173,6 +173,15 @@ export function EdgeInspector({
</span>
</div>
{/* Several nodes can publish one message, and so can a dashboard or
another flow — so the value alone does not say what caused it. */}
{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}
{live === undefined ? (
<p className="mt-2 text-sm text-muted-foreground">
Nothing has come through yet. Run the flow to see a value here.
@@ -0,0 +1,66 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, Workflow } from "lucide-react"
import { memo } from "react"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
const KIND_ICONS = {
dashboard: LayoutDashboard,
flow: Workflow,
} as const
/**
* A thing wired into this flow that is not a node in it.
*
* Drawn as a label rather than a card on purpose: a dashboard with twenty
* tiles would otherwise bury the logic the canvas exists to show, which is
* the same reason dashboards are their own documents. It is here to account
* for a value, not to compete with the nodes for attention.
*/
function EndpointNodeComponent({ data, selected }: NodeProps) {
const { label, kind, detail, provides, requires } = data as EndpointNodeData
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
const messages = [...provides, ...requires]
return (
<div
className={cn(
"flex max-w-48 items-center gap-2 px-1 py-0.5 text-muted-foreground",
selected && "text-foreground",
)}
title={messages.join("\n")}
>
{/* Both sides always exist so an edge can attach; only one is used. */}
{requires.map((message) => (
<Handle
key={`in-${message}`}
type="target"
id={message}
position={Position.Left}
className="!border-border !bg-card"
/>
))}
{provides.map((message) => (
<Handle
key={`out-${message}`}
type="source"
id={message}
position={Position.Right}
className="!border-border !bg-card"
/>
))}
<Icon className="size-4 shrink-0" />
<span className="grid min-w-0">
<span className="truncate text-sm leading-tight">{label}</span>
<span className="truncate text-xs tracking-wide uppercase opacity-70">
{detail}
</span>
</span>
</div>
)
}
export const EndpointNode = memo(EndpointNodeComponent)
export default EndpointNode
+61 -6
View File
@@ -42,6 +42,8 @@ import useCustomToast from "@/hooks/useCustomToast"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode"
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
@@ -61,7 +63,10 @@ import {
} from "./queries"
import { useFlowSocket } from "./useFlowSocket"
const nodeTypes = { flow: FlowNode }
const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode }
/** Is this canvas node actually part of the flow document? */
const isDocumentNode = (node: { id: string }) => !isEndpointNode(node)
const edgeTypes = { live: LiveEdge }
type Rebind = {
@@ -380,11 +385,50 @@ function FlowEditorInner({
}
}, [key])
/** Where clicking an endpoint takes you: the thing it stands for. */
const openEndpoint = useCallback(
(id: string) => {
const [kind, rest] = id.split(":", 2)
if (kind === "dashboard") {
navigate({
to: "/dashboards/$name",
params: { name: (rest ?? "").split(":")[0] },
})
} else if (kind === "flow") {
navigate({
to: "/flows/$flowName",
params: { flowName: (rest ?? "").split(".")[0] },
})
}
},
[navigate],
)
// Dashboards and other flows wired into this one. They are drawn but never
// stored: they join at render, after everything that reads or writes
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
const external = useMemo(
() =>
deriveEndpoints(
detail.endpoints ?? [],
definitions,
flowName,
new Map(canvasNodes.map((node) => [node.id, node.position])),
),
[detail.endpoints, key, flowName],
)
// Edges follow from the name bindings, so they are derived, never stored.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
const edges = useMemo(
() => deriveEdges(definitions, flowName),
[key, flowName],
() => [...deriveEdges(definitions, flowName), ...external.edges],
[key, flowName, external],
)
const shownNodes = useMemo(
() => [...renderedNodes, ...external.nodes],
[renderedNodes, external],
)
// Editing ports adds and removes handles. React Flow measures those once, so
@@ -680,14 +724,25 @@ function FlowEditorInner({
return (
<>
<ReactFlow
nodes={renderedNodes}
nodes={shownNodes}
edges={edges}
onNodesChange={onNodesChange}
onNodeDragStop={(_event, _node, dragged) =>
commit(definitions, mergeDragged(canvasNodes, dragged))
commit(
definitions,
mergeDragged(canvasNodes, dragged.filter(isDocumentNode)),
)
}
onNodesDelete={(deleted) =>
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
}
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
onNodeClick={(_event, node) => {
// An endpoint is somewhere else's: opening its panel here would
// offer to edit a node this flow does not contain.
if (!isDocumentNode(node)) {
openEndpoint(node.id)
return
}
setFlowPanelOpen(false)
setSelectedId(node.id)
}}
+9 -3
View File
@@ -34,7 +34,7 @@ function LiveEdgeComponent({
data,
selected,
}: EdgeProps) {
const { message } = (data ?? {}) as FlowEdgeData
const { message, producerId } = (data ?? {}) as FlowEdgeData
const live = useLiveValue(message)
const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false)
@@ -49,14 +49,20 @@ function LiveEdgeComponent({
targetPosition,
})
// Restart the stroke animation whenever a newer message lands.
// Restart the stroke animation whenever a newer message lands — but only
// for the producer that actually published it. A message can have several
// producers, and can also be set from a dashboard or another flow, so
// pulsing on the value alone claims things happened that did not.
useEffect(() => {
if (!live?.ts || live.ts === lastTs.current) return
lastTs.current = live.ts
const from = live.source
// No source at all is an older engine; pulse rather than go silent.
if (from && from.id !== producerId) return
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [live?.ts])
}, [live?.ts, live?.source, producerId])
return (
<>
+7 -1
View File
@@ -27,6 +27,8 @@ export function portOf(spec: MessageSpec): string {
export type FlowEdgeData = {
message: string
flow: string
/** Whose publication this edge represents, so only it pulses. */
producerId: string
[key: string]: unknown
}
@@ -65,7 +67,11 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
target: node.id,
targetHandle: targetPort,
type: "live",
data: { message, flow } satisfies FlowEdgeData,
data: {
message,
flow,
producerId: `${flow}.${producer.node}`,
} satisfies FlowEdgeData,
})
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
import type { Endpoint, NodeDef_Input } from "@/client"
import type { FlowEdgeData } from "./deriveEdges"
import { portOf, qualify } from "./deriveEdges"
/**
* Canvas elements for things wired into a flow that are not nodes in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow across a dotted name. Without these the canvas shows a
* value changing with nothing to account for it — and pulses whichever node
* happens to be drawn as a producer, which did nothing.
*
* They are never part of the document: this returns display-only nodes that
* are appended after everything which reads or writes `canvasNodes`, so an
* autosave, an undo or a delete cannot reach them.
*/
/** Marks a canvas node as one of these, for anything that has to skip them. */
export const ENDPOINT_TYPE = "endpoint"
export type EndpointNodeData = {
label: string
kind: string
detail: string
/** Messages it publishes into this flow, and ones it reads out of it. */
provides: string[]
requires: string[]
[key: string]: unknown
}
/** Lanes either side of the graph, so a label never lands on a node. */
const GAP_X = 120
const STACK_Y = 64
/** Roughly a node's width; only used to find the right-hand lane. */
const NODE_W = 220
export function isEndpointNode(node: { id: string }): boolean {
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
}
/**
* Place the endpoints and wire them to the nodes they touch.
*
* Positions are computed rather than stored: an endpoint is not part of the
* flow, so there is nowhere to keep a position that would not be a lie about
* what the document contains. A producer sits left of what it feeds, a
* consumer right of what feeds it.
*/
export function deriveEndpoints(
endpoints: Endpoint[],
definitions: NodeDef_Input[],
flow: string,
positions: Map<string, { x: number; y: number }>,
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
if (endpoints.length === 0) return { nodes: [], edges: [] }
// Which node consumes or produces each message, so a label can sit beside it.
const consumers = new Map<string, { node: string; port: string }[]>()
const producers = new Map<string, { node: string; port: string }[]>()
for (const node of definitions) {
for (const spec of node.requires ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
consumers.set(message, [
...(consumers.get(message) ?? []),
{ node: node.id, port: portOf(spec) },
])
}
for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
producers.set(message, [
...(producers.get(message) ?? []),
{ node: node.id, port: portOf(spec) },
])
}
}
// A lane either side of the graph. Anchoring each label to the node it
// feeds put them on top of the nodes, so they live outside the whole thing
// instead: producers to the left of everything, consumers to the right.
const placed = [...positions.values()]
const bounds = {
left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0,
right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0,
top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0,
}
const nodes: FlowCanvasNode[] = []
const edges: Edge[] = []
// How many labels already sit on each side, so they stack instead of overlap.
const stacked = { left: 0, right: 0 }
for (const endpoint of endpoints) {
const produces = endpoint.provides ?? []
const reads = endpoint.requires ?? []
// A producer belongs upstream of what it feeds; everything else downstream.
const side = produces.length > 0 ? "left" : "right"
const index = stacked[side]
stacked[side] += 1
nodes.push({
id: endpoint.id,
type: ENDPOINT_TYPE,
position: {
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
y: bounds.top + index * STACK_Y,
},
// Not part of the document, and not the author's to rearrange.
draggable: false,
selectable: true,
deletable: false,
data: {
label: endpoint.label,
kind: endpoint.kind,
detail: endpoint.detail ?? "",
provides: produces,
requires: reads,
} satisfies EndpointNodeData,
})
// An endpoint that publishes feeds every node consuming that message.
for (const message of produces) {
for (const consumer of consumers.get(message) ?? []) {
edges.push({
id: `${endpoint.id}->${consumer.node}:${consumer.port}`,
source: endpoint.id,
sourceHandle: message,
target: consumer.node,
targetHandle: consumer.port,
type: "live",
data: {
message,
flow,
producerId: endpoint.id,
} satisfies FlowEdgeData,
})
}
}
// One that reads is fed by every node producing it.
for (const message of reads) {
for (const producer of producers.get(message) ?? []) {
edges.push({
id: `${producer.node}:${producer.port}->${endpoint.id}`,
source: producer.node,
sourceHandle: producer.port,
target: endpoint.id,
targetHandle: message,
type: "live",
data: {
message,
flow,
producerId: `${flow}.${producer.node}`,
} satisfies FlowEdgeData,
})
}
}
}
return { nodes, edges }
}
+12 -1
View File
@@ -8,7 +8,18 @@ import { useSyncExternalStore } from "react"
* nothing else.
*/
export type LiveValue = { value: unknown; ts: number | null }
/** Who caused a value. The canvas needs it to pulse the right edge. */
export type ValueSource = {
kind: "node" | "dashboard" | "flow" | "agent" | "api"
id: string
label: string
detail?: string
}
export type LiveValue = {
value: unknown
ts: number | null
source?: ValueSource
}
export type LiveStatus = {
status: "active" | "error" | "running" | "success"
error?: string | null
@@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useRef } from "react"
import { OpenAPI } from "@/client"
import { type LogLine, liveStore } from "./liveStore"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000
@@ -19,7 +19,13 @@ type FlowEvent =
paused?: string[]
logs?: LogLine[]
}
| { type: "message_value"; name: string; value: unknown; ts: number }
| {
type: "message_value"
name: string
value: unknown
ts: number
source?: ValueSource
}
| { type: "node_executed"; node: string; outputs: number }
| { type: "node_error"; node: string; error: string }
| { type: "node_status"; node: string; status: string; error?: string | null }
@@ -88,6 +94,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
liveStore.setValue(message.name, {
value: message.value,
ts: message.ts,
source: message.source,
})
break
case "node_executed":