feat: ant path rendering on cahnges

This commit is contained in:
2026-03-11 22:08:08 +01:00
parent ea6bdeb05f
commit 1232d148e6
11 changed files with 322 additions and 34 deletions

View File

@@ -66,6 +66,7 @@ import {
getNodeType, getNodeType,
isConnectionAllowed, isConnectionAllowed,
} from '@/lib/nodeRegistry' } from '@/lib/nodeRegistry'
import { getPathNodeIds } from '@/lib/graphPath'
import type { AppNode, AppEdge } from '@/lib/nodeTypes' import type { AppNode, AppEdge } from '@/lib/nodeTypes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -77,6 +78,8 @@ import {
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 } const DUPLICATE_OFFSET = { x: 30, y: 30 }
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0], x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1], y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
@@ -264,6 +267,75 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false) const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null) const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null) const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState<string[]>([])
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState<string[]>([])
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
const pathUpdateStartTimeRef = useRef<number | null>(null)
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearPathUpdateSession = useCallback(() => {
setConnectionPathUpdatingNodeIds([])
setConnectionPathTriggerNodeIds([])
}, [])
const startConnectionPathUpdate = useCallback((nodeId: string) => {
const ref = pathUpdateNodeIdsRef.current
ref.add(nodeId)
if (ref.size === 1) {
pathUpdateStartTimeRef.current = Date.now()
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
pathUpdateEndTimeoutRef.current = null
}
}
setConnectionPathUpdatingNodeIds(Array.from(ref))
}, [])
const endConnectionPathUpdate = useCallback((nodeId: string) => {
const ref = pathUpdateNodeIdsRef.current
ref.delete(nodeId)
if (ref.size > 0) {
setConnectionPathUpdatingNodeIds(Array.from(ref))
return
}
const startedAt = pathUpdateStartTimeRef.current ?? 0
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
if (remaining === 0) {
clearPathUpdateSession()
} else {
pathUpdateEndTimeoutRef.current = setTimeout(() => {
pathUpdateEndTimeoutRef.current = null
clearPathUpdateSession()
}, remaining)
}
}, [clearPathUpdateSession])
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
const pathTriggerScheduledRef = useRef(false)
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)
})
})
}, [])
React.useEffect(() => () => {
if (pathUpdateEndTimeoutRef.current != null) {
clearTimeout(pathUpdateEndTimeoutRef.current)
}
}, [])
const nodesRef = useRef(nodes) const nodesRef = useRef(nodes)
nodesRef.current = nodes nodesRef.current = nodes
@@ -471,6 +543,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef.current?.pasteAtViewportCenter?.() flowActionsRef.current?.pasteAtViewportCenter?.()
}, []) }, [])
const connectionPathNodeIds = useMemo(
() => getPathNodeIds(edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds),
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds]
)
const flowContextValue = useMemo( const flowContextValue = useMemo(
() => ({ () => ({
nodes, nodes,
@@ -485,6 +562,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef, flowActionsRef,
fullscreenNodeId, fullscreenNodeId,
setFullscreenNodeId, setFullscreenNodeId,
connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
}), }),
[ [
nodes, nodes,
@@ -499,6 +582,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef, flowActionsRef,
fullscreenNodeId, fullscreenNodeId,
setFullscreenNodeId, setFullscreenNodeId,
connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
] ]
) )

View File

