/** * Hook that holds all connection-path state and callbacks for the canvas. * Used to show the "ant trail" along the path of an update (e.g. config → agent → render). * Extracted from CanvasPage to keep the page focused on composition. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath' /** Serialize set to a stable string for equality. */ function setToStableKey(s: Set): string { return Array.from(s).sort().join(',') } export type EdgeLike = { source: string; target: string } const PULSE_MS = 1500 export type UseCanvasConnectionPathResult = { connectionPathUpdatingNodeIds: string[] connectionPathTriggerNodeIds: string[] connectionPathPausedNodeIds: string[] connectionPathErrorNodeIds: string[] connectionPathNodeIds: Set connectionPathPausedSegmentNodeIds: Set connectionPathActiveSegmentNodeIds: Set startConnectionPathUpdate: (nodeId: string) => void endConnectionPathUpdate: (nodeId: string) => void addConnectionPathTrigger: (nodeId: string) => void addConnectionPathPausedNode: (nodeId: string) => void removeConnectionPathPausedNode: (nodeId: string) => void addConnectionPathError: (nodeId: string) => void removeConnectionPathError: (nodeId: string) => void } export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult { const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState([]) const [pulseEndsAt, setPulseEndsAt] = useState(null) const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState([]) const pathTriggerBatchRef = useRef>(new Set()) const pathTriggerScheduledRef = useRef(false) const pulseTimerRef = useRef | null>(null) const addConnectionPathTrigger = useCallback((nodeId: string) => { pathTriggerBatchRef.current.add(nodeId) if (pathTriggerScheduledRef.current) return pathTriggerScheduledRef.current = true requestAnimationFrame(() => { pathTriggerScheduledRef.current = false const batch = new Set(pathTriggerBatchRef.current) pathTriggerBatchRef.current = new Set() if (batch.size === 0) return setConnectionPathTriggerNodeIds((prev) => { const next = new Set(prev) batch.forEach((id) => next.add(id)) return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next) }) setPulseEndsAt(Date.now() + PULSE_MS) }) }, []) useEffect(() => { if (pulseEndsAt == null) return const delay = Math.max(0, pulseEndsAt - Date.now()) if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current) pulseTimerRef.current = setTimeout(() => { pulseTimerRef.current = null setPulseEndsAt(null) setConnectionPathTriggerNodeIds([]) }, delay) return () => { if (pulseTimerRef.current != null) { clearTimeout(pulseTimerRef.current) pulseTimerRef.current = null } } }, [pulseEndsAt]) const connectionPathNodeIdsRaw = useMemo( () => getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined), [edges, connectionPathTriggerNodeIds] ) const connectionPathNodeIdsRef = useRef>(connectionPathNodeIdsRaw) const connectionPathNodeIdsKeyRef = useRef('') const connectionPathNodeIds = setToStableKey(connectionPathNodeIdsRaw) === connectionPathNodeIdsKeyRef.current ? connectionPathNodeIdsRef.current : (() => { connectionPathNodeIdsKeyRef.current = setToStableKey(connectionPathNodeIdsRaw) connectionPathNodeIdsRef.current = connectionPathNodeIdsRaw return connectionPathNodeIdsRaw })() const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set(), []) const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT const pulseActive = pulseEndsAt != null const connectionPathActiveSegmentNodeIdsRaw = useMemo( () => getPathToUpdatingSegmentNodeIds( edges, connectionPathTriggerNodeIds, pulseActive ), [edges, connectionPathTriggerNodeIds, pulseActive] ) const connectionPathActiveSegmentNodeIdsRef = useRef>( connectionPathActiveSegmentNodeIdsRaw ) const connectionPathActiveSegmentNodeIdsKeyRef = useRef('') const connectionPathActiveSegmentNodeIds = setToStableKey(connectionPathActiveSegmentNodeIdsRaw) === connectionPathActiveSegmentNodeIdsKeyRef.current ? connectionPathActiveSegmentNodeIdsRef.current : (() => { connectionPathActiveSegmentNodeIdsKeyRef.current = setToStableKey( connectionPathActiveSegmentNodeIdsRaw ) connectionPathActiveSegmentNodeIdsRef.current = connectionPathActiveSegmentNodeIdsRaw return connectionPathActiveSegmentNodeIdsRaw })() const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, []) const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, []) const addConnectionPathError = useCallback((nodeId: string) => { setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) }, []) const removeConnectionPathError = useCallback((nodeId: string) => { setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId)) }, []) return { connectionPathUpdatingNodeIds: [], connectionPathTriggerNodeIds, connectionPathPausedNodeIds: [], connectionPathErrorNodeIds, connectionPathNodeIds, connectionPathPausedSegmentNodeIds, connectionPathActiveSegmentNodeIds, startConnectionPathUpdate, endConnectionPathUpdate, addConnectionPathTrigger, addConnectionPathPausedNode, removeConnectionPathPausedNode, addConnectionPathError, removeConnectionPathError, } }