@@ -27,6 +27,7 @@ import {
2727 TooltipProvider ,
2828 TooltipTrigger ,
2929} from "@/components/ui/tooltip" ;
30+ import { useEventCallback } from "@/hooks/use-event-callback" ;
3031import { useMediaQuery } from "@/hooks/use-media-query" ;
3132import {
3233 type ApprovalWorkflow ,
@@ -697,7 +698,33 @@ const Inner = ({
697698 const [ nodes , setNodes , onNodesChange ] = useNodesState < Node < NodeData > > (
698699 initialNodes . map ( ( n ) => ( { ...n , style : { visibility : "hidden" } } ) ) ,
699700 ) ;
701+ // Live mirror of `nodes` so the focus effect can read the current positions (to pick
702+ // the topmost pending gate) without listing `nodes` as a dep (which would re-fire it).
703+ const nodesRef = useRef ( nodes ) ;
704+ nodesRef . current = nodes ;
700705 const [ rfEdges , setEdges , onEdgesChange ] = useEdgesState < Edge > ( edges ) ;
706+
707+ // The one framing rule, shared by the initial layout fit and the later focus effect:
708+ // pending gate(s) present → frame the SINGLE topmost one (smallest y, or x when
709+ // stacked), tight + zoomed, so the next thing to approve is centered; none present
710+ // → fit the WHOLE workflow. Topmost is read from the live node positions.
711+ const frameForFocus = useEventCallback ( ( pending : string [ ] , ms : number ) => {
712+ if ( pending . length > 0 ) {
713+ const axis = ( id : string ) : number => {
714+ const pos = nodesRef . current . find ( ( m ) => m . id === id ) ?. position ;
715+ return pos ? ( vertical ? pos . x : pos . y ) : Number . POSITIVE_INFINITY ;
716+ } ;
717+ const top = [ ...pending ] . sort ( ( a , b ) => axis ( a ) - axis ( b ) ) [ 0 ] ;
718+ void fitView ( {
719+ nodes : top ? [ { id : top } ] : pending . map ( ( id ) => ( { id } ) ) ,
720+ duration : ms ,
721+ padding : 0.25 ,
722+ maxZoom : 1.1 ,
723+ } ) ;
724+ } else {
725+ void fitView ( { duration : ms , padding : 0.18 } ) ;
726+ }
727+ } ) ;
701728 // Bumped to force one more layout pass when a node's measured height drifts after the
702729 // initial layout (see the drift effect below); a dep of the layout effect.
703730 const [ relayoutTick , setRelayoutTick ] = useState ( 0 ) ;
@@ -770,20 +797,7 @@ const Inner = ({
770797 driftRelayout . current = false ;
771798 return ;
772799 }
773- const focus = focusIds ?? [ ] ;
774- requestAnimationFrame (
775- ( ) =>
776- void fitView (
777- focus . length > 0
778- ? {
779- nodes : focus . map ( ( id ) => ( { id } ) ) ,
780- padding : 0.25 ,
781- maxZoom : 1.1 ,
782- duration : 200 ,
783- }
784- : { padding : 0.18 , duration : 200 } ,
785- ) ,
786- ) ;
800+ requestAnimationFrame ( ( ) => frameForFocus ( focusIds ?? [ ] , 200 ) ) ;
787801 } , [
788802 initialized ,
789803 graphKey ,
@@ -793,6 +807,7 @@ const Inner = ({
793807 fitView ,
794808 vertical ,
795809 focusIds ,
810+ frameForFocus ,
796811 relayoutTick ,
797812 ] ) ;
798813
@@ -922,33 +937,22 @@ const Inner = ({
922937 setNodes ,
923938 ] ) ;
924939
925- // Smoothly frame the focused nodes (the pending group awaiting a decision, or one on
926- // hover), so the node you must act on lands centered. Runs after the per-graph layout
927- // (`laidOutFor === graphKey`) so it doesn't fight the one-shot fit; keyed on `focusKey`
928- // so it animates once per change (the next gate after an approve re-centers here).
929- // `maxZoom` lets it actually zoom IN on a single gate (fitView otherwise caps the
930- // zoom and, on a 2-node graph, would just frame both and leave the gate off-center);
931- // a rAF lets the just-laid-out node measurements settle before we measure their box.
940+ // Re-frame the view when a run state ARRIVES (not while staging a decision). The rule:
941+ // • can approve now (pending gates) → frame the SINGLE topmost pending gate, so the
942+ // next thing to act on is centered and zoomed in;
943+ // • otherwise (a run just resolved / a replay loaded / posted) → fit the WHOLE
944+ // workflow, so the finished path reads at a glance.
945+ // Keyed on `focusKey` (the pending set), so it fires once when the gate set changes
946+ // (a run pauses, a resume reaches the next gate, a run completes to none). Clicking
947+ // Approve/Reject doesn't change the pending set, so the view stays put on a click.
932948 const focusKey = ( focusIds ?? [ ] ) . join ( "|" ) ;
933949 useEffect ( ( ) => {
934- if ( ! initialized || laidOutFor . current !== graphKey || focusKey === "" )
935- return ;
936- const ids = focusKey . split ( "|" ) . map ( ( id ) => ( { id } ) ) ;
937- // Frame the pending gate itself, tightly. A large padding made fitView zoom out
938- // until the WHOLE graph fit (the gate no longer looked focused); a small padding
939- // keeps the gate centered and legible. maxZoom caps how far it zooms in on a lone
940- // node so it doesn't blow up. A rAF lets the just-laid-out measurements settle.
941- const raf = requestAnimationFrame (
942- ( ) =>
943- void fitView ( {
944- nodes : ids ,
945- duration : 400 ,
946- padding : 0.25 ,
947- maxZoom : 1.1 ,
948- } ) ,
950+ if ( ! initialized || laidOutFor . current !== graphKey ) return ;
951+ const raf = requestAnimationFrame ( ( ) =>
952+ frameForFocus ( focusKey ? focusKey . split ( "|" ) : [ ] , 400 ) ,
949953 ) ;
950954 return ( ) => cancelAnimationFrame ( raf ) ;
951- } , [ focusKey , initialized , graphKey , fitView ] ) ;
955+ } , [ focusKey , initialized , graphKey , frameForFocus ] ) ;
952956
953957 // Patch edge "flow" styling from the live statuses, cheap, no relayout (kept out of
954958 // the structural `edges` so a status tick never resets/re-measures the graph). An edge
0 commit comments