fix(workflows): deterministic density-toggle fit, density-aware zoom ceiling, and a coherent morph#178
Conversation
…etry fitView reads React Flow's measured node boxes, and measurement lags a density flip by a frame or more — so the reframe raced it and framed the old node sizes as often as the new. The structural nodes carry the layouter's authoritative position + size for exactly the new layout, so the viewport is now computed from them (getNodesBounds + getViewportForBounds, same FIT_VIEW clamps and reduced-motion handling) and set directly. Deterministic: the same density always lands the same frame.
Full cards at zoom 1 are their designed size, but compact tiles are small by design — capping their fit at 1 stranded a short compact graph as specks in empty space (while the Controls fit button, uncapped, framed it properly). The fit ceiling is now 1.5 for compact and stays 1 for expanded, on both the mount fit and the density-toggle reframe.
The toggle was three visible stages: node content swapped in the old boxes, then boxes and positions snapped, then the camera glided — which read as jitter. The layout transition now tweens node geometry and the viewport together through React Flow's store (220ms, ease-in-out), so edges re-route against the moving nodes on every frame; each card clips overflow, so mid-tween content reads as a reveal. Run state is re-merged into every frame so a tween can't wipe a node's live status. Reduced motion (and any non-tweenable transition: new node set, hidden canvas) lands atomically — nodes and camera in the same pass.
…ding in The rendered density flipped the instant the toggle was clicked — full card content popped into still-tile-sized boxes (and handle anchors jumped) one frame before anything moved, which read as a glitch. The rendered density now TRAILS the target: the box and camera start morphing around the old, clipped content, and the swap lands mid-motion where the two shells are closest in size — growing flips just after the box starts to grow, shrinking once the card has nearly collapsed. The incoming content fades in (opacity only, content only — a node's border/surface never blinks out), and a bounce that reverses before its flip fired never swaps content at all. Reduced motion keeps the atomic swap.
This comment has been minimized.
This comment has been minimized.
The tween captured the run state once when it started, so a status tick landing inside the 220ms window was stomped by the next frame — and if it was the run's LAST tick (a terminal SSE event), the graph stayed stale until an unrelated re-render. Every write the transition effect makes now reads the state ref fresh, so the very next frame carries a mid-tween tick. Regression test drives a tick mid-tween and asserts the final frame kept it.
🤖 AI Code Review (ensemble)
SummaryThis PR replaces React Flow's animated Issues Found5 total — 2 P1 (blocking) · 3 P2 (should fix) · 0 P3 (nice to have)
|
tangletools
left a comment
There was a problem hiding this comment.
🟢 Value Audit — sound
| Verdict | sound |
| Concerns | 0 (none) |
| Heuristic | 0.0s |
| Duplication | 0.0s |
| Interrogation | 189.1s (2 bridge agents) |
| Total | 189.1s |
💰 Value — sound
Replaces a measurement-racing fitView reframe on the density toggle with a deterministic bounds-computed viewport, a density-aware zoom ceiling, and a store-driven node+camera tween — a coherent, in-grain fix with no better existing approach.
- What it does: Three concrete behavior changes in the workflow graph: (1) the post-density-toggle reframe now computes the end viewport from the layouter's own node geometry via React Flow's getNodesBounds/getViewportForBounds (WorkflowGraph.tsx:761-768) instead of inst.fitView, which read ResizeObserver-measured boxes that lag a density flip; (2) the fit zoom ceiling is density-aware — compact fits up to 1.5, e
- Goals it achieves: Fix three real defects of the density toggle: non-deterministic framing (the fitView/ResizeObserver race framed old node sizes ~half the time), compact graphs stranded as specks by the maxZoom:1 cap (compact tiles are small by design, so a legitimate fit zooms past 1), and a jittery three-stage toggle (content swap, snap, camera glide). After merge: same density always lands the same frame, compac
- Assessment: Good on its merits. The diagnosis is correct and grounded in React Flow's actual behavior: fitView reads measured node boxes, and measurement genuinely lags a relayout — the prior code (git show c9e141e^) was a rAF-deferred fitView, which is exactly the race described. The fix uses React Flow's own exported helpers rather than reinventing viewport math. The manual store tween is justified, not gra
- Better / existing approach: None found. Searched the workflows module for existing viewport/animation utilities (grep for getViewportForBounds|getNodesBounds|fitView): the only prior mechanism was the fitViewOnLayoutChange helper this PR deletes, and React Flow itself is the source of the helpers now used. React Flow has no built-in node-geometry animation (its duration options are camera-only, which was the old buggy path),
- Model: opencode/kimi-for-coding/k2p7
- Bridge attempts: 1
🎯 Usefulness — sound
Replaces a measurement-racing fitView with deterministic structural-geometry viewport computation plus a custom node+camera tween, fixing three real density-toggle bugs in the grain of the codebase.
- Integration: Fully reachable. WorkflowGraph is the production component, reached only through WorkflowGraphLazy (src/workflows/WorkflowGraphLazy.tsx:50) which is re-exported from the /workflows barrel (src/workflows/index.ts:29) and consumed by platform-web per the PR body. The removed export
fitViewOnLayoutChangehad no consumers outside this repo's two co-located test files (git log -S confirms it was born - Fit with existing patterns: Fits the established pattern precisely. The prior code already used React Flow's
fitView+fitViewOptionsfor reframing; this change drops one abstraction level togetNodesBounds+getViewportForBounds(imported from @xyflow/react at WorkflowGraph.tsx:13-14) — the SAME library's lower-level primitives, not a reinvention. The CSS class.wf-node-body-infollows the existing convention in s - Real-world viability: Handles the realistic edge cases thoroughly: mid-flight retoggle redirects from current geometry because cleanup cancels both rAF and the density-flip timer, and the next run reads
nodesRef.currentas its starting point (WorkflowGraph.tsx:827-831); a run-state tick landing mid-tween is carried through becausewithRunStatereadsnodeStateRef.currentfresh inside everysetNodescall rather t - Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 1
No concerns — sound change, no better or existing approach found. ✅
What this audit checks
It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.
| Pass | What it asks |
|---|---|
| Heuristic | Vague title? Whitespace-only or cruft-bearing diff? (content signals only) |
| Duplication | Do added function/class names already exist elsewhere in the repo? |
| Value Audit | What does it do? What goal does it achieve? Is it good? Better architecture or already-exists? |
| Usefulness Audit | Does it integrate and fit? Will it hold up in real use and actually get used? |
Findings are concerns, not blocks — the human reviewer decides what to do with them.
✅ No Blockers —
|
| glm | kimi-code | aggregate | |
|---|---|---|---|
| Readiness | 86 | 86 | 86 |
| Confidence | 70 | 70 | 70 |
| Correctness | 86 | 86 | 86 |
| Security | 86 | 86 | 86 |
| Testing | 86 | 86 | 86 |
| Architecture | 86 | 86 | 86 |
Reviewer score is advisory once the run is complete and the verdict has no blockers.
Full multi-shot audit completed 2/2 planned shots over 4 changed files. Global verifier still owns final merge decision. | Full multi-shot audit completed 2/2 planned shots over 4 changed files. Global verifier still owns final merge decision.
🟡 LOW Density toggle on a hidden canvas leaves a stale viewport on reveal — src/workflows/WorkflowGraph.tsx
The
frame.width === 0 || frame.height === 0early return seeds nodes + displayCompact but never reframes; React Flow'sfitViewprop only applies at init, so a graph toggled while display:none keeps the old density's framing when revealed until the next structural change or a manual Controls fit. The new test ('leaves the viewport alone while the canvas has no frame') pins this as intended, so it's a documented trade-off rather than a bug — flagging only because a hidden-tab-mount + toggle sequence is a plausible host integration (e.g. graph inside a collapsed drawer).
🟡 LOW Tween stomps concurrent user pan/zoom/drag for ~220ms — src/workflows/WorkflowGraph.tsx
During the density tween, every frame writes inst.setViewport({...}) and setNodes(interpolated), overriding any simultaneous user pan/pinch-zoom (both variants allow panOnDrag + zoomOnPinch) or node drag (full variant). The window is only LAYOUT_TRANSITION_MS and self-corrects, and the pre-change code also re-seeded nodes on structural change, so this is a minor UX regression at worst. If it ever matters, gate the tween on
!userInteractingor pause on pointerdown.
🟡 LOW fitViewOptions allocates a new object on every render — src/workflows/WorkflowGraph.tsx
fitViewOptions={{ ...FIT_VIEW, maxZoom: fitZoomCeiling(compact) }} creates a new object reference each render. React Flow's fitView prop only applies on mount, so this is functionally harmless — RF ignores fitViewOptions changes after initial fit. But useMemo-ing it (or extracting to a constant keyed on compact) would avoid passing a new prop identity on every render. Cosmetic, not a correctness issue.
🟡 LOW No test exercises the atomic path when the node set changes (yaml edit / node added or removed) — src/workflows/WorkflowGraphFitView.test.tsx
The effect's sameNodeSet===false branch (WorkflowGraph.tsx:774-783) takes the atomic path — setNodes + setViewport with no tween — but every test uses the same 2-node YAML, so this branch is never exercised. A regression that accidentally tweened a node-set change (e.g. a yaml edit while motion is allowed) would not be caught. Consider a test that rerenders with a different YAML after the initial mount and asserts exactly one setViewport call.
🟡 LOW Rapid double-toggle bounce path is documented but untested — src/workflows/WorkflowGraphFitView.test.tsx
The cleanup comment at WorkflowGraph.tsx:827-831 describes specific behavior for a rapid double-toggle: 'a bounce that reverses before its flip fired never swaps the content at all.' This relies on clearTimeout(flipTimer) correctly canceling the pending displayCompact change. No test toggles twice within LAYOUT_TRANSITION_MS to verify displayCompact stays at its original value and the second tween starts from the mid-flight geometry (nodesRef.current). Adding this test would lock in the invariant that prevents a content blink on bounce.
🟡 LOW Tween state updates fire outside act() in the new fit-view tests — src/workflows/WorkflowGraphFitView.test.tsx
The rAF-via-setTimeout stub and the flip timer drive setNodes/setDisplayCompact outside React's act(), producing 'An update to WorkflowGraph inside a test was not wrapped in act(...)' stderr warnings in at least the mid-tween run-state test (observed in the run). Tests still pass deterministically, so this is noise, not a correctness gap — but it can mask future update-ordering bugs and clutters CI output. Fix: wrap the wait windows in act(async () => ...) or advance the stubbed frames inside act().
tangletools · 2026-07-15T03:06:58Z · trace
tangletools
left a comment
There was a problem hiding this comment.
✅ Approved — 6 non-blocking findings — b15eb00f
Full multi-shot audit completed 2/2 planned shots over 4 changed files. Global verifier still owns final merge decision. | Full multi-shot audit completed 2/2 planned shots over 4 changed files. Global verifier still owns final merge decision.
Full immutable report for this review: trace
Summary comment for this run: full summary
tangletools · 2026-07-15T03:06:58Z · immutable trace
Problem
The workflow graph's expand/compact density toggle misbehaved three ways, found while iterating on platform-web's workflow detail page:
fitView, which reads React Flow's measured node boxes — measurement (a ResizeObserver) lags a density flip by a frame or more, so the reframe raced it and framed the old node sizes as often as the new ones.maxZoom: 1cap makes sense for full cards (1 is their designed size) but compact tiles are small by design — capping their fit at 1 left a short compact graph floating in empty canvas, while the Controls fit button (uncapped) framed it properly.Solution (one commit per fix, in sequence)
fix(workflows): reframe the density toggle from the layout's own geometry— the end viewport is computed from the structural nodes' authoritative position + size (getNodesBounds+getViewportForBounds) and set directly, never via measurement-racingfitView. Deterministic: the same density always lands the same frame.fix(workflows): density-aware fit zoom ceiling—fitZoomCeiling: compact fits up to 1.5, expanded holds at 1, on both the mount fit and the toggle reframe.feat(workflows): tween the density toggle — nodes and camera move as one— node geometry and the viewport lerp together through React Flow's store (220ms ease-in-out), so edges re-route against the moving nodes on every frame (a CSS transform transition can't do that — edge paths come from store positions and would snap ahead of the gliding nodes). Run state is re-merged into every frame so a tween can't wipe a node's live status; a mid-flight retoggle redirects from the current geometry; the tween is time-based, so a background-tab freeze self-heals on the next frame. Reduced motion (and any non-tweenable transition: new node set, hidden canvas) lands atomically — nodes and camera in the same pass.fitViewOnLayoutChangeis removed (no consumers outside this module).feat(workflows): land the density swap mid-morph, with the content fading in— the rendered density trails the target (DENSITY_FLIP_AT: growing flips at 12%, shrinking at 75%, where the two shells are closest in size), and the incoming content fades in via the content-only.wf-node-body-inanimation — a node's border/surface never fades, so it can't blink out. A bounce that reverses before its flip fired never swaps content at all.Testing
WorkflowGraphFitView.test.tsxnow pins the transition contract (many un-animatedsetViewportwrites under motion — the tween is the animation; exactly one atomic write under reduced motion; untouched viewport on a hidden canvas), plus afitZoomCeilingunit test.Consumer rollout
Publish as 0.83.0, then bump
@tangle-network/sandbox-uiin agent-dev-container's platform-web (companion UI PR: tangle-network/agent-dev-container#3615, which runs fine against 0.82.0 until then).