@@ -79,6 +79,8 @@ function CompactNodeView({ id, type, selected, width, height }: NodeProps) {
/** /**
* Wraps a node component so that when the viewport zoom is at or below * Wraps a node component so that when the viewport zoom is at or below
* CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view. * CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view.
* Inner is always mounted (hidden when compact) so switching zoom does not
* remount and re-trigger effects (e.g. RenderingNode fetch).
*/ */
export function createContextualNode<P extends NodeProps>( export function createContextualNode<P extends NodeProps>(
Inner: React.ComponentType<P> Inner: React.ComponentType<P>
@@ -86,22 +88,35 @@ export function createContextualNode<P extends NodeProps>(
function ContextualZoomNode(props: P) { function ContextualZoomNode(props: P) {
const { zoom } = useViewport() const { zoom } = useViewport()
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
const p = props as NodeProps
const type = p.type
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = p.width ?? defaultStyle.width
const h = p.height ?? defaultStyle.height
if (showCompact) { return (
const p = props as NodeProps <div
return ( style={
<CompactNodeView showCompact
id={p.id} ? { width: w, height: h, minWidth: w, minHeight: h, position: 'relative' }
type={p.type} : { display: 'contents' }
data={p.data} }
selected={p.selected} >
width={p.width} {showCompact && (
height={p.height} <CompactNodeView
/> id={p.id}
) type={type}
} data={p.data}
selected={p.selected}
return <Inner {...props} /> width={p.width}
height={p.height}
/>
)}
<div style={{ display: showCompact ? 'none' : undefined }} aria-hidden={showCompact}>
<Inner {...props} />
</div>
</div>
)
} }
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})` ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`

View File

@@ -9,9 +9,11 @@ import { getConnectionLabelForTarget } from '../../lib/nodeRegistry'
const EDGE_STROKE_WIDTH = 2 const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5 const DOT_MARKER_R = 1.5
const EMPTY_PATH_NODE_IDS = new Set<string>()
export function AnimatedEdge({ export function AnimatedEdge({
id, id,
source,
sourceX, sourceX,
sourceY, sourceY,
targetX, targetX,
@@ -25,6 +27,7 @@ export function AnimatedEdge({
}: EdgeProps) { }: EdgeProps) {
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? [] const nodes = ctx?.nodes ?? []
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target]) const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo( const derivedLabel = useMemo(
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined), () => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
@@ -32,6 +35,8 @@ export function AnimatedEdge({
) )
const label = labelProp ?? derivedLabel const label = labelProp ?? derivedLabel
const isOnUpdatingPath = pathNodeIds.has(source) && pathNodeIds.has(target)
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({ const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX, sourceX,
sourceY, sourceY,
@@ -88,7 +93,7 @@ export function AnimatedEdge({
strokeWidth: EDGE_STROKE_WIDTH, strokeWidth: EDGE_STROKE_WIDTH,
...style, ...style,
}} }}
className="animated-edge-path" className={`animated-edge-path${isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`}
interactionWidth={interactionWidth} interactionWidth={interactionWidth}
/> />
{label != null && ( {label != null && (

View File

@@ -1,6 +1,7 @@
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { NodeResizer } from "@xyflow/react"; import { NodeResizer } from "@xyflow/react";
import { useConnectionPathRole } from "@/lib/flowContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */ /** Default min size for resizable nodes (used by NodeResizer). */
@@ -34,6 +35,7 @@ export function BaseNode({
resizeConstraints, resizeConstraints,
...props ...props
}: BaseNodeProps) { }: BaseNodeProps) {
const connectionPathRole = useConnectionPathRole(nodeId);
const hasSize = const hasSize =
dimensions && dimensions &&
dimensions.width > 0 && dimensions.width > 0 &&
@@ -62,9 +64,13 @@ export function BaseNode({
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200", "bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
"hover:ring-1", "hover:ring-1",
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]", selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
connectionPathRole === "trigger" && "connection-path-trigger",
connectionPathRole === "updating" && "connection-path-updating",
connectionPathRole === "on-path" && "connection-path-on-path",
className, className,
)} )}
data-selected={selected} data-selected={selected}
data-path-role={connectionPathRole ?? undefined}
style={appliedStyle} style={appliedStyle}
tabIndex={0} tabIndex={0}
{...props} {...props}

View File

@@ -55,6 +55,8 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn
function AgentNodeComponent({ id, data, width, height, selected }: Props) { function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext) const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {}) const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform() const { aiConnection } = usePlatform()
@@ -88,6 +90,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) })) const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
updateData({ error: undefined, loading: true }) updateData({ error: undefined, loading: true })
startConnectionPathUpdate?.(id)
setRunning(true) setRunning(true)
try { try {
const res = await fetch('/api/agent', { const res = await fetch('/api/agent', {
@@ -107,16 +110,19 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`, error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
outputMarkdown: undefined, outputMarkdown: undefined,
}) })
endConnectionPathUpdate?.(id)
return return
} }
const markdown = (json as { markdown?: string }).markdown ?? '' const markdown = (json as { markdown?: string }).markdown ?? ''
updateData({ loading: false, error: undefined, outputMarkdown: markdown }) updateData({ loading: false, error: undefined, outputMarkdown: markdown })
endConnectionPathUpdate?.(id)
} catch (err: unknown) { } catch (err: unknown) {
updateData({ updateData({
loading: false, loading: false,
error: err instanceof Error ? err.message : 'Agent request failed', error: err instanceof Error ? err.message : 'Agent request failed',
outputMarkdown: undefined, outputMarkdown: undefined,
}) })
endConnectionPathUpdate?.(id)
} finally { } finally {
setRunning(false) setRunning(false)
} }
@@ -131,6 +137,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNode <BaseNode
className="min-w-[360px] min-h-[320px]" className="min-w-[360px] min-h-[320px]"
dimensions={dimensions} dimensions={dimensions}
nodeId={id}
selected={selected} selected={selected}
handles={ handles={
<> <>

View File

@@ -50,6 +50,8 @@ type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) { function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext) const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
const supportsFullscreen = getNodeType('render')?.supportsFullscreen const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const [renderedContent, setRenderedContent] = useState<string | null>(null) const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null) const [resolvedContent, setResolvedContent] = useState<string | null>(null)
@@ -61,6 +63,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const runIdRef = useRef(0) const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null) const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isInitialRunRef = useRef(true)
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {}) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
@@ -195,6 +198,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node', message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
}) })
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
return return
} }
if (incomingIds.length === 0) { if (incomingIds.length === 0) {
@@ -202,6 +207,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setResolvedContent(null) setResolvedContent(null)
setError(null) setError(null)
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
return return
} }
@@ -212,6 +219,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const run = async () => { const run = async () => {
loadingStartedAtRef.current = Date.now() loadingStartedAtRef.current = Date.now()
setLoading(true) setLoading(true)
if (!isInitialRunRef.current) startConnectionPathUpdate?.(id)
setError(null) setError(null)
try { try {
if (srcNode?.type === 'agent') { if (srcNode?.type === 'agent') {
@@ -223,6 +231,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setRenderedContent(html) setRenderedContent(html)
setError(null) setError(null)
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
return return
} }
const configIdsUsed = new Set<string>() const configIdsUsed = new Set<string>()
@@ -497,6 +507,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setResolvedContent(null) setResolvedContent(null)
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` }) setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
return return
} }
@@ -524,10 +536,16 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (remaining > 0) { if (remaining > 0) {
minLoadingTimeoutRef.current = setTimeout(() => { minLoadingTimeoutRef.current = setTimeout(() => {
minLoadingTimeoutRef.current = null minLoadingTimeoutRef.current = null
if (!cancelled && thisRunId === runIdRef.current) setLoading(false) if (!cancelled && thisRunId === runIdRef.current) {
setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
}
}, remaining) }, remaining)
} else { } else {
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
} }
} }
} }
@@ -537,6 +555,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setRenderedContent(null) setRenderedContent(null)
setError({ kind: 'render', message: err?.message ?? 'Render error' }) setError({ kind: 'render', message: err?.message ?? 'Render error' })
setLoading(false) setLoading(false)
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
} }
} }
} }
@@ -549,6 +569,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
clearTimeout(minLoadingTimeoutRef.current) clearTimeout(minLoadingTimeoutRef.current)
minLoadingTimeoutRef.current = null minLoadingTimeoutRef.current = null
} }
if (!isInitialRunRef.current) endConnectionPathUpdate?.(id)
isInitialRunRef.current = false
} }
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry. // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource]) }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])

