Gate the flow node pulse on a real emit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
This commit is contained in:
2026-08-20 13:38:59 +02:00
co-authored by Claude Opus 5
parent 32cc23fa6b
commit 4b0554d766
2 changed files with 22 additions and 4 deletions
-1
View File
@@ -168,7 +168,6 @@ as an em dash.
### Flow editor follow-ups ### Flow editor follow-ups
- BUG/UI: the payload of the inject node should adapt to the type set in the "provides" arguments and the number of fields should grow with the number of "provides" arguments. So if e.g. one argument is boolean, the payload should be a dropdown for true/false. If it is int, the value entered should be parsed as int etc. Check for other notes where this applies as well - BUG/UI: the payload of the inject node should adapt to the type set in the "provides" arguments and the number of fields should grow with the number of "provides" arguments. So if e.g. one argument is boolean, the payload should be a dropdown for true/false. If it is int, the value entered should be parsed as int etc. Check for other notes where this applies as well
- BUG/UI: `FlowNode` still renders `emits > 0 ? <span key={emits} className="node-pulse"/>`, so opening the editor with a non-zero persisted count replays a pulse for something that happened before the canvas mounted. `BrainNode` gained a `seen` ref that gates the pulse on a real emit; the same gate belongs here.
- CHORE/UI: a node's error status clears as soon as it runs again, so a failure that genuinely fired an alert can leave no trace on the canvas by the time anyone looks. The logs panel keeps the traceback; the node itself reads as healthy. - CHORE/UI: a node's error status clears as soon as it runs again, so a failure that genuinely fired an alert can leave no trace on the canvas by the time anyone looks. The logs panel keeps the traceback; the node itself reads as healthy.
- FEAT/UI: the flow graph should auto zoom-to fit when a panel is open and edges are created through assigning inputs/outputs - FEAT/UI: the flow graph should auto zoom-to fit when a panel is open and edges are created through assigning inputs/outputs
- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild only the touched flow when it starts to show. - PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild only the touched flow when it starts to show.
+22 -3
View File
@@ -19,7 +19,7 @@ import {
Terminal, Terminal,
Timer, Timer,
} from "lucide-react" } from "lucide-react"
import { memo } from "react" import { memo, useEffect, useRef, useState } from "react"
import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client" import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -29,6 +29,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges" import { portOf } from "./deriveEdges"
import { useNodeEmits, useNodeStatus } from "./liveStore" import { useNodeEmits, useNodeStatus } from "./liveStore"
@@ -119,6 +120,22 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
// The graph runs top to bottom on a phone, so the ports have to face that // The graph runs top to bottom on a phone, so the ports have to face that
// way too — see DESIGN-GUIDELINES.md → Responsive. // way too — see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile() const vertical = useIsMobile()
// Whether a pulse is playing right now; the ring is mounted only while it is.
const [firing, setFiring] = useState(false)
// The count outlives this component: the store is module-level, and a
// snapshot restores what the engine counted before the page even loaded. So
// the number we mount with is history, and only a change on top of it is
// something that just happened.
const seen = useRef(emits)
useEffect(() => {
if (emits === seen.current) return
seen.current = emits
setFiring(true)
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [emits])
const Icon = const Icon =
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
// A connector's own type cannot be in the map above, and a device is // A connector's own type cannot be in the map above, and a device is
@@ -141,8 +158,10 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
// The pulse ring measures itself from here rather than from the card, so // The pulse ring measures itself from here rather than from the card, so
// a border can never land on top of it — see `.node-pulse` in flow.css. // a border can never land on top of it — see `.node-pulse` in flow.css.
<div className="relative rounded-lg"> <div className="relative rounded-lg">
{/* Remounting on each emit is what restarts the animation. */} {/* Remounting on each emit is what restarts the animation, so this is
{emits > 0 ? <span key={emits} className="node-pulse" /> : null} gated on the pulse rather than on the count: a node mounting with a
count already in the store would otherwise play it once for free. */}
{firing ? <span key={emits} className="node-pulse" /> : null}
<div <div
className={cn( className={cn(
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow", "relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",