From b642a7aab270ffa7522d7bf4fae87e6406b95ee5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 14 Mar 2026 07:52:34 +0100 Subject: [PATCH] feat: simplify paths --- frontend/package-lock.json | 80 +++++++ frontend/package.json | 6 +- frontend/src/app/canvas/canvasStore.index.ts | 1 - .../src/app/canvas/canvasStore.reducer.ts | 41 +--- .../src/app/canvas/canvasStore.selectors.ts | 62 +++--- frontend/src/app/canvas/canvasStore.test.ts | 113 +++++----- frontend/src/app/canvas/canvasStore.types.ts | 8 +- .../src/app/canvas/useCanvasConnectionPath.ts | 135 ++++-------- .../useCanvasConnectionPathFromStore.ts | 120 ++++------- .../src/components/graph/AnimatedEdge.tsx | 2 +- frontend/src/components/graph/BaseNode.tsx | 1 - .../components/nodes/render/RenderingNode.tsx | 24 +-- .../nodes/render/useRenderingNodeState.ts | 21 +- frontend/src/lib/graph/connectionStatus.ts | 21 +- frontend/src/lib/graph/flowContext.tsx | 8 +- frontend/src/lib/graph/graphPath.ts | 131 +----------- frontend/src/lib/graph/graphologyPath.ts | 201 ++++++++++++++++++ frontend/src/lib/graph/nodeLifecycle.ts | 72 ++----- 18 files changed, 541 insertions(+), 506 deletions(-) create mode 100644 frontend/src/lib/graph/graphologyPath.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e6d67a5..6c51ae4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,8 @@ "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "graphology": "^0.26.0", + "graphology-traversal": "^0.3.1", "lucide-react": "^0.577.0", "markdown-to-jsx": "^9.7.9", "marked": "^17.0.4", @@ -4845,6 +4847,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -5350,6 +5361,60 @@ "dev": true, "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-traversal": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/graphology-traversal/-/graphology-traversal-0.3.1.tgz", + "integrity": "sha512-lGLrLKEDKtNgAKgHVhVftKf3cb/nuWwuVPQZHXRnN90JWn0RSjco/s+NB2ARSlMapEMlbnPgv6j++427yTnU3Q==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.0.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/graphql": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", @@ -6163,6 +6228,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6449,6 +6523,12 @@ "node": ">= 10" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index eca1457..dbdad30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,6 @@ "test:run": "vitest run" }, "dependencies": { - "zustand": "^5.0.2", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -31,6 +30,8 @@ "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "graphology": "^0.26.0", + "graphology-traversal": "^0.3.1", "lucide-react": "^0.577.0", "markdown-to-jsx": "^9.7.9", "marked": "^17.0.4", @@ -44,7 +45,8 @@ "react-simple-code-editor": "^0.14.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.2" }, "devDependencies": { "@testing-library/react": "^16.0.0", diff --git a/frontend/src/app/canvas/canvasStore.index.ts b/frontend/src/app/canvas/canvasStore.index.ts index 3aee198..1eaf3d5 100644 --- a/frontend/src/app/canvas/canvasStore.index.ts +++ b/frontend/src/app/canvas/canvasStore.index.ts @@ -20,7 +20,6 @@ export { selectNodes, selectEdges, selectPathNodeIds, - selectPathPausedSegmentNodeIds, selectPathActiveSegmentNodeIds, selectConnectionStatusForEdge, selectPathRoleForNode, diff --git a/frontend/src/app/canvas/canvasStore.reducer.ts b/frontend/src/app/canvas/canvasStore.reducer.ts index 9b59fad..a649a74 100644 --- a/frontend/src/app/canvas/canvasStore.reducer.ts +++ b/frontend/src/app/canvas/canvasStore.reducer.ts @@ -14,10 +14,11 @@ const initialGraph: GraphSlice = { edges: [], } +const PULSE_MS = 1000 + const initialPath: PathSlice = { - updatingNodeIds: [], triggerNodeIds: [], - pausedNodeIds: [], + pulseEndsAt: null, errorNodeIds: [], } @@ -65,33 +66,14 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice { case 'path/addTrigger': { const id = cmd.payload if (prev.triggerNodeIds.includes(id)) return prev - return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] } + return { + ...prev, + triggerNodeIds: [...prev.triggerNodeIds, id], + pulseEndsAt: Date.now() + PULSE_MS, + } } case 'path/clearTriggers': - return { ...prev, triggerNodeIds: [] } - case 'path/startUpdate': { - const id = cmd.payload - if (prev.updatingNodeIds.includes(id)) return prev - return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] } - } - case 'path/endUpdate': { - const id = cmd.payload - return { - ...prev, - updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id), - } - } - case 'path/setPaused': { - const { nodeId, paused } = cmd.payload - const has = prev.pausedNodeIds.includes(nodeId) - if (paused === has) return prev - return { - ...prev, - pausedNodeIds: paused - ? [...prev.pausedNodeIds, nodeId] - : prev.pausedNodeIds.filter((x) => x !== nodeId), - } - } + return { ...prev, triggerNodeIds: [], pulseEndsAt: null } case 'path/setError': { const { nodeId, error } = cmd.payload const has = prev.errorNodeIds.includes(nodeId) @@ -104,10 +86,7 @@ function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice { } } case 'path/clearPathSession': - return { - ...initialPath, - errorNodeIds: prev.errorNodeIds, - } + return { ...initialPath, errorNodeIds: prev.errorNodeIds } case 'path/clearErrors': return { ...prev, errorNodeIds: [] } default: diff --git a/frontend/src/app/canvas/canvasStore.selectors.ts b/frontend/src/app/canvas/canvasStore.selectors.ts index 34625cb..58fd327 100644 --- a/frontend/src/app/canvas/canvasStore.selectors.ts +++ b/frontend/src/app/canvas/canvasStore.selectors.ts @@ -3,11 +3,16 @@ * Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here. */ -import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' +import { + getPathNodeIds, + getPathToUpdatingSegmentNodeIds, + type NodeAttributesMap, +} from '@/lib/graph/graphologyPath' import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus' import type { CanvasStore } from './canvasStore.types' +import type { AppNode } from '@/lib/graph/nodeTypes' -export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null +export type ConnectionPathRole = 'trigger' | 'on-path' | null // --------------------------------------------------------------------------- // Raw slices @@ -40,38 +45,44 @@ export function selectEdges(state: CanvasStore) { const emptySet = new Set() function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) { - return edges.map((e) => ({ source: e.source, target: e.target })) + return edges.map((e) => ({ source: e.source, target: e.target, id: e.id })) +} + +/** Build node attributes for graphology (nodeType, updateMode) from canvas nodes. */ +function buildNodeAttributesMap(nodes: AppNode[]): NodeAttributesMap { + const map: NodeAttributesMap = {} + for (const n of nodes) { + const data = n.data as { updateMode?: 'auto' | 'manual' } | undefined + map[n.id] = { + nodeType: n.type ?? undefined, + updateMode: data?.updateMode, + } + } + return map } export function selectPathNodeIds(state: CanvasStore): Set { - const { edges } = state.graph - const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path + const { nodes, edges } = state.graph + const { triggerNodeIds } = state.path return getPathNodeIds( edgesAsGraphEdges(edges), - updatingNodeIds, + [], triggerNodeIds, - pausedNodeIds - ) -} - -export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set { - const pathNodeIds = selectPathNodeIds(state) - const { edges } = state.graph - const { triggerNodeIds, pausedNodeIds } = state.path - return getPausedSegmentNodeIds( - edgesAsGraphEdges(edges), - pathNodeIds, - triggerNodeIds, - pausedNodeIds + undefined, + buildNodeAttributesMap(nodes) ) } export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set { - const pathNodeIds = selectPathNodeIds(state) - const pausedSegment = selectPathPausedSegmentNodeIds(state) - const active = new Set(pathNodeIds) - pausedSegment.forEach((id) => active.delete(id)) - return active + const { nodes, edges } = state.graph + const { triggerNodeIds, pulseEndsAt } = state.path + const pulseActive = pulseEndsAt != null + return getPathToUpdatingSegmentNodeIds( + edgesAsGraphEdges(edges), + triggerNodeIds, + pulseActive, + buildNodeAttributesMap(nodes) + ) } // --------------------------------------------------------------------------- @@ -84,14 +95,12 @@ export function selectConnectionStatusForEdge( target: string ): ConnectionStatus { const pathNodeIds = selectPathNodeIds(state) - const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state) const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state) const errorTargetNodeIds = new Set(state.path.errorNodeIds) return getConnectionStatus({ source, target, pathNodeIds, - pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, }) @@ -108,7 +117,6 @@ export function selectPathRoleForNode( const pathNodeIds = selectPathNodeIds(state) if (!pathNodeIds.has(nodeId)) return null if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger' - if (state.path.updatingNodeIds.includes(nodeId)) return 'updating' return 'on-path' } diff --git a/frontend/src/app/canvas/canvasStore.test.ts b/frontend/src/app/canvas/canvasStore.test.ts index 222aa03..ce4bf5e 100644 --- a/frontend/src/app/canvas/canvasStore.test.ts +++ b/frontend/src/app/canvas/canvasStore.test.ts @@ -7,7 +7,6 @@ import { } from './canvasStore' import { selectPathNodeIds, - selectPathPausedSegmentNodeIds, selectPathActiveSegmentNodeIds, selectConnectionStatusForEdge, selectPathRoleForNode, @@ -141,42 +140,15 @@ describe('canvasStoreReducer', () => { expect(next.path.triggerNodeIds).toEqual([]) }) - it('path/startUpdate adds to updatingNodeIds', () => { + it('path/addTrigger sets pulseEndsAt', () => { const state: CanvasStore = { ...initialCanvasStore } + const before = Date.now() const next = canvasStoreReducer(state, { - type: 'path/startUpdate', + type: 'path/addTrigger', payload: 'n1', }) - expect(next.path.updatingNodeIds).toEqual(['n1']) - }) - - it('path/endUpdate removes from updatingNodeIds', () => { - const state: CanvasStore = { - ...initialCanvasStore, - path: { - ...initialCanvasStore.path, - updatingNodeIds: ['n1', 'n2'], - }, - } - const next = canvasStoreReducer(state, { - type: 'path/endUpdate', - payload: 'n1', - }) - expect(next.path.updatingNodeIds).toEqual(['n2']) - }) - - it('path/setPaused adds and removes paused node', () => { - const state: CanvasStore = { ...initialCanvasStore } - let next = canvasStoreReducer(state, { - type: 'path/setPaused', - payload: { nodeId: 'n1', paused: true }, - }) - expect(next.path.pausedNodeIds).toEqual(['n1']) - next = canvasStoreReducer(next, { - type: 'path/setPaused', - payload: { nodeId: 'n1', paused: false }, - }) - expect(next.path.pausedNodeIds).toEqual([]) + expect(next.path.triggerNodeIds).toEqual(['n1']) + expect(next.path.pulseEndsAt).toBeGreaterThanOrEqual(before + 1000) }) it('path/setError adds and removes error node', () => { @@ -193,20 +165,18 @@ describe('canvasStoreReducer', () => { expect(next.path.errorNodeIds).toEqual([]) }) - it('path/clearPathSession resets updating, trigger, paused; keeps error', () => { + it('path/clearPathSession resets trigger and pulse; keeps error', () => { const state: CanvasStore = { ...initialCanvasStore, path: { - updatingNodeIds: ['u1'], triggerNodeIds: ['t1'], - pausedNodeIds: ['p1'], + pulseEndsAt: Date.now() + 1000, errorNodeIds: ['e1'], }, } const next = canvasStoreReducer(state, { type: 'path/clearPathSession' }) - expect(next.path.updatingNodeIds).toEqual([]) expect(next.path.triggerNodeIds).toEqual([]) - expect(next.path.pausedNodeIds).toEqual([]) + expect(next.path.pulseEndsAt).toBeNull() expect(next.path.errorNodeIds).toEqual(['e1']) }) }) @@ -280,9 +250,8 @@ describe('canvasStore selectors', () => { ], }, path: { - updatingNodeIds: ['c'], triggerNodeIds: ['a'], - pausedNodeIds: [], + pulseEndsAt: null, errorNodeIds: [], }, } @@ -311,15 +280,14 @@ describe('canvasStore selectors', () => { path: { ...initialCanvasStore.path, triggerNodeIds: ['a'], - updatingNodeIds: ['b'], - pausedNodeIds: [], + pulseEndsAt: null, errorNodeIds: ['b'], }, } expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error') }) - it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => { + it('selectPathRoleForNode returns trigger or on-path', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { @@ -329,13 +297,12 @@ describe('canvasStore selectors', () => { path: { ...initialCanvasStore.path, triggerNodeIds: ['a'], - updatingNodeIds: ['b'], - pausedNodeIds: [], + pulseEndsAt: null, errorNodeIds: [], }, } expect(selectPathRoleForNode(state, 'a')).toBe('trigger') - expect(selectPathRoleForNode(state, 'b')).toBe('updating') + expect(selectPathRoleForNode(state, 'b')).toBe('on-path') expect(selectPathRoleForNode(state, 'x')).toBe(null) }) @@ -378,14 +345,13 @@ describe('canvas store integration', () => { expect(state.graph.nodes[0].id).toBe('test-1') }) - it('dispatch path/addTrigger updates path and selectPathNodeIds', () => { + it('dispatch path/addTrigger updates path and starts pulse', () => { dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] }) dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] }) dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' }) - dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' }) const state = getCanvasStore() expect(state.path.triggerNodeIds).toContain('a') - expect(state.path.updatingNodeIds).toContain('b') + expect(state.path.pulseEndsAt).not.toBeNull() const pathIds = selectPathNodeIds(state) expect(pathIds.has('a')).toBe(true) expect(pathIds.has('b')).toBe(true) @@ -396,4 +362,53 @@ describe('canvas store integration', () => { const state = getCanvasStore() expect(state.ui.fullscreenNodeId).toBe('full-node') }) + + // Pulse: addTrigger starts a short "updating" pulse along downstream path. + it('path-per-sink: addTrigger starts pulse, all downstream edges show updating', () => { + const nodes: AppNode[] = [ + { + id: 'var_001', + type: 'variable', + position: { x: -270, y: 210 }, + data: { value: 'sss', valueType: 'string' }, + }, + { + id: 'cfg_001', + type: 'config', + position: { x: 180, y: 330 }, + data: { configType: 'plantuml', content: '@startuml\nactor Mulis\n@enduml\n', title: 'cfg_001' }, + }, + { + id: 'rnd_001', + type: 'render', + position: { x: 780, y: 540 }, + data: { updateMode: 'manual', runTrigger: 1, lastRunSourceSignature: 'sig1' }, + }, + { + id: 'rnd_002', + type: 'render', + position: { x: 780, y: 180 }, + data: { updateMode: 'auto', runTrigger: 2, lastRunSourceSignature: 'sig2' }, + }, + ] + const edges: AppEdge[] = [ + { id: 'xy-edge__var_001out-cfg_001ain', source: 'var_001', target: 'cfg_001', data: { targetType: 'config' } }, + { id: 'xy-edge__cfg_001out-rnd_001ain', source: 'cfg_001', target: 'rnd_001', data: { targetType: 'render' } }, + { id: 'xy-edge__cfg_001out-rnd_002ain', source: 'cfg_001', target: 'rnd_002', data: { targetType: 'render' } }, + ] + dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } }) + dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'var_001' }) + + const state = getCanvasStore() + const pathIds = selectPathNodeIds(state) + expect(pathIds.has('var_001')).toBe(true) + expect(pathIds.has('cfg_001')).toBe(true) + expect(pathIds.has('rnd_001')).toBe(true) + expect(pathIds.has('rnd_002')).toBe(true) + + // During pulse, all path edges show "updating". + expect(selectConnectionStatusForEdge(state, 'var_001', 'cfg_001')).toBe('updating') + expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_001')).toBe('updating') + expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_002')).toBe('updating') + }) }) diff --git a/frontend/src/app/canvas/canvasStore.types.ts b/frontend/src/app/canvas/canvasStore.types.ts index ccc458b..76fb437 100644 --- a/frontend/src/app/canvas/canvasStore.types.ts +++ b/frontend/src/app/canvas/canvasStore.types.ts @@ -18,10 +18,11 @@ export type GraphSlice = { // Path slice (primitive arrays; derived Sets are in selectors) // --------------------------------------------------------------------------- +/** Pulse: "updating" shows along path for a short time after a trigger; no node start/end. */ export type PathSlice = { - updatingNodeIds: string[] triggerNodeIds: string[] - pausedNodeIds: string[] + /** When non-null, path from triggers shows "updating" until this time (ms). Cleared by timer. */ + pulseEndsAt: number | null errorNodeIds: string[] } @@ -57,9 +58,6 @@ export type CanvasCommand = | { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } } | { type: 'path/addTrigger'; payload: string } | { type: 'path/clearTriggers' } - | { type: 'path/startUpdate'; payload: string } - | { type: 'path/endUpdate'; payload: string } - | { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } } | { type: 'path/setError'; payload: { nodeId: string; error: boolean } } | { type: 'path/clearPathSession' } | { type: 'path/clearErrors' } diff --git a/frontend/src/app/canvas/useCanvasConnectionPath.ts b/frontend/src/app/canvas/useCanvasConnectionPath.ts index 9e8cb8b..596e4cd 100644 --- a/frontend/src/app/canvas/useCanvasConnectionPath.ts +++ b/frontend/src/app/canvas/useCanvasConnectionPath.ts @@ -5,7 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' +import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath' /** Serialize set to a stable string for equality. */ function setToStableKey(s: Set): string { @@ -14,8 +14,7 @@ function setToStableKey(s: Set): string { export type EdgeLike = { source: string; target: string } -/** Short tail (ms) after last updating node ends so the path doesn't vanish instantly. */ -const CONNECTION_PATH_UPDATE_TAIL_MS = 200 +const PULSE_MS = 1500 export type UseCanvasConnectionPathResult = { connectionPathUpdatingNodeIds: string[] @@ -35,51 +34,13 @@ export type UseCanvasConnectionPathResult = { } export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult { - const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState([]) const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState([]) - const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState([]) + const [pulseEndsAt, setPulseEndsAt] = useState(null) const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState([]) - const pathUpdateNodeIdsRef = useRef>(new Set()) - const pathUpdateEndTimeoutRef = useRef | null>(null) - const connectionPathPausedNodeIdsRef = useRef([]) - connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds - const pathTriggerBatchRef = useRef>(new Set()) const pathTriggerScheduledRef = useRef(false) - - const clearPathUpdateSession = useCallback(() => { - setConnectionPathUpdatingNodeIds([]) - if (connectionPathPausedNodeIdsRef.current.length === 0) { - setConnectionPathTriggerNodeIds([]) - setConnectionPathPausedNodeIds([]) - } - }, []) - - const startConnectionPathUpdate = useCallback((nodeId: string) => { - const ref = pathUpdateNodeIdsRef.current - ref.add(nodeId) - if (ref.size === 1) { - 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 - } - pathUpdateEndTimeoutRef.current = setTimeout(() => { - pathUpdateEndTimeoutRef.current = null - clearPathUpdateSession() - }, CONNECTION_PATH_UPDATE_TAIL_MS) - }, [clearPathUpdateSession]) + const pulseTimerRef = useRef | null>(null) const addConnectionPathTrigger = useCallback((nodeId: string) => { pathTriggerBatchRef.current.add(nodeId) @@ -95,27 +56,31 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP 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 (pathUpdateEndTimeoutRef.current != null) { - clearTimeout(pathUpdateEndTimeoutRef.current) + 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, - connectionPathUpdatingNodeIds, - connectionPathTriggerNodeIds, - connectionPathPausedNodeIds - ), - [edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] + getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined), + [edges, connectionPathTriggerNodeIds] ) const connectionPathNodeIdsRef = useRef>(connectionPathNodeIdsRaw) const connectionPathNodeIdsKeyRef = useRef('') @@ -128,37 +93,19 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP return connectionPathNodeIdsRaw })() - const connectionPathPausedSegmentNodeIdsRaw = useMemo( - () => - getPausedSegmentNodeIds( - edges, - connectionPathNodeIds, - connectionPathTriggerNodeIds, - connectionPathPausedNodeIds - ), - [edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds] - ) - const connectionPathPausedSegmentNodeIdsRef = useRef>( - connectionPathPausedSegmentNodeIdsRaw - ) - const connectionPathPausedSegmentNodeIdsKeyRef = useRef('') - const connectionPathPausedSegmentNodeIds = - setToStableKey(connectionPathPausedSegmentNodeIdsRaw) === - connectionPathPausedSegmentNodeIdsKeyRef.current - ? connectionPathPausedSegmentNodeIdsRef.current - : (() => { - connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey( - connectionPathPausedSegmentNodeIdsRaw - ) - connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw - return connectionPathPausedSegmentNodeIdsRaw - })() + const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set(), []) + const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT - const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => { - const active = new Set(connectionPathNodeIds) - connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) - return active - }, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds]) + const pulseActive = pulseEndsAt != null + const connectionPathActiveSegmentNodeIdsRaw = useMemo( + () => + getPathToUpdatingSegmentNodeIds( + edges, + connectionPathTriggerNodeIds, + pulseActive + ), + [edges, connectionPathTriggerNodeIds, pulseActive] + ) const connectionPathActiveSegmentNodeIdsRef = useRef>( connectionPathActiveSegmentNodeIdsRaw ) @@ -175,13 +122,11 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP return connectionPathActiveSegmentNodeIdsRaw })() - const addConnectionPathPausedNode = useCallback((nodeId: string) => { - setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) - }, []) + const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) + const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) - const removeConnectionPathPausedNode = useCallback((nodeId: string) => { - setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId)) - }, []) + const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, []) + const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, []) const addConnectionPathError = useCallback((nodeId: string) => { setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId])) @@ -192,9 +137,9 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP }, []) return { - connectionPathUpdatingNodeIds, + connectionPathUpdatingNodeIds: [], connectionPathTriggerNodeIds, - connectionPathPausedNodeIds, + connectionPathPausedNodeIds: [], connectionPathErrorNodeIds, connectionPathNodeIds, connectionPathPausedSegmentNodeIds, diff --git a/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts b/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts index d252c70..a96a921 100644 --- a/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts +++ b/frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts @@ -1,10 +1,7 @@ /** * Connection-path state and callbacks backed by the canvas store. - * Replaces useCanvasConnectionPath when the store is the source of truth for path. - * Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges. - * - * Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo - * so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops. + * "Updating" is time-bound: when a trigger is added, the path pulses for a short time + * and then clears. No node start/end reporting. */ import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -16,10 +13,10 @@ import { selectPathRoleForNode, type ConnectionPathRole, } from '@/app/canvas/canvasStore.selectors' -import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath' +import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphologyPath' import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath' -const CONNECTION_PATH_UPDATE_TAIL_MS = 200 +const EMPTY_PAUSED_SEGMENT = new Set() function edgesAsGraphEdges( edges: Array<{ source: string; target: string }> @@ -30,66 +27,63 @@ function edgesAsGraphEdges( export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult { const path = useCanvasStore((s) => s.path) const edges = useCanvasStore((s) => s.graph.edges) + const pulseEndsAtRef = useRef(null) + const pulseTimerRef = useRef | null>(null) const pathNodeIds = useMemo( () => getPathNodeIds( edgesAsGraphEdges(edges), - path.updatingNodeIds, + [], path.triggerNodeIds, - path.pausedNodeIds + undefined ), - [ - edges, - path.updatingNodeIds, - path.triggerNodeIds, - path.pausedNodeIds, - ] + [edges, path.triggerNodeIds] ) - const connectionPathPausedSegmentNodeIds = useMemo( + const pulseActive = path.pulseEndsAt != null + const connectionPathActiveSegmentNodeIds = useMemo( () => - getPausedSegmentNodeIds( + getPathToUpdatingSegmentNodeIds( edgesAsGraphEdges(edges), - pathNodeIds, path.triggerNodeIds, - path.pausedNodeIds + pulseActive ), - [edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds] + [edges, path.triggerNodeIds, pulseActive] ) - const connectionPathActiveSegmentNodeIds = useMemo(() => { - const active = new Set(pathNodeIds) - connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id)) - return active - }, [pathNodeIds, connectionPathPausedSegmentNodeIds]) - const pathUpdateEndTimeoutRef = useRef | null>(null) - const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length) - - const startConnectionPathUpdate = useCallback((nodeId: string) => { - dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId }) - }, []) - - const endConnectionPathUpdate = useCallback((nodeId: string) => { - dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId }) - }, []) + useEffect(() => { + const endsAt = path.pulseEndsAt + if (endsAt == null) { + if (pulseTimerRef.current != null) { + clearTimeout(pulseTimerRef.current) + pulseTimerRef.current = null + } + pulseEndsAtRef.current = null + return + } + if (endsAt === pulseEndsAtRef.current) return + pulseEndsAtRef.current = endsAt + const delay = Math.max(0, endsAt - Date.now()) + if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current) + pulseTimerRef.current = setTimeout(() => { + pulseTimerRef.current = null + pulseEndsAtRef.current = null + dispatchCanvasCommand({ type: 'path/clearPathSession' }) + }, delay) + return () => { + if (pulseTimerRef.current != null) { + clearTimeout(pulseTimerRef.current) + pulseTimerRef.current = null + } + } + }, [path.pulseEndsAt]) const addConnectionPathTrigger = useCallback((nodeId: string) => { dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId }) }, []) - const addConnectionPathPausedNode = useCallback((nodeId: string) => { - dispatchCanvasCommand({ - type: 'path/setPaused', - payload: { nodeId, paused: true }, - }) - }, []) - - const removeConnectionPathPausedNode = useCallback((nodeId: string) => { - dispatchCanvasCommand({ - type: 'path/setPaused', - payload: { nodeId, paused: false }, - }) - }, []) + const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) + const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, []) const addConnectionPathError = useCallback((nodeId: string) => { dispatchCanvasCommand({ @@ -105,36 +99,16 @@ export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResul }) }, []) - useEffect(() => { - const prev = prevUpdatingLengthRef.current - const now = path.updatingNodeIds.length - prevUpdatingLengthRef.current = now - if (prev > 0 && now === 0) { - if (pathUpdateEndTimeoutRef.current != null) { - clearTimeout(pathUpdateEndTimeoutRef.current) - } - pathUpdateEndTimeoutRef.current = setTimeout(() => { - pathUpdateEndTimeoutRef.current = null - dispatchCanvasCommand({ type: 'path/clearPathSession' }) - }, CONNECTION_PATH_UPDATE_TAIL_MS) - } - return () => { - if (pathUpdateEndTimeoutRef.current != null) { - clearTimeout(pathUpdateEndTimeoutRef.current) - } - } - }, [path.updatingNodeIds.length]) - return { - connectionPathUpdatingNodeIds: path.updatingNodeIds, + connectionPathUpdatingNodeIds: [], connectionPathTriggerNodeIds: path.triggerNodeIds, - connectionPathPausedNodeIds: path.pausedNodeIds, + connectionPathPausedNodeIds: [], connectionPathErrorNodeIds: path.errorNodeIds, connectionPathNodeIds: pathNodeIds, - connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds, + connectionPathPausedSegmentNodeIds: EMPTY_PAUSED_SEGMENT, connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds, - startConnectionPathUpdate, - endConnectionPathUpdate, + startConnectionPathUpdate: () => {}, + endConnectionPathUpdate: () => {}, addConnectionPathTrigger, addConnectionPathPausedNode, removeConnectionPathPausedNode, diff --git a/frontend/src/components/graph/AnimatedEdge.tsx b/frontend/src/components/graph/AnimatedEdge.tsx index 65ceaf5..18c7898 100644 --- a/frontend/src/components/graph/AnimatedEdge.tsx +++ b/frontend/src/components/graph/AnimatedEdge.tsx @@ -120,7 +120,6 @@ function AnimatedEdgeInner({ ) } - function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean { return ( prev.id === next.id && @@ -142,3 +141,4 @@ function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean { } export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual) + diff --git a/frontend/src/components/graph/BaseNode.tsx b/frontend/src/components/graph/BaseNode.tsx index 2cf5f43..be3fe3c 100644 --- a/frontend/src/components/graph/BaseNode.tsx +++ b/frontend/src/components/graph/BaseNode.tsx @@ -69,7 +69,6 @@ export function BaseNode({ "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)]", connectionPathRole === "trigger" && "connection-path-trigger", - connectionPathRole === "updating" && "connection-path-updating", connectionPathRole === "on-path" && "connection-path-on-path", className, )} diff --git a/frontend/src/components/nodes/render/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx index 7c5d52d..f03c95e 100644 --- a/frontend/src/components/nodes/render/RenderingNode.tsx +++ b/frontend/src/components/nodes/render/RenderingNode.tsx @@ -33,7 +33,6 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' import { useTheme } from '@/lib/themeContext' import { useRenderingNodeState, @@ -170,13 +169,11 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { type="button" size="sm" variant="outline" - disabled={state.loading} - className={cn( - 'h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs', - state.effectiveUpdateMode === 'manual' && - state.hasPendingInputs && - 'border-l-2 border-l-amber-500 bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20' - )} + disabled={ + state.loading || + (state.effectiveUpdateMode === 'manual' && !state.hasPendingInputs) + } + className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs" onClick={(e) => { e.stopPropagation() state.incrementRunTrigger() @@ -184,7 +181,9 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { title={ state.effectiveUpdateMode === 'manual' && state.hasPendingInputs ? 'Inputs changed — click to render' - : undefined + : state.effectiveUpdateMode === 'manual' && !state.hasPendingInputs + ? 'No new data to render' + : undefined } > {state.loading ? ( @@ -200,12 +199,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { type="button" size="sm" variant="outline" - className={cn( - 'h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal', - state.effectiveUpdateMode === 'manual' && - state.hasPendingInputs && - 'bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20' - )} + className="h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal" aria-label="Update mode" onClick={(e) => e.stopPropagation()} > diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index a75561c..1b61bbe 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -39,11 +39,10 @@ const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_HEIGHT = 800 const RENDER_DEBOUNCE_MS = 250 -/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/paused/error) stays in sync. */ +/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */ export type RenderingNodeLifecycle = { updating: boolean error: boolean - paused: boolean } export type RenderingNodeState = { @@ -102,14 +101,21 @@ export function useRenderingNodeState( data: RenderingNodeData | undefined ): RenderingNodeState { const { - nodes, - edges, + nodes: contextNodes, + edges: contextEdges, setNodes, setEdges, sourceIds: incomingIds, updateData, } = useAbstractNode(id, data ?? {}) + // Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data). + // Context only exposes a ref, so we wouldn't re-render when another node updates otherwise. + const storeNodes = useCanvasStore((s) => s.graph.nodes) + const storeEdges = useCanvasStore((s) => s.graph.edges) + const nodes = storeNodes.length > 0 ? storeNodes : contextNodes + const edges = storeEdges.length > 0 ? storeEdges : contextEdges + const nodesEdgesRef = useRef({ nodes, edges }) nodesEdgesRef.current = { nodes, edges } @@ -189,8 +195,6 @@ export function useRenderingNodeState( const lastManualRunTriggerRef = useRef(0) const manualRunTriggerSyncedRef = useRef(false) - const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds) - const updateDataRef = useRef(updateData) updateDataRef.current = updateData const setNodesRef = useRef(setNodes) @@ -200,13 +204,12 @@ export function useRenderingNodeState( const hasPendingInputs = effectiveUpdateMode === 'manual' && !loading && - triggerNodeIds.length > 0 && incomingIds.length > 0 && sourceSignature !== lastRunSourceSignature const lifecycle = useMemo( - () => ({ updating: loading, error: error != null, paused: hasPendingInputs }), - [loading, error, hasPendingInputs] + () => ({ updating: loading, error: error != null }), + [loading, error] ) useSyncConnectionStatus(id, lifecycle) diff --git a/frontend/src/lib/graph/connectionStatus.ts b/frontend/src/lib/graph/connectionStatus.ts index d5555d4..324f91c 100644 --- a/frontend/src/lib/graph/connectionStatus.ts +++ b/frontend/src/lib/graph/connectionStatus.ts @@ -1,36 +1,24 @@ /** * Connection status: visual state of an edge (color/class). - * Priority when multiple apply: error > paused > updating > default. - * - * State flow: nodes report lifecycle via useSyncConnectionStatus() (nodeLifecycle.ts) → FlowContext - * holds the sets → edges pass those sets into getConnectionStatus() here. See lib/graph/state.ts. + * Priority: error > updating > default. */ -export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error' +export type ConnectionStatus = 'default' | 'updating' | 'error' export type ConnectionStatusInputs = { source: string target: string pathNodeIds: Set - pausedSegmentNodeIds: Set activeSegmentNodeIds: Set errorTargetNodeIds: Set } /** - * Compute the single connection status for an edge (priority: error > paused > updating > default). - * Use in edge components; add new statuses by extending the type and adding a branch here. + * Compute the single connection status for an edge (priority: error > updating > default). */ export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus { - const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs + const { target, pathNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs if (errorTargetNodeIds.has(target)) return 'error' - if ( - pathNodeIds.has(source) && - pathNodeIds.has(target) && - pausedSegmentNodeIds.has(source) && - pausedSegmentNodeIds.has(target) - ) - return 'paused' if ( pathNodeIds.has(source) && pathNodeIds.has(target) && @@ -45,6 +33,5 @@ export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionS export const CONNECTION_STATUS_CLASS: Record = { default: '', updating: 'animated-edge-path--updating', - paused: 'animated-edge-path--paused', error: 'animated-edge-path--error', } diff --git a/frontend/src/lib/graph/flowContext.tsx b/frontend/src/lib/graph/flowContext.tsx index 63b7d74..e71dba8 100644 --- a/frontend/src/lib/graph/flowContext.tsx +++ b/frontend/src/lib/graph/flowContext.tsx @@ -16,8 +16,8 @@ import type { AppNode, AppEdge } from './nodeTypes' export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null -/** Role of a node in the current connection path update. */ -export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' +/** Role of a node in the current connection path. */ +export type ConnectionPathRole = 'trigger' | 'on-path' export type FlowActions = { pasteAtViewportCenter: () => void @@ -108,11 +108,9 @@ export function useConnectionPathRole(nodeId: string | undefined): ConnectionPat 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]) + }, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathNodeIds]) } diff --git a/frontend/src/lib/graph/graphPath.ts b/frontend/src/lib/graph/graphPath.ts index 3d896e8..beeb2fa 100644 --- a/frontend/src/lib/graph/graphPath.ts +++ b/frontend/src/lib/graph/graphPath.ts @@ -1,123 +1,14 @@ /** - * 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). + * @deprecated Use graphologyPath.ts instead. Path derivation now uses graphology + * (DirectedGraph + BFS) for traversal. This file re-exports from graphologyPath + * for backward compatibility and will be removed in a future version. + * + * @see graphologyPath.ts */ -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 { - const out = new Set(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 { - const out = new Set(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 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[], - pausedNodeIds?: string[] -): Set { - 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 - } - - 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 (e.g. Renderer waiting for Run). - * 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 -} +export type { GraphEdge } from './graphologyPath' +export { + getPathNodeIds, + getPathToUpdatingSegmentNodeIds, + clearGraphologyPathCache, +} from './graphologyPath' diff --git a/frontend/src/lib/graph/graphologyPath.ts b/frontend/src/lib/graph/graphologyPath.ts new file mode 100644 index 0000000..34a5012 --- /dev/null +++ b/frontend/src/lib/graph/graphologyPath.ts @@ -0,0 +1,201 @@ +/** + * Path derivation for connection status using graphology. + * Builds a DirectedGraph from canvas edges (with optional node attributes), + * uses BFS (outbound/inbound) to compute path sets for propagation animation. + * + * @see https://graphology.github.io/ + * @see https://graphology.github.io/standard-library/traversal.html + */ + +import DirectedGraph from 'graphology' +import { bfsFromNode } from 'graphology-traversal' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type GraphEdge = { source: string; target: string; id?: string } + +/** Optional attributes stored on graphology nodes (e.g. for sink detection). */ +export type GraphologyNodeAttributes = { + nodeType?: string + updateMode?: 'auto' | 'manual' +} + +/** Optional: pass when building so graph has node type/updateMode for future use. */ +export type NodeAttributesMap = Record + +// --------------------------------------------------------------------------- +// Graph build (with cache) +// --------------------------------------------------------------------------- + +let cachedEdgesRef: GraphEdge[] | null = null +let cachedGraph: DirectedGraph | null = + null + +function buildGraph( + edges: GraphEdge[], + nodeAttributes?: NodeAttributesMap +): DirectedGraph { + if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph + + const graph = new DirectedGraph() + const nodeIds = new Set() + for (const e of edges) { + nodeIds.add(e.source) + nodeIds.add(e.target) + } + for (const id of nodeIds) { + const attrs: GraphologyNodeAttributes = { ...nodeAttributes?.[id] } + graph.mergeNode(id, attrs) + } + for (const e of edges) { + const key = e.id ?? `${e.source}->${e.target}` + if (!graph.hasEdge(e.source, e.target)) { + graph.addEdgeWithKey(key, e.source, e.target, { + source: e.source, + target: e.target, + edgeId: e.id, + }) + } + } + cachedEdgesRef = edges + cachedGraph = graph + return graph +} + +/** Call when graph structure changes from outside (e.g. store reset) to clear cache. */ +export function clearGraphologyPathCache(): void { + cachedEdgesRef = null + cachedGraph = null +} + +// --------------------------------------------------------------------------- +// Traversal helpers (BFS via graphology-traversal) +// --------------------------------------------------------------------------- + +/** Outbound BFS: collect all node ids reachable from seeds following edges forward (source → target). */ +function getDownstreamNodeIds( + graph: DirectedGraph, + seedIds: string[] +): Set { + const out = new Set() + for (const id of seedIds) { + if (!graph.hasNode(id)) continue + bfsFromNode( + graph, + id, + (node) => { + out.add(node) + return false + }, + { mode: 'outbound' } + ) + } + return out +} + +/** Inbound BFS: collect all node ids that can reach any seed (following edges backward). */ +function getUpstreamNodeIds( + graph: DirectedGraph, + seedIds: string[] +): Set { + const out = new Set() + for (const id of seedIds) { + if (!graph.hasNode(id)) continue + bfsFromNode( + graph, + id, + (node) => { + out.add(node) + return false + }, + { mode: 'inbound' } + ) + } + return out +} + +// --------------------------------------------------------------------------- +// Path-to-updating / path-to-paused (same semantics as graphPath.ts) +// --------------------------------------------------------------------------- + +function getPathToUpdatingNodeIds( + graph: DirectedGraph, + triggerNodeIds: string[], + updatingNodeIds: string[] +): Set { + const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds) + const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds) + const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds) + const path = new Set() + downstreamOfTrigger.forEach((id) => { + if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id) + }) + return path +} + +function getPathToPausedNodeIds( + graph: DirectedGraph, + triggerNodeIds: string[], + pausedNodeIds: string[] +): Set { + const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds) + const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds) + const path = new Set() + upstreamOfPaused.forEach((id) => { + if (downstreamOfTrigger.has(id)) path.add(id) + }) + return path +} + +// --------------------------------------------------------------------------- +// Public API (same signatures as graphPath.ts for drop-in replacement) +// --------------------------------------------------------------------------- + +/** + * All node ids on the path of an update (downstream of trigger, or path to updating nodes). + */ +export function getPathNodeIds( + edges: GraphEdge[], + updatingNodeIds: string[], + triggerNodeIds?: string[], + _pausedNodeIds?: string[], + nodeAttributes?: NodeAttributesMap +): Set { + const hasUpdating = updatingNodeIds.length > 0 + const hasTrigger = triggerNodeIds != null && triggerNodeIds.length > 0 + const graph = buildGraph(edges, nodeAttributes) + + if (hasTrigger && hasUpdating) { + return getPathToUpdatingNodeIds(graph, triggerNodeIds!, updatingNodeIds) + } + if (hasUpdating) { + const upstream = getUpstreamNodeIds(graph, updatingNodeIds) + const downstream = getDownstreamNodeIds(graph, updatingNodeIds) + const path = new Set(upstream) + downstream.forEach((id) => path.add(id)) + return path + } + + if (hasTrigger) { + return getDownstreamNodeIds(graph, triggerNodeIds!) + } + + return new Set() +} + +/** + * Path nodes that show "updating" during the time-bound pulse. + * When pulseActive is true, returns downstream(trigger); otherwise empty. + */ +export function getPathToUpdatingSegmentNodeIds( + edges: GraphEdge[], + triggerNodeIds: string[], + pulseActive: boolean, + nodeAttributes?: NodeAttributesMap +): Set { + if (!pulseActive || triggerNodeIds.length === 0) return new Set() + const graph = buildGraph(edges, nodeAttributes) + return getDownstreamNodeIds(graph, triggerNodeIds) +} diff --git a/frontend/src/lib/graph/nodeLifecycle.ts b/frontend/src/lib/graph/nodeLifecycle.ts index 1ed0714..1252e06 100644 --- a/frontend/src/lib/graph/nodeLifecycle.ts +++ b/frontend/src/lib/graph/nodeLifecycle.ts @@ -1,85 +1,47 @@ /** - * Node lifecycle: contract that nodes implement so the graph can show the right - * connection status (edge colors) and path animation. + * Node lifecycle: nodes report error state so the graph can show the right + * connection status (edge colors). "Updating" is time-bound from triggers, not per-node. * - * ## State flow - * - * Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state). - * This hook dispatches to the canvas store; edges read path state via selectors. - * - * ## Lifecycle phases (conceptual) - * - * - **Idle** – Node is not on an active path; edges use default style. - * - **Trigger** – Node's output changed; path is computed from triggers + updating nodes. - * - **Updating** – Node is doing async work. Report `updating: true` → false. Path edges show blue. - * - **Paused** – Node is on hold (e.g. manual mode waiting for Run). Report `paused: true` → false. Segment shows yellow. - * - **Error** – Node has an error. Report `error: true` → false. Incoming edges show red. - * - * Priority for edge status: error > paused > updating > default. + * Priority for edge status: error > updating (pulse) > default. */ - import { useEffect, useRef } from 'react' import { dispatchCanvasCommand } from '@/app/canvas/canvasStore' -export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error' +export type NodeLifecyclePhase = 'idle' | 'trigger' | 'on-path' | 'error' /** * State that drives connection status for this node. - * Pass the current values from your node; the hook syncs them to the canvas store. + * Only error is synced to the path; updating is shown via time-bound pulse from triggers. */ export type NodeConnectionStatusState = { - /** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */ + /** Ignored for path; kept for API compatibility (e.g. loading spinner). */ updating?: boolean /** Node has an error. Incoming edges to this node show red. */ error?: boolean - /** Node is on hold (e.g. agent waiting for Run). Path edges in paused segment show yellow. */ - paused?: boolean } /** - * Syncs this node's lifecycle state to the canvas store so connection status (edge colors) - * and path animation are correct. Call once per node with the current updating/error/paused - * state; the hook will dispatch path commands to add/remove this node from the appropriate sets. - * - * Use in any node that can be updating, in error, or paused: - * - * const [loading, setLoading] = useState(false) - * const [error, setError] = useState(null) - * const hasPendingInputs = ... - * useSyncConnectionStatus(id, { updating: loading, error: !!error, paused: hasPendingInputs }) + * Syncs this node's error state to the canvas store so connection status (edge colors) + * is correct. Call once per node: useSyncConnectionStatus(id, { error: !!error }). */ export function useSyncConnectionStatus( nodeId: string, state: NodeConnectionStatusState ): void { - const { updating, error, paused } = state - const prevRef = useRef({ updating: false, error: false, paused: false }) + const { error } = state + const prevRef = useRef(false) useEffect(() => { - const prev = prevRef.current - const nowUpdating = Boolean(updating) const nowError = Boolean(error) - const nowPaused = Boolean(paused) - - if (prev.updating !== nowUpdating) { - if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId }) - else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId }) - prev.updating = nowUpdating - } - if (prev.error !== nowError) { + if (prevRef.current !== nowError) { dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } }) - prev.error = nowError + prevRef.current = nowError } - if (prev.paused !== nowPaused) { - dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } }) - prev.paused = nowPaused - } - return () => { - if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId }) - if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } }) - if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } }) - prevRef.current = { updating: false, error: false, paused: false } + if (prevRef.current) { + dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } }) + prevRef.current = false + } } - }, [nodeId, updating, error, paused]) + }, [nodeId, error]) }