View File

@@ -84,7 +84,7 @@ function VariableNodeComponent({ id, data, width, height, selected }: Props) {
) )
return ( return (
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} selected={selected} handles={<OutputHandle id="out" />}> <BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} nodeId={id} selected={selected} handles={<OutputHandle id="out" />}>
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} /> <BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
<BaseNodeContent> <BaseNodeContent>

View File

@@ -72,6 +72,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges const setEdges = ctx?.setEdges
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
const updateData = useCallback( const updateData = useCallback(
(partial: Partial<TData>) => { (partial: Partial<TData>) => {
if (!setNodes) return if (!setNodes) return
@@ -80,8 +81,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[] ) as AppNode[]
) )
addConnectionPathTrigger?.(id)
}, },
[id, setNodes] [id, setNodes, addConnectionPathTrigger]
) )
const incomingEdges = useMemo( const incomingEdges = useMemo(

View File

@@ -1,9 +1,12 @@
import React from 'react' import React, { useMemo } from 'react'
import type { Connection } from '@xyflow/react' import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/nodeTypes' import type { AppNode, AppEdge } from '@/lib/nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
export type FlowActions = { export type FlowActions = {
pasteAtViewportCenter: () => void pasteAtViewportCenter: () => void
fitView: () => void fitView: () => void
@@ -25,8 +28,38 @@ export type FlowContextValue = {
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */ /** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
fullscreenNodeId: string | null fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void setFullscreenNodeId: (id: string | null) => void
/** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */
connectionPathUpdatingNodeIds: string[]
/** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */
connectionPathTriggerNodeIds: string[]
/** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */
addConnectionPathTrigger: (nodeId: string) => void
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
connectionPathNodeIds: Set<string>
/** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */
startConnectionPathUpdate: (nodeId: string) => void
/** Call when a path update ends for this node. If min duration not reached, animation continues until then. */
endConnectionPathUpdate: (nodeId: string) => void
} }
const FlowContext = React.createContext<FlowContextValue | null>(null) const FlowContext = React.createContext<FlowContextValue | null>(null)
export default FlowContext export default FlowContext
/**
* Returns this node's role in the current path update for styling (pushing vs receiving).
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
*/
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
const ctx = React.useContext(FlowContext)
return useMemo(() => {
if (!nodeId) return null
const triggers = ctx?.connectionPathTriggerNodeIds
const updating = ctx?.connectionPathUpdatingNodeIds
const path = ctx?.connectionPathNodeIds
if (!path?.has(nodeId)) return null
if (triggers?.includes(nodeId)) return 'trigger'
if (updating?.includes(nodeId)) return 'updating'
return 'on-path'
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
}

View File

@@ -0,0 +1,67 @@
/**
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
*/
export type GraphEdge = { source: string; target: string }
/** Nodes reachable from seedIds by following edges forward (source → target). */
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.source) && !out.has(e.target)) {
out.add(e.target)
added = true
}
}
}
return out
}
/** Nodes that can reach any seed by following edges backward (target → source). */
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.target) && !out.has(e.source)) {
out.add(e.source)
added = true
}
}
}
return out
}
/**
* All node ids that lie on the path of an update.
* - If triggerNodeIds is non-empty: path = nodes that are both downstream of a trigger and
* upstream of an updating node (only the chain that actually triggered the update).
* - Otherwise: path = upstream updating downstream of updating (legacy full upstream/downstream).
* An edge should show the update animation iff both its source and target are in this set.
*/
export function getPathNodeIds(
edges: GraphEdge[],
updatingNodeIds: string[],
triggerNodeIds?: string[]
): Set<string> {
if (updatingNodeIds.length === 0) return new Set()
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
if (triggerNodeIds != null && triggerNodeIds.length > 0) {
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
const path = new Set<string>()
upstream.forEach((id) => {
if (downstreamOfTrigger.has(id)) path.add(id)
})
return path
}
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>(upstream)
downstream.forEach((id) => path.add(id))
return path
}

