From e79821bd8157a4d002c5e240a660076ca98d7136 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 11 Mar 2026 23:05:01 +0100 Subject: [PATCH] feat: connections improvements --- frontend/src/app/canvas/CanvasPage.tsx | 56 ++++++++++++- frontend/src/components/base/AnimatedEdge.tsx | 9 ++- frontend/src/components/nodes/AgentNode.tsx | 39 ++++++++- frontend/src/lib/flowContext.tsx | 10 +++ frontend/src/lib/graphPath.ts | 80 ++++++++++++++++--- frontend/src/styles.css | 7 ++ 6 files changed, 180 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 216a0e6..5c8a826 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -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(null) const pathUpdateEndTimeoutRef = useRef | null>(null) + const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState([]) + const connectionPathPausedNodeIdsRef = useRef([]) + 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, ] diff --git a/frontend/src/components/base/AnimatedEdge.tsx b/frontend/src/components/base/AnimatedEdge.tsx index 665484b..e8bf602 100644 --- a/frontend/src/components/base/AnimatedEdge.tsx +++ b/frontend/src/components/base/AnimatedEdge.tsx @@ -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 && ( diff --git a/frontend/src/components/nodes/AgentNode.tsx b/frontend/src/components/nodes/AgentNode.tsx index b64b3e0..689940e 100644 --- a/frontend/src/components/nodes/AgentNode.tsx +++ b/frontend/src/components/nodes/AgentNode.tsx @@ -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 @@ -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(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 | 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) => 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 ( void /** All node ids on the path of an update. Edges with both endpoints in this set animate. */ connectionPathNodeIds: Set + /** Path nodes in the "paused" segment (from trigger up to Archon on hold). Those edges are yellow. */ + connectionPathPausedSegmentNodeIds: Set + /** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */ + connectionPathActiveSegmentNodeIds: Set + /** 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. */ diff --git a/frontend/src/lib/graphPath.ts b/frontend/src/lib/graphPath.ts index 6ebffe3..5ac4668 100644 --- a/frontend/src/lib/graphPath.ts +++ b/frontend/src/lib/graphPath.ts @@ -40,28 +40,84 @@ export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set { - 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() + 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() upstream.forEach((id) => { if (downstreamOfTrigger.has(id)) path.add(id) }) return path } - const downstream = getDownstreamNodeIds(edges, updatingNodeIds) - const path = new Set(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(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, + triggerNodeIds: string[], + pausedNodeIds: string[] +): Set { + 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(seeds) + const frontier: string[] = [...seeds] + const visited = new Set(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 } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 75af9a1..e6d234c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 {