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 f24402d25d
commit 399d3e76f2
2 changed files with 22 additions and 4 deletions
+22 -3
View File
@@ -19,7 +19,7 @@ import {
Terminal,
Timer,
} from "lucide-react"
import { memo } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
@@ -29,6 +29,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/useMobile"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges"
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
// way too — see DESIGN-GUIDELINES.md → Responsive.
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 =
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
// 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
// a border can never land on top of it — see `.node-pulse` in flow.css.
<div className="relative rounded-lg">
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
{/* Remounting on each emit is what restarts the animation, so this is
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
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",