View File

@@ -24,9 +24,11 @@ body {
.react-flow__pane { .react-flow__pane {
cursor: crosshair; cursor: crosshair;
} }
.react-flow.react-flow--panning .react-flow__pane { .react-flow.react-flow--panning .react-flow__pane {
cursor: grabbing; cursor: grabbing;
} }
.react-flow.react-flow--selecting .react-flow__pane { .react-flow.react-flow--selecting .react-flow__pane {
cursor: crosshair; cursor: crosshair;
} }
@@ -133,26 +135,43 @@ body {
min-height: min-content; min-height: min-content;
} }
/* Animated React Flow edges: thicker stroke + path animation */ /* Animated React Flow edges: ant trail always on; color shows updating path */
.react-flow__edge path.animated-edge-path, .react-flow__edge path.animated-edge-path,
.animated-edge-path { .animated-edge-path {
stroke-width: 4; stroke-width: 4;
stroke: hsl(var(--foreground) / 0.6); stroke: hsl(var(--foreground) / 0.5);
fill: none; fill: none;
stroke-dasharray: 8 6; stroke-dasharray: 8 6;
animation: edge-flow 0.6s linear infinite; stroke-dashoffset: 0;
animation: edge-flow 1.2s linear infinite;
will-change: stroke-dashoffset;
transform: translateZ(0);
backface-visibility: hidden;
transition: stroke 0.4s ease-out;
}
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
.animated-edge-path.animated-edge-path--updating {
stroke: hsl(var(--primary));
} }
@keyframes edge-flow { @keyframes edge-flow {
from { 0% {
stroke-dashoffset: 14; stroke-dashoffset: 14;
} }
100% {
to {
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
} }
/* Connection path roles: pushing (trigger) vs receiving (updating) — classes on BaseNode root */
.connection-path-trigger {
box-shadow: inset 3px 0 0 hsl(var(--primary));
}
.connection-path-updating {
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.4);
}
/* NodeResizer: snappy drag; no border/background on handles; keep resize cursors. */ /* NodeResizer: snappy drag; no border/background on handles; keep resize cursors. */
.react-flow__resize-control { .react-flow__resize-control {
touch-action: none; touch-action: none;
@@ -174,14 +193,37 @@ body {
} }
/* Resize cursor per position (keep cursor style change on hover). */ /* Resize cursor per position (keep cursor style change on hover). */
.react-flow__resize-control.top.left { cursor: nw-resize; } .react-flow__resize-control.top.left {
.react-flow__resize-control.top.right { cursor: ne-resize; } cursor: nw-resize;
.react-flow__resize-control.bottom.left { cursor: sw-resize; } }
.react-flow__resize-control.bottom.right { cursor: se-resize; }
.react-flow__resize-control.top { cursor: n-resize; } .react-flow__resize-control.top.right {
.react-flow__resize-control.right { cursor: e-resize; } cursor: ne-resize;
.react-flow__resize-control.bottom { cursor: s-resize; } }
.react-flow__resize-control.left { cursor: w-resize; }
.react-flow__resize-control.bottom.left {
cursor: sw-resize;
}
.react-flow__resize-control.bottom.right {
cursor: se-resize;
}
.react-flow__resize-control.top {
cursor: n-resize;
}
.react-flow__resize-control.right {
cursor: e-resize;
}
.react-flow__resize-control.bottom {
cursor: s-resize;
}
.react-flow__resize-control.left {
cursor: w-resize;
}
/* Small helper for monospace pre output */ /* Small helper for monospace pre output */
pre { pre {