Skip to content

fix(workflows): deterministic density-toggle fit, density-aware zoom ceiling, and a coherent morph#178

Merged
Tjemmmic merged 5 commits into
mainfrom
fix/workflow-graph-density-refit
Jul 15, 2026
Merged

fix(workflows): deterministic density-toggle fit, density-aware zoom ceiling, and a coherent morph#178
Tjemmmic merged 5 commits into
mainfrom
fix/workflow-graph-density-refit

Conversation

@Tjemmmic

Copy link
Copy Markdown
Contributor

Problem

The workflow graph's expand/compact density toggle misbehaved three ways, found while iterating on platform-web's workflow detail page:

  1. Inconsistent fit: the post-toggle reframe went through 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.
  2. Compact stranded as specks: the fit's maxZoom: 1 cap 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.
  3. Jittery toggle: the switch was three separately visible stages — content swapped inside the old boxes, boxes/positions snapped, then the camera glided.

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-racing fitView. Deterministic: the same density always lands the same frame.
  • fix(workflows): density-aware fit zoom ceilingfitZoomCeiling: 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. fitViewOnLayoutChange is 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-in animation — 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

  • Full suite: 796/796 across 57 files; typecheck clean. WorkflowGraphFitView.test.tsx now pins the transition contract (many un-animated setViewport writes under motion — the tween is the animation; exactly one atomic write under reduced motion; untouched viewport on a hidden canvas), plus a fitZoomCeiling unit test.
  • Browser-verified against platform-web's workflow detail page: toggle transforms are pixel-deterministic across repeated flips and identical to the Controls fit button's framing; 20ms-interval DOM sampling shows the eased geometry+camera morph and the mid-morph content swap landing at the designed offsets, settling pixel-exact in both directions; interrupted double-toggles redirect smoothly.

Consumer rollout

Publish as 0.83.0, then bump @tangle-network/sandbox-ui in agent-dev-container's platform-web (companion UI PR: tangle-network/agent-dev-container#3615, which runs fine against 0.82.0 until then).

Tjemmmic added 4 commits July 14, 2026 18:37
…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.
@Tjemmmic

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.
@Tjemmmic

Tjemmmic commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI Code Review (ensemble)

Resolution (b15eb00): 4 not an issue · 1 acknowledged.

Summary

This PR replaces React Flow's animated fitView with a custom manual tween driving both node geometry and the camera, fixing a race condition with node measurement. The core approach is solid, but there are real bugs around concurrent drag operations clobbering the tween, potential displayCompact desync from rapid re-triggers, missing dimension guards, and unhandled hidden-canvas relayout edge cases.

Issues Found

5 total — 2 P1 (blocking) · 3 P2 (should fix) · 0 P3 (nice to have)

❗ P1 — Manual layout tween clobbers concurrent drag operationsNot an issue

Note: Not a regression — drag positions have never survived a relayout in this graph: the pre-PR code re-seeded every node position instantly on any structural change (setNodes(structural.nodes)), by design (the layout is authoritative; the graph is a read-only visualization and drags have no persistence). The tween animates the same stomp to the same end state. A toggle click can't co-occur with a drag from the same pointer; the residual collision (keyboard-activated toggle or a live definition edit mid-drag) behaved identically on main, just without the glide.

  • File: src/workflows/WorkflowGraph.tsx:L755-L786
  • Problem: During the 220ms tween, the effect calls setNodes on every animation frame, writing interpolated positions for all nodes based on the snapshot prevById. If the user starts dragging a node mid-tween, the next step frame overwrites the drag-in-progress positions with the interpolated values, snapping the dragged node back.
  • Fix: Track an isDraggingRef that is set on drag start and cleared on drag end. Inside the step function, bail early if isDraggingRef.current is true. Wire it up in the onNodeDragStart / onNodeDragStop handlers.

❗ P1 — Rapid re-trigger of layout transition can leave displayCompact stuckNot an issue

Note: A cleared timer cannot fire a stale closure: the effect cleanup runs clearTimeout(flipTimer) before the next effect execution, and React guarantees that ordering — so the only live timer is always the latest execution's, whose captured compact IS the current target. Every execution ends by either setting displayCompact synchronously (seed/hidden/atomic paths) or scheduling that timer, so it always converges to the latest compact. The suggested functional update is behaviorally identical to the current call (React already bails on same-value sets).

  • File: src/workflows/WorkflowGraph.tsx:L756-L760
  • Problem: When the effect re-runs (e.g., due to a rapid double-toggle or structural change), the cleanup clears the flipTimer. But if the effect runs again before the timer fires, the captured compact value inside the setTimeout closure becomes stale. Depending on timing, displayCompact can end up out of sync with what the nodes are actually rendering.
  • Fix: Guard against the stale closure by capturing the compact value in a ref or use a functional update that only applies the flip if it hasn't already been applied: setDisplayCompact((prev) => prev === compact ? prev : compact).

⚠️ P2 — Tween doesn't handle structural nodes where width/height is undefinedNot an issue

