Skip to content

Commit c99a7f8

Browse files
DylanMerigaudclaude
andcommitted
#4 Reframe only when a run state arrives: topmost pending gate, else whole workflow
One framing rule, shared by the initial layout fit and the focus effect: • pending gate(s) to act on → frame the SINGLE topmost gate (smallest y, or x when stacked vertically), tight + zoomed, so the next decision is centered; • none pending (a run resolved, a resume returned posted, a replay loaded) → fit the WHOLE workflow. Keyed on the pending set, so it fires when that set changes (pause, next gate after a resume, completion), NOT on a staged Approve/Reject click (which leaves the set unchanged) — so clicking a decision no longer jerks the view. Topmost is read from the live node positions via a ref, so the effect doesn't re-fire on every node change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 883bc23 commit c99a7f8

1 file changed

Lines changed: 41 additions & 37 deletions

File tree

components/workflow-graph.tsx

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
TooltipProvider,
2828
TooltipTrigger,
2929
} from "@/components/ui/tooltip";
30+
import { useEventCallback } from "@/hooks/use-event-callback";
3031
import { useMediaQuery } from "@/hooks/use-media-query";
3132
import {
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

Comments
 (0)