feat: connections improvements

This commit is contained in:
2026-03-11 23:05:01 +01:00
parent 3ef30bd58c
commit e79821bd81
6 changed files with 180 additions and 21 deletions

View File

@@ -66,7 +66,7 @@ import {
getNodeType,
isConnectionAllowed,
} from '@/lib/nodeRegistry'
import { getPathNodeIds } from '@/lib/graphPath'
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graphPath'
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
import { toast } from 'sonner'
import {
@@ -273,9 +273,16 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const pathUpdateStartTimeRef = useRef<number | null>(null)
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState<string[]>([])
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
const clearPathUpdateSession = useCallback(() => {
setConnectionPathUpdatingNodeIds([])
setConnectionPathTriggerNodeIds([])
if (connectionPathPausedNodeIdsRef.current.length === 0) {
setConnectionPathTriggerNodeIds([])
setConnectionPathPausedNodeIds([])
}
}, [])
const startConnectionPathUpdate = useCallback((nodeId: string) => {
@@ -544,10 +551,41 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
}, [])
const connectionPathNodeIds = useMemo(
() => getPathNodeIds(edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds),
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds]
() =>
getPathNodeIds(
edges,
connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds,
connectionPathPausedNodeIds
),
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
)
const connectionPathPausedSegmentNodeIds = useMemo(
() =>
getPausedSegmentNodeIds(
edges,
connectionPathNodeIds,
connectionPathTriggerNodeIds,
connectionPathPausedNodeIds
),
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
)
const connectionPathActiveSegmentNodeIds = useMemo(() => {
const active = new Set(connectionPathNodeIds)
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
return active
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
}, [])
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
}, [])
const flowContextValue = useMemo(
() => ({
nodes,
@@ -566,6 +604,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds,
connectionPathPausedNodeIds,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
startConnectionPathUpdate,
endConnectionPathUpdate,
}),
@@ -586,6 +629,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
connectionPathTriggerNodeIds,
addConnectionPathTrigger,
connectionPathNodeIds,
connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds,
connectionPathPausedNodeIds,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
startConnectionPathUpdate,
endConnectionPathUpdate,
]

View File

