Add flow settings, pulse emitting nodes, and simplify node state

- One dot per node now carries the whole story: primary while running, sage
  after a good run, red when anything is wrong, with the explanation on hover.
  The corner badge is gone, along with the second way of saying the same thing.
- A node that publishes something flashes a ring, so a running flow is legible
  without reading the edge values. Nodes that consume but publish nothing stay
  quiet, which is why the event carries an output count.
- Flow settings open in the same panel its nodes use, from a pencil in the
  dock: the title, the name, and deleting the flow. NodePanel and FlowPanel
  share the panel chrome rather than each drawing their own.
- Renaming is a server operation, because a flow's name is the namespace of its
  messages: the directory moves and every other flow reading `old.message` is
  repointed, instead of being left pointing at a flow that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 19:46:23 +02:00
co-authored by Claude Fable 5
parent c254d487ba
commit fd666743d2
18 changed files with 756 additions and 197 deletions
+55 -1
View File
@@ -41,12 +41,14 @@ import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs"
import { LiveEdge } from "./LiveEdge"
import { NodePanel } from "./NodePanel"
import "./flow.css"
import { liveStore } from "./liveStore"
import {
flowKeys,
flowQueryOptions,
flowsQueryOptions,
nodeTypesQueryOptions,
@@ -96,6 +98,7 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
}
function FlowEditorInner({ flowName }: { flowName: string }) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { screenToFlowPosition, fitView } = useReactFlow()
@@ -117,6 +120,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [inspected, setInspected] = useState<InspectedEdge | null>(null)
const [rebind, setRebind] = useState<Rebind | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
const issues = detail.issues ?? []
@@ -228,6 +232,32 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
showErrorToast("The flow could not run. Check the node errors."),
})
const renameMutation = useMutation({
mutationFn: (newName: string) =>
FlowsService.renameFlow({ name: flowName, requestBody: { new_name: newName } }),
onSuccess: (detail) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
navigate({
to: "/flows/$flowName",
params: { flowName: detail.definition.name },
replace: true,
})
},
onError: () =>
showErrorToast("That name is taken, or is not a valid flow name."),
})
const deleteMutation = useMutation({
mutationFn: () => FlowsService.deleteFlow({ name: flowName }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
setFlowPanelOpen(false)
navigate({ to: "/flows", replace: true })
},
onError: () => showErrorToast("The flow could not be deleted."),
})
const sourceMutation = useMutation({
mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) =>
FlowsService.saveNodeSource({
@@ -406,7 +436,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
commit(definitions, mergeDragged(canvasNodes, dragged))
}
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
onNodeClick={(_event, node) => setSelectedId(node.id)}
onNodeClick={(_event, node) => {
setFlowPanelOpen(false)
setSelectedId(node.id)
}}
onPaneClick={() => {
setSelectedId(null)
setInspected(null)
@@ -453,6 +486,10 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
issues={issues}
running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
onRun={() => {
flush()
runMutation.mutate()
@@ -475,6 +512,23 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
</div>
) : null}
<FlowPanel
open={flowPanelOpen && !selected}
definition={{ ...detail.definition, nodes: definitions }}
nodeCount={definitions.length}
renaming={renameMutation.isPending}
onChange={(next) => {
flush()
save({ ...next, nodes: definitions })
}}
onRename={(newName) => {
flush()
renameMutation.mutate(newName)
}}
onDelete={() => deleteMutation.mutate()}
onClose={() => setFlowPanelOpen(false)}
/>
<NodePanel
node={selected}
flow={flowName}