Skip to content

Commit 416371c

Browse files
cristipufuclaude
andcommitted
feat: use qualified_node_name for precise subgraph node highlighting
- Pass qualified_node_name through StateData → serializer → WebSocket - Frontend uses qualified_node_name (e.g. "coder:model" → "coder/model") to match exact React Flow node IDs, avoiding ambiguity when multiple subgraphs contain nodes with the same name - Replace trace-based node status with state-event-based status coloring - Add __start__/__end__ node handling: __start__ turns green once execution begins, __end__ turns green/red on run completion - Scope subgraph __start__/__end__ to only highlight when their subgraph was actually visited - Fix status not persisting after page refresh (layoutSeq trigger) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e424f9b commit 416371c

10 files changed

Lines changed: 206 additions & 120 deletions

File tree

src/uipath/dev/models/data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class StateData:
3838

3939
run_id: str
4040
node_name: str
41+
qualified_node_name: str | None = None
4142
payload: dict[str, Any] | None = None
4243
timestamp: datetime = field(default_factory=datetime.now)
4344

src/uipath/dev/server/frontend/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export default function App() {
8888
selectedRunId,
8989
detail.states.map((s) => ({
9090
node_name: s.node_name,
91+
qualified_node_name: s.qualified_node_name,
9192
timestamp: new Date(s.timestamp).getTime(),
9293
payload: s.payload,
9394
})),

src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx

Lines changed: 137 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -346,18 +346,20 @@ interface Props {
346346
fitViewTrigger?: number;
347347
}
348348

349-
export default function GraphPanel({ entrypoint, traces, runId, breakpointNode, onBreakpointChange, fitViewTrigger }: Props) {
349+
export default function GraphPanel({ entrypoint, runId, breakpointNode, onBreakpointChange, fitViewTrigger }: Props) {
350350
const [nodes, setNodes, onNodesChange] = useNodesState([]);
351351
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
352352
const [loading, setLoading] = useState(true);
353353
const [graphUnavailable, setGraphUnavailable] = useState(false);
354+
const [layoutSeq, setLayoutSeq] = useState(0);
354355
const layoutRef = useRef(0);
355356
const rfInstance = useRef<ReactFlowInstance | null>(null);
356357

357358
const bpMap = useRunStore((s) => s.breakpoints[runId]);
358359
const toggleBreakpoint = useRunStore((s) => s.toggleBreakpoint);
359360
const clearBreakpoints = useRunStore((s) => s.clearBreakpoints);
360361
const activeNode = useRunStore((s) => s.activeNodes[runId]);
362+
const runStatus = useRunStore((s) => s.runs[runId]?.status);
361363

362364
const onNodeClick = useCallback(
363365
(_: React.MouseEvent, node: Node) => {
@@ -429,32 +431,62 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
429431
// Highlight edges + nodes during execution
430432
// - Paused at breakpoint (before node X): edges INTO X, node X (via isPausedHere)
431433
// - Running (state event after node Y completes): edges OUT of Y, target nodes of those edges
434+
// - __start__: highlighted on first state event; __end__: highlighted when run completes
432435
useEffect(() => {
433436
const isPaused = !!breakpointNode;
434-
let matchIds = new Set<string>();
435-
const activeTargetIds = new Set<string>();
437+
let matchIds = new Set<string>(); // Full React Flow node IDs
438+
const activeTargetIds = new Set<string>(); // Full React Flow node IDs
439+
const nodeTypeById = new Map<string, string>();
436440

437-
// 1) Build label→ID lookup (read-only pass, returns nds unchanged)
441+
// 1) Build matchIds + node type map (read-only pass, returns nds unchanged)
438442
setNodes((nds) => {
439-
const labelToIds = new Map<string, Set<string>>();
440443
for (const n of nds) {
441-
const label = n.data?.label as string | undefined;
442-
if (!label) continue;
443-
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
444-
for (const key of [plainId, label]) {
445-
let s = labelToIds.get(key);
446-
if (!s) { s = new Set(); labelToIds.set(key, s); }
447-
s.add(plainId);
448-
}
444+
if (n.type) nodeTypeById.set(n.id, n.type);
449445
}
450446

451447
if (isPaused && breakpointNode) {
452448
const bpNames = breakpointNode.split(",").map((s) => s.trim()).filter(Boolean);
453-
for (const name of bpNames) {
454-
(labelToIds.get(name) ?? new Set()).forEach((id) => matchIds.add(id));
449+
for (const n of nds) {
450+
if (n.type === "groupNode") continue;
451+
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
452+
const label = n.data?.label as string | undefined;
453+
if (bpNames.includes(plainId) || (label != null && bpNames.includes(label))) {
454+
matchIds.add(n.id);
455+
}
455456
}
456457
} else if (activeNode) {
457-
matchIds = labelToIds.get(activeNode.current) ?? new Set<string>();
458+
// Try qualified name first (exact match via "subgraph:node" → "subgraph/node")
459+
const qualifiedName = activeNode.qualifiedNodeName;
460+
if (qualifiedName) {
461+
const qualifiedId = qualifiedName.replace(/:/g, "/");
462+
for (const n of nds) {
463+
if (n.id === qualifiedId) {
464+
matchIds.add(n.id);
465+
}
466+
}
467+
}
468+
// Fallback: label/plainId matching
469+
if (matchIds.size === 0) {
470+
const labelToIds = new Map<string, Set<string>>();
471+
for (const n of nds) {
472+
const label = n.data?.label as string | undefined;
473+
if (!label) continue;
474+
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
475+
for (const key of [plainId, label]) {
476+
let s = labelToIds.get(key);
477+
if (!s) { s = new Set(); labelToIds.set(key, s); }
478+
s.add(n.id);
479+
}
480+
}
481+
matchIds = labelToIds.get(activeNode.current) ?? new Set<string>();
482+
}
483+
484+
// __start__: include top-level on first event so its outgoing edges highlight
485+
if (activeNode.prev === null) {
486+
for (const n of nds) {
487+
if (n.type === "startNode" && !n.parentNode) matchIds.add(n.id);
488+
}
489+
}
458490
}
459491

460492
return nds;
@@ -463,15 +495,20 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
463495
// 2) Highlight edges + collect target IDs for running mode
464496
setEdges((eds) =>
465497
eds.map((e) => {
466-
const srcPlain = e.source.includes("/") ? e.source.split("/").pop()! : e.source;
467-
const tgtPlain = e.target.includes("/") ? e.target.split("/").pop()! : e.target;
468-
469-
const isActive = isPaused
470-
? matchIds.has(tgtPlain) // breakpoint: edges INTO paused node
471-
: matchIds.has(srcPlain); // running: edges OUT of completed node
498+
let isActive: boolean;
499+
if (isPaused) {
500+
isActive = matchIds.has(e.target);
501+
} else {
502+
// Running: edges OUT of completed node
503+
isActive = matchIds.has(e.source);
504+
// For __end__: also highlight edges INTO it
505+
if (!isActive && nodeTypeById.get(e.target) === "endNode" && matchIds.has(e.target)) {
506+
isActive = true;
507+
}
508+
}
472509

473510
if (isActive) {
474-
if (!isPaused) activeTargetIds.add(tgtPlain);
511+
if (!isPaused) activeTargetIds.add(e.target);
475512
return {
476513
...e,
477514
style: { stroke: "var(--accent)", strokeWidth: 2.5 },
@@ -495,33 +532,21 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
495532
}),
496533
);
497534

498-
// 3) Mark target nodes as active (running mode only; breakpoint uses isPausedHere)
535+
// 3) Mark target nodes as active
536+
// Also highlight __start__/__end__ themselves when they are the matched node
499537
setNodes((nds) =>
500538
nds.map((n) => {
501539
if (n.type === "groupNode") return n;
502-
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
503-
const active = activeTargetIds.has(plainId);
540+
const isStartOrEnd = n.type === "startNode" || n.type === "endNode";
541+
const active = activeTargetIds.has(n.id) || (!isPaused && isStartOrEnd && matchIds.has(n.id));
504542
return active !== !!n.data?.isActiveNode
505543
? { ...n, data: { ...n.data, isActiveNode: active } }
506544
: n;
507545
}),
508546
);
509-
}, [activeNode, breakpointNode, setNodes, setEdges]);
510-
511-
const nodeStatusMap = useCallback(() => {
512-
const map: Record<string, string> = {};
513-
traces.forEach((t) => {
514-
const current = map[t.span_name];
515-
if (
516-
!current ||
517-
t.status === "failed" ||
518-
(t.status === "running" && current !== "failed")
519-
) {
520-
map[t.span_name] = t.status;
521-
}
522-
});
523-
return map;
524-
}, [traces]);
547+
}, [activeNode, breakpointNode, runStatus, setNodes, setEdges]);
548+
549+
const stateEvents = useRunStore((s) => s.stateEvents[runId]);
525550

526551
// Subscribe to cached graph reactively (populated async from run detail)
527552
const cachedGraph = useRunStore((s) => s.graphCache[runId]);
@@ -560,6 +585,7 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
560585
: laidNodes;
561586
setNodes(nodesWithBp);
562587
setEdges(laidEdges);
588+
setLayoutSeq((s) => s + 1);
563589
// Fit view after nodes are rendered
564590
setTimeout(() => {
565591
rfInstance.current?.fitView({ padding: 0.1, duration: 200 });
@@ -588,28 +614,81 @@ export default function GraphPanel({ entrypoint, traces, runId, breakpointNode,
588614
}
589615
}, [fitViewTrigger]);
590616

591-
// Update node status from traces
617+
// Update node status from state events (uses qualified_node_name for precise subgraph matching)
592618
useEffect(() => {
593-
const statusMap = nodeStatusMap();
594-
setNodes((nds) =>
595-
nds.map((n) => {
596-
if (n.type === "groupNode") {
619+
setNodes((nds) => {
620+
const hasEvents = !!stateEvents?.length;
621+
const isTerminal = runStatus === "completed" || runStatus === "failed";
622+
623+
// Build set of completed React Flow node IDs from state events
624+
const completedIds = new Set<string>();
625+
if (hasEvents) {
626+
const allNodeIds = new Set(nds.map((n) => n.id));
627+
// Fallback label→fullId map (used when no qualified name)
628+
const labelToIds = new Map<string, Set<string>>();
629+
for (const n of nds) {
597630
const label = n.data?.label as string | undefined;
598-
const status = label ? statusMap[label] : undefined;
599-
return status !== n.data?.status
600-
? { ...n, data: { ...n.data, status } }
601-
: n;
631+
if (!label) continue;
632+
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
633+
for (const key of [plainId, label]) {
634+
let s = labelToIds.get(key);
635+
if (!s) { s = new Set(); labelToIds.set(key, s); }
636+
s.add(n.id);
637+
}
602638
}
603-
const label = n.data?.label as string | undefined;
604-
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
605-
const status =
606-
(label ? statusMap[label] : undefined) ?? statusMap[plainId];
639+
640+
for (const evt of stateEvents) {
641+
let matched = false;
642+
if (evt.qualified_node_name) {
643+
const qId = evt.qualified_node_name.replace(/:/g, "/");
644+
if (allNodeIds.has(qId)) {
645+
completedIds.add(qId);
646+
matched = true;
647+
}
648+
}
649+
if (!matched) {
650+
const ids = labelToIds.get(evt.node_name);
651+
if (ids) ids.forEach((id) => completedIds.add(id));
652+
}
653+
}
654+
}
655+
656+
// Track which subgraphs were actually visited
657+
const visitedParents = new Set<string>();
658+
for (const n of nds) {
659+
if (n.parentNode && completedIds.has(n.id)) {
660+
visitedParents.add(n.parentNode);
661+
}
662+
}
663+
664+
return nds.map((n) => {
665+
let status: string | undefined;
666+
667+
if (completedIds.has(n.id)) {
668+
status = "completed";
669+
} else if (n.type === "startNode") {
670+
// Top-level: completed once execution begins; subgraph: only if visited
671+
if (!n.parentNode && hasEvents) status = "completed";
672+
else if (n.parentNode && visitedParents.has(n.parentNode)) status = "completed";
673+
} else if (n.type === "endNode") {
674+
// Top-level: completed when run finishes; subgraph: as soon as visited
675+
if (!n.parentNode && isTerminal) {
676+
status = runStatus === "failed" ? "failed" : "completed";
677+
} else if (n.parentNode && visitedParents.has(n.parentNode)) {
678+
status = "completed";
679+
}
680+
} else if (n.type === "groupNode") {
681+
// Group is completed if any child completed
682+
if (visitedParents.has(n.id)) status = "completed";
683+
}
684+
607685
return status !== n.data?.status
608686
? { ...n, data: { ...n.data, status } }
609687
: n;
610-
}),
611-
);
612-
}, [nodeStatusMap, setNodes]);
688+
});
689+
});
690+
// layoutSeq ensures this re-runs after graph layout completes
691+
}, [stateEvents, runStatus, layoutSeq, setNodes]);
613692

614693
if (loading) {
615694
return (

src/uipath/dev/server/frontend/src/store/useRunStore.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ interface RunStore {
3737
toggleBreakpoint: (runId: string, nodeId: string) => void;
3838
clearBreakpoints: (runId: string) => void;
3939

40-
activeNodes: Record<string, { prev: string | null; current: string }>;
41-
setActiveNode: (runId: string, nodeName: string) => void;
40+
activeNodes: Record<string, { prev: string | null; current: string; qualifiedNodeName?: string | null }>;
41+
setActiveNode: (runId: string, nodeName: string, qualifiedNodeName?: string | null) => void;
4242

43-
stateEvents: Record<string, { node_name: string; timestamp: number; payload?: Record<string, unknown> }[]>;
44-
addStateEvent: (runId: string, nodeName: string, payload?: Record<string, unknown>) => void;
45-
setStateEvents: (runId: string, events: { node_name: string; timestamp: number; payload?: Record<string, unknown> }[]) => void;
43+
stateEvents: Record<string, { node_name: string; qualified_node_name?: string | null; timestamp: number; payload?: Record<string, unknown> }[]>;
44+
addStateEvent: (runId: string, nodeName: string, payload?: Record<string, unknown>, qualifiedNodeName?: string | null) => void;
45+
setStateEvents: (runId: string, events: { node_name: string; qualified_node_name?: string | null; timestamp: number; payload?: Record<string, unknown> }[]) => void;
4646

4747
focusedSpan: { name: string; index: number } | null;
4848
setFocusedSpan: (span: { name: string; index: number } | null) => void;
@@ -211,25 +211,25 @@ export const useRunStore = create<RunStore>((set) => ({
211211
}),
212212

213213
activeNodes: {},
214-
setActiveNode: (runId, nodeName) =>
214+
setActiveNode: (runId, nodeName, qualifiedNodeName) =>
215215
set((state) => {
216216
const existing = state.activeNodes[runId];
217217
return {
218218
activeNodes: {
219219
...state.activeNodes,
220-
[runId]: { prev: existing?.current ?? null, current: nodeName },
220+
[runId]: { prev: existing?.current ?? null, current: nodeName, qualifiedNodeName },
221221
},
222222
};
223223
}),
224224

225225
stateEvents: {},
226-
addStateEvent: (runId, nodeName, payload) =>
226+
addStateEvent: (runId, nodeName, payload, qualifiedNodeName) =>
227227
set((state) => {
228228
const existing = state.stateEvents[runId] ?? [];
229229
return {
230230
stateEvents: {
231231
...state.stateEvents,
232-
[runId]: [...existing, { node_name: nodeName, timestamp: Date.now(), payload }],
232+
[runId]: [...existing, { node_name: nodeName, qualified_node_name: qualifiedNodeName, timestamp: Date.now(), payload }],
233233
},
234234
};
235235
}),

src/uipath/dev/server/frontend/src/store/useWebSocket.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ export function useWebSocket() {
3939
case "state": {
4040
const runId = msg.payload.run_id as string;
4141
const nodeName = msg.payload.node_name as string;
42+
const qualifiedNodeName = (msg.payload.qualified_node_name as string | undefined) ?? null;
4243
const payload = msg.payload.payload as Record<string, unknown> | undefined;
43-
setActiveNode(runId, nodeName);
44-
addStateEvent(runId, nodeName, payload);
44+
setActiveNode(runId, nodeName, qualifiedNodeName);
45+
addStateEvent(runId, nodeName, payload, qualifiedNodeName);
4546
break;
4647
}
4748
case "reload":

src/uipath/dev/server/frontend/src/types/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface RunSummary {
2121
export interface StateEventData {
2222
run_id: string;
2323
node_name: string;
24+
qualified_node_name?: string | null;
2425
timestamp: string;
2526
payload?: Record<string, unknown>;
2627
}

src/uipath/dev/server/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def serialize_state(state_data: StateData) -> dict[str, Any]:
5454
"node_name": state_data.node_name,
5555
"timestamp": state_data.timestamp.isoformat(),
5656
}
57+
if state_data.qualified_node_name is not None:
58+
result["qualified_node_name"] = state_data.qualified_node_name
5759
if state_data.payload is not None:
5860
result["payload"] = state_data.payload
5961
return result

0 commit comments

Comments
 (0)