@@ -28,6 +28,8 @@ export function AnimatedEdge({
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
@@ -35,7 +37,10 @@ export function AnimatedEdge({
)
const label = labelProp ?? derivedLabel
const isOnUpdatingPath = pathNodeIds.has(source) && pathNodeIds.has(target)
const isOnPausedSegment =
pathNodeIds.has(source) && pathNodeIds.has(target) && pausedSegmentNodeIds.has(source) && pausedSegmentNodeIds.has(target)
const isOnUpdatingPath =
pathNodeIds.has(source) && pathNodeIds.has(target) && activeSegmentNodeIds.has(source) && activeSegmentNodeIds.has(target)
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
@@ -93,7 +98,7 @@ export function AnimatedEdge({
strokeWidth: EDGE_STROKE_WIDTH,
...style,
}}
className={`animated-edge-path${isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`}
className={`animated-edge-path${isOnPausedSegment ? ' animated-edge-path--paused' : isOnUpdatingPath ? ' animated-edge-path--updating' : ''}`}
interactionWidth={interactionWidth}
/>
{label != null && (

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useMemo, useState } from 'react'
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
@@ -26,6 +26,8 @@ export type AgentNodeData = {
outputMarkdown?: string
error?: string
loading?: boolean
/** Signature of inputs (sourceIds + config contents) from the last successful run. Used to show on-hold when current inputs differ. */
lastRunSourceSignature?: string
}
type Props = AbstractNodeProps<AgentNodeData>
@@ -57,6 +59,8 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
const addConnectionPathPausedNode = flowContext?.addConnectionPathPausedNode
const removeConnectionPathPausedNode = flowContext?.removeConnectionPathPausedNode
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
@@ -79,6 +83,16 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
})
}, [sourceIds, nodes])
const sourceSignature = useMemo(() => {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((n: { id: string }) => n.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) => getConfigContent((nodes.find((n: { id: string }) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined))
return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents })
}, [sourceIds, nodes])
const runAgent = useCallback(async () => {
const configContents = sourceIds
.filter((sid) => {
@@ -89,6 +103,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const prompt = configContents.length > 0 ? configContents.join('\n\n---\n\n') : 'No prompt provided. Please describe what you want in structured markdown.'
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
removeConnectionPathPausedNode?.(id)
updateData({ error: undefined, loading: true })
startConnectionPathUpdate?.(id)
setRunning(true)
@@ -114,7 +129,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
return
}
const markdown = (json as { markdown?: string }).markdown ?? ''
updateData({ loading: false, error: undefined, outputMarkdown: markdown })
updateData({
loading: false,
error: undefined,
outputMarkdown: markdown,
lastRunSourceSignature: sourceSignature,
})
endConnectionPathUpdate?.(id)
} catch (err: unknown) {
updateData({
@@ -126,13 +146,26 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
} finally {
setRunning(false)
}
}, [sourceIds, nodes, contextText, updateData, aiConnection])
}, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection, removeConnectionPathPausedNode, startConnectionPathUpdate, endConnectionPathUpdate])
const onContextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
[updateData]
)
const pathNodeIds = flowContext?.connectionPathNodeIds
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? []
const lastRunSourceSignature = data?.lastRunSourceSignature
const hasPendingInputs =
pathNodeIds?.has(id) &&
triggerNodeIds.length > 0 &&
!loading &&
sourceSignature !== lastRunSourceSignature
useEffect(() => {
if (hasPendingInputs) addConnectionPathPausedNode?.(id)
else removeConnectionPathPausedNode?.(id)
}, [id, hasPendingInputs, addConnectionPathPausedNode, removeConnectionPathPausedNode])
return (
<BaseNode
className="min-w-[360px] min-h-[320px]"

View File

@@ -36,6 +36,16 @@ export type FlowContextValue = {
addConnectionPathTrigger: (nodeId: string) => void
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
connectionPathNodeIds: Set<string>
/** Path nodes in the "paused" segment (from trigger up to Archon on hold). Those edges are yellow. */
connectionPathPausedSegmentNodeIds: Set<string>
/** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */
connectionPathActiveSegmentNodeIds: Set<string>
/** Archon-type nodes that are on hold (e.g. Agent waiting for Run). */
connectionPathPausedNodeIds: string[]
/** Add this node as paused (on hold); remove when user continues. */
addConnectionPathPausedNode: (nodeId: string) => void
/** Remove this node from paused. */
removeConnectionPathPausedNode: (nodeId: string) => void
/** 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. */

View File

@@ -40,28 +40,84 @@ export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<s
/**
* 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.
* - If updatingNodeIds non-empty: path = downstream(trigger) ∩ (upstream(updating) downstream(updating))
* so we include config→agent→rendering when agent is running.
* - Else if triggerNodeIds and pausedNodeIds non-empty: path = downstream(trigger) ∩ upstream(paused)
* so we show yellow (config→agent) when config changed and agent is on hold.
* An edge should show color iff both its source and target are in this set.
*/
export function getPathNodeIds(
edges: GraphEdge[],
updatingNodeIds: string[],
triggerNodeIds?: string[]
triggerNodeIds?: string[],
pausedNodeIds?: string[]
): Set<string> {
if (updatingNodeIds.length === 0) return new Set()
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
if (triggerNodeIds != null && triggerNodeIds.length > 0) {
const hasUpdating = updatingNodeIds.length > 0
const hasPausedPath =
pausedNodeIds != null &&
pausedNodeIds.length > 0 &&
triggerNodeIds != null &&
triggerNodeIds.length > 0
if (hasUpdating && triggerNodeIds != null && triggerNodeIds.length > 0) {
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
const upstreamOfUpdating = getUpstreamNodeIds(edges, updatingNodeIds)
const downstreamOfUpdating = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>()
downstreamOfTrigger.forEach((id) => {
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
})
return path
}
if (hasPausedPath && !hasUpdating) {
const upstream = getUpstreamNodeIds(edges, pausedNodeIds!)
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
if (hasUpdating) {
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>(upstream)
downstream.forEach((id) => path.add(id))
return path
}
return new Set()
}
/**
* Path nodes from triggers up to and including the first paused node (Archon on hold).
* Used to color those edges yellow; rest of path stays blue.
*/
export function getPausedSegmentNodeIds(
edges: GraphEdge[],
pathNodeIds: Set<string>,
triggerNodeIds: string[],
pausedNodeIds: string[]
): Set<string> {
if (pausedNodeIds.length === 0 || triggerNodeIds.length === 0) return new Set()
const pausedSet = new Set(pausedNodeIds)
const seeds = triggerNodeIds.filter((id) => pathNodeIds.has(id))
if (seeds.length === 0) return new Set()
const out = new Set<string>(seeds)
const frontier: string[] = [...seeds]
const visited = new Set<string>(seeds)
while (frontier.length > 0) {
const n = frontier.shift()!
if (pausedSet.has(n)) continue
for (const e of edges) {
if (e.source !== n || !pathNodeIds.has(e.target) || visited.has(e.target)) continue
visited.add(e.target)
out.add(e.target)
if (pausedSet.has(e.target)) continue
frontier.push(e.target)
}
}
return out
}

View File

@@ -153,6 +153,13 @@ body {
.react-flow__edge path.animated-edge-path.animated-edge-path--updating,
.animated-edge-path.animated-edge-path--updating {
stroke: hsl(217 91% 60%);
transition: none;
}
.react-flow__edge path.animated-edge-path.animated-edge-path--paused,
.animated-edge-path.animated-edge-path--paused {
stroke: hsl(45 98% 50%);
transition: none;
}
@keyframes edge-flow {