@@ -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 (
0 commit comments