Note: Structural nodes carry authoritative dimensions by construction — flow-graph.ts sets width/height (and style width/height) on every node from the layouter's computed sizes, unconditionally. The n.width ?? p.width exists only because React Flow's Node type marks the fields optional; the undefined branch is unreachable for nodes this component builds.

  • File: src/workflows/WorkflowGraph.tsx:L768-L776
  • Problem: The code checks p.width === undefined || p.height === undefined on the PREVIOUS node, but the NEW structural node (n) could also have n.width === undefined. In that case n.width ?? p.width uses the previous width but n.position is set to the new position, which could be inconsistent.
  • Fix: Skip interpolation for nodes where the structural node itself lacks dimensions and fall back to the final value directly: if (!p || p.width === undefined || p.height === undefined || n.width === undefined || n.height === undefined) return n;

⚠️ P2 — Hidden canvas relayout never refits when it becomes visible ℹ️ Acknowledged

Note: Valid observation, but pre-existing rather than introduced: on main the relayout path ran fitView against the 0×0 container (no guard at all), which equally leaves a wrong frame when the canvas becomes visible — no refit-on-visible has ever existed. This PR makes the skip explicit instead of computing a degenerate fit. A ResizeObserver-driven refit is a reasonable follow-up, out of scope for a PR about the toggle race/framing.

  • File: src/workflows/WorkflowGraph.tsx:L745-L748
  • Problem: When the wrapper has a 0x0 frame, the effect commits structural.nodes and displayCompact but intentionally skips setViewport. There is no dependency or observer that reruns this effect when the hidden graph later becomes visible, so a density toggle, orientation change, or run-overlay layout change inside a collapsed tab/panel leaves the graph using the old viewport against the new node geometry.
  • Fix: Keep a pending refit when frame.width === 0 || frame.height === 0 and retry once the wrapper becomes measurable, for example with a ResizeObserver on wrapperRef that bumps local state when the size transitions to non-zero, causing the same structural fit path to run.

⚠️ P2 — wf-node-body-in animation re-fires on every React re-render, not just density swapNot an issue

Note: Run-state ticks update props on stable elements — same element type at the same position, so React updates in place, there is no remount, and a CSS mount animation cannot re-fire (verified in-browser: elements tagged with a JS marker retain it across state re-renders; only a density flip replaces them). The one non-density mount is StatusFooter first appearing when a run gains state — a deliberate materialize, consistent with the library's existing animate-in/row-in mount animations.

  • File: src/styles/globals.css:L358-L365
  • Problem: The .wf-node-body-in class is statically applied to content elements in the node component (StatusFooter, NodeMark wrapper, text div, main content div). The CSS animation runs every time the element is mounted/remounted. Any React key change or conditional remount (e.g., state changes that cause the component to unmount/remount) will re-trigger the fade-in animation, causing unexpected content blinking on run-state updates.
  • Fix: Apply the class conditionally based on whether a density transition is in progress, or use a key that only changes on density flip. Alternatively, toggle the class via state during the tween window or use an animation that only plays once and is re-triggered via a key change tied specifically to displayCompact.

❌ CHANGES REQUESTEDResolved — no blocking concerns remain

Status: Both P1s are refuted (drag stomp is the pre-existing, by-design relayout behavior; a cleared timer cannot fire a stale closure — cleanup ordering guarantees convergence). Two P2s refuted by construction/in-browser evidence; the hidden-canvas refit gap is a valid pre-existing limitation noted as a follow-up.

Two P1 bugs (tween clobbering concurrent drags and displayCompact desync from stale timer closures) will manifest in production and must be addressed. The three P2 issues (missing dimension guards, hidden canvas refit gap, and unguarded animation re-firing) add cumulative risk. With 2 P1s and 3 P2s, this exceeds the threshold for CHANGES_REQUESTED.

Quick Reference

  • P1: Manual layout tween clobbers concurrent drag operations
  • P1: Rapid re-trigger of layout transition can leave displayCompact stuck
  • P2: Tween doesn't handle structural nodes where width/height is undefined
  • P2: Hidden canvas relayout never refits when it becomes visible
  • P2: wf-node-body-in animation re-fires on every React re-render, not just density swap

Synthesized by Sokuza AI from multiple independent reviewers

@Tjemmmic
Tjemmmic marked this pull request as ready for review July 15, 2026 02:54
@Tjemmmic
Tjemmmic requested a review from tangletools July 15, 2026 02:58

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 fitViewOnLayoutChange had 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 + fitViewOptions for reframing; this change drops one abstraction level to getNodesBounds + 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-in follows 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.current as its starting point (WorkflowGraph.tsx:827-831); a run-state tick landing mid-tween is carried through because withRunState reads nodeStateRef.current fresh inside every setNodes call 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.

value-audit · 20260715T025918Z

@tangletools

Copy link
Copy Markdown
Contributor

✅ No Blockers — b15eb00f

Review health 100/100 · Reviewer score 86/100 · Confidence 70/100 · 6 findings (6 low)

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 === 0 early return seeds nodes + displayCompact but never reframes; React Flow's fitView prop 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 !userInteracting or 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 tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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

@Tjemmmic
Tjemmmic merged commit 4531350 into main Jul 15, 2026
1 check passed
@Tjemmmic Tjemmmic mentioned this pull request Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants