Skip to content

Fix sidebar workspace frame collection virtualization - #5325

Merged
austinywang merged 15 commits into
mainfrom
issue-5323-sidebar-virtualize
Jun 4, 2026
Merged

austinywang merged 15 commits into
mainfrom
issue-5323-sidebar-virtualize

Conversation

@austinywang

@austinywang austinywang commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5323.

Reproduce-first evidence

I reproduced the mechanism locally before changing code, using the existing production cmux process on this machine rather than a fresh VM or a new dev build.

Observed:

  • The active cmux window has 128 workspaces, enough to exercise the 100+ workspace scale called out in the issue.
  • The running production app did not expose debug.sidebar.simulate_drag, so I used ARM-native Time Profiler sampling while applying harmless temporary UserDefaults churn, then deleted the temporary key.
  • Trace: /tmp/cmux-issue5323-before-arm64.trace.
  • The exported time profile showed UserDefaultsSettingsStore notification handling re-entering VerticalTabsSidebar.body / VerticalTabsSidebar.workspaceRows, then SwiftUI preference/layout work including GraphHost.updatePreferences, NSHostingView.layout, ForEachList.applyNodes, and DynamicContainerInfo.updateItems.
  • The same trace includes GeometryProxy.subscript.getter from VerticalTabsSidebar.workspaceRows(renderContext:), which is the sidebar drop overlay resolving per-row .bounds anchors into drop target frames.

Expected:

  • In steady state, with no sidebar drag and no Bonsplit workspace drop active, rows should not publish per-row .bounds anchors and the sidebar should not install the row-frame overlayPreferenceValue / GeometryReader reader. A LazyVStack can then avoid requiring off-viewport row frames.

Fix

  • Added a shared policy seam, SidebarDropPlanner.shouldCollectWorkspaceDropTargets(...), with a failing test committed first (test: reproduce sidebar drop target collection bug).
  • Gated every SidebarWorkspaceRowFramePreferenceKey emitter for workspace rows and group headers.
  • Gated the sidebar-wide overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) so it is only installed while workspace drop target collection is active.
  • Kept a lightweight AppKit Bonsplit drag-capture overlay at rest without any row-frame preference reader, so Bonsplit drags can still request collection.
  • Scoped deferred Bonsplit drops to the active target-collection request and deferred pending-drop replay out of updateNSView to avoid mutating SwiftUI bindings during the representable update pass.
  • Preserved existing row content optimizations; this does not re-blame TabItemView body work.

Before / after verification

Before:

  • Runtime trace on the 128-workspace local app showed defaults writes reaching the sidebar row preference/layout path and resolving row anchors through GeometryProxy.subscript.
  • The first commit's policy implementation intentionally returns true for no active drag, matching the old always-on collection behavior; the new test is red against that commit.

After:

  • The fix changes the no-drag policy to false; row/group .bounds anchors and the row-frame overlayPreferenceValue reader are absent in steady state.
  • The policy returns true during a sidebar workspace drag or a detected Bonsplit workspace drop, limiting all-row frame collection to actual drag sessions.
  • I did not run a local dev build or runtime after-profile because the task explicitly forbids reload.sh/builds before CI is green and user authorization is given.

Tests / checks

  • Added coverage in cmuxTests/SidebarWorkspaceDropPlannerTests.swift for:
    • no drag => target collection disabled
    • sidebar drag => target collection enabled
    • Bonsplit workspace drop => target collection enabled
  • Addressed Cursor Bugbot and CodeRabbit review comments around pending Bonsplit drop behavior and steady-state preference collection.
  • Local build/tests not run per repo/user constraints. CI should validate.
  • Localization audit: no user-facing strings were added or changed. The only new strings are DEBUG-only diagnostic text.

Note

Medium Risk
Touches sidebar drag/drop and Bonsplit move-to-workspace flows with timing-sensitive pending-drop logic; steady-state performance improves but regressions could affect large-workspace DnD.

Overview
Stops the sidebar from collecting per-workspace row frame anchors and the overlayPreferenceValue geometry reader unless a drag actually needs drop targets—either a normal sidebar tab drag (draggedTabId) or an active Bonsplit workspace drop signaled separately from SidebarDragState.

Bonsplit drags now turn on that collection via AppKit pasteboard callbacks, feed targets through a TargetBridge / TargetWriter, and can defer a drop until row frames exist—without tearing down the drop NSView when collection flips on (overlay stays mounted outside the gated branch).

Adds SidebarDropPlanner.shouldCollectWorkspaceDropTargets, a sidebarWorkspaceFrameAnchor modifier, and unit tests for when collection is on vs off.

Reviewed by Cursor Bugbot for commit beb318e. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Deferred handling for workspace drops so transfers are queued and completed once targets appear.
  • Bug Fixes

    • Conditional collection of workspace drop targets to reduce spurious anchors and improve drag-and-drop accuracy.
    • More robust drop-indicator and teardown behavior during complex/late-arriving drops.
  • Tests

    • Added tests verifying when workspace drop-target collection is active.

@vercel

vercel Bot commented Jun 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 4, 2026 8:00am
cmux-staging Building Building Preview, Comment Jun 4, 2026 8:00am

@coderabbitai

coderabbitai Bot commented Jun 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Gates per-row frame-anchor collection so workspace drop-target frames are only published during an active tab drag or when bonsplit drop mode is enabled. Adds a centralized predicate, a transient ContentView state flag, conditional anchor application in headers/rows, overlay refactor, and pending-drop resolution in the Bonsplit drop view.

Changes

Gate workspace drop target collection on drag to restore sidebar virtualization

Layer / File(s) Summary
Decision predicate for workspace drop target collection
Sources/Sidebar/SidebarDropPlanner.swift, cmuxTests/SidebarWorkspaceDropPlannerTests.swift
SidebarDropPlanner.shouldCollectWorkspaceDropTargets returns true when a tab drag is in progress or bonsplit workspace drop mode is active; unit tests verify no-drag, active-drag, and bonsplit-drop cases.
Conditional anchor application in headers
Sources/VerticalTabsSidebar+WorkspaceGroups.swift
sidebarWorkspaceGroupHeader gains shouldCollectWorkspaceDropTargets param, stores header locally, and applies the SidebarWorkspaceRowFramePreferenceKey only when collection is enabled.
Transient state flag and teardown management
Sources/ContentView.swift
isBonsplitWorkspaceDropTargetCollectionActive transient state tracks bonsplit workspace drop collection and is cleared during drag teardown/defensive reset paths.
Workspace list refactor and conditional overlay composition
Sources/ContentView.swift
workspaceRows is refactored into a @ViewBuilder, extracts bonsplitWorkspaceDropOverlay(), and conditionally uses overlayPreferenceValue+GeometryReader to map anchors to WorkspaceDropTarget only when collection is enabled; otherwise renders baseline overlay with empty targets.
Conditional per-row anchor emission
Sources/ContentView.swift
workspaceRow binds row locally and attaches anchorPreference(SidebarWorkspaceRowFramePreferenceKey) only when shouldCollectWorkspaceDropTargets is true, otherwise preserves previous padding without emitting anchors.
Drop overlay callback wiring and drag lifecycle management
Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Overlay accepts setWorkspaceDropTargetCollectionActive and isWorkspaceDropTargetCollectionActive, replaces direct targets with a TargetBridge, adds isRequestingWorkspaceDropTargets and PendingDrop handling, supports pending transfers when targets are missing, resolves pending drops when targets arrive, and updates drag lifecycle to avoid premature deactivation of indicators.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#4989: Both PRs touch sidebarWorkspaceGroupHeader and header/drag handling; changes are code-adjacent and may overlap.

Suggested reviewers

  • Ari4ka

Poem

🐰 I nudged the anchors, soft and bright,

Only when a tab is dragged do I light,
I hold a pending hop until targets show,
Then tap the path where falling bits should go,
A tiny bridge, a tidy drop — now off I go.


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error TargetBridge class in SidebarBonsplitTabWorkspaceDropOverlay.swift is shared mutable reference type without explicit actor isolation, accessed from SwiftUI and AppKit contexts. Mark TargetBridge with @MainActor, or use a lock with documented reason, or apply @unchecked Sendable with safety explanation.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix sidebar workspace frame collection virtualization' clearly and specifically describes the main change: disabling unconditional workspace frame collection to restore LazyVStack virtualization.
Linked Issues check ✅ Passed All changes directly address the objectives in #5323: gates SidebarWorkspaceRowFramePreferenceKey emitters, gates the overlayPreferenceValue reader, keeps Bonsplit overlay lightweight, and adds test coverage for the collection policy, successfully restoring LazyVStack virtualization.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the collection policy and virtualization fix: workspace row preference gating, overlay collection toggling, Bonsplit pending-drop handling with deferred teardown, and related test coverage. No unrelated or out-of-scope modifications were introduced.
Cmux Swift Blocking Runtime ✅ Passed No blocking runtime primitives introduced; pre-existing blocking constructs found only in ContentView.swift, not in the PR's 4 target files.
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift files; check explicitly covers TypeScript/JavaScript/shell/build scripts only. Swift timing is covered by separate rule.
Cmux Algorithmic Complexity ✅ Passed PR gates the full-collection scan and anchor preference modifiers behind shouldCollectWorkspaceDropTargets, so they only execute during drag/drop operations when needed, not unconditionally.
Cmux Swift Concurrency ✅ Passed Three new DispatchQueue.main.async calls are all at AppKit/SwiftUI boundaries: SwiftUI-NSView bridge, NSViewRepresentable lifecycle, and AppKit drag callbacks. No forbidden patterns found.
Cmux Swift @Concurrent ✅ Passed PR introduces no async functions, @concurrent annotations, or nonisolated async patterns. All synchronous code; DispatchQueue.main calls appropriately used from MainActor contexts.
Cmux Swift File And Package Boundaries ✅ Passed SidebarBonsplitTabWorkspaceDropOverlay (426 lines) has clear single responsibility and qualifies as allowed AppKit bridge code per rules.
Cmux Swift Logging ✅ Passed PR's sidebar changes contain no logging violations. The only NSLog found is pre-existing code in feedback submission, wrapped in #if DEBUG (allowed case per swift-logging.md).
Cmux User-Facing Error Privacy ✅ Passed PR contains only internal drag-drop UI refactoring with no user-facing error messages, alerts, or sensitive data exposed in accordance with user-facing error privacy rules.
Cmux Full Internationalization ✅ Passed PR contains no user-facing strings or catalog changes; all debug logs wrapped in #if DEBUG, strings are internal identifiers, and no localization files modified.
Cmux Swiftui State Layout ✅ Passed No ObservableObject/@published patterns. State mutations in proper lifecycle callbacks. Row subtrees receive snapshots not stores. GeometryReader conditionally gated.
Cmux Architecture Rethink ✅ Passed Bounds collection gated by shouldCollectWorkspaceDropTargets policy eliminates unconditional aggregation. Pending-drop mechanism is required AppKit-SwiftUI callback bridge with documented rationale.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No standalone NSWindow/NSPanel/NSWindowController/Window/WindowGroup added. Changes gating drop-target collection within main workspace sidebar (allowed case).
Description check ✅ Passed The PR description comprehensively covers all required sections: a detailed summary explaining what changed and why, thorough testing methodology including reproduction steps and before/after verification, clear checklist items, and explicit notes about CI validation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5323-sidebar-virtualize

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift Outdated
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 12218-12221: Compute
SidebarDropPlanner.shouldCollectWorkspaceDropTargets once in workspaceRows
(using dragState.draggedTabId and isBonsplitWorkspaceDropTargetCollectionActive)
and pass that boolean into workspaceRow as an extra parameter (or extract it to
a shared computed property used by both workspaceRows and workspaceRow). Update
all callers that currently recompute the predicate (the occurrences around
workspaceRows and the separate workspaceRow usage) to use the new
parameter/property, and remove the duplicated calls to
SidebarDropPlanner.shouldCollectWorkspaceDropTargets to keep the decision
centralized.
- Line 10452: The SwiftLint warning is triggered because the SwiftUI state
property is not private; change the declaration of
isBonsplitWorkspaceDropTargetCollectionActive to be private (mark the `@State` var
isBonsplitWorkspaceDropTargetCollectionActive as private) inside
VerticalTabsSidebar so the state is encapsulated and satisfies the
private_swiftui_state rule.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 27fcb2ac-38b2-4e92-bdc3-15efdd5facea

📥 Commits

Reviewing files that changed from the base of the PR and between 2c35c54 and d5f7959.

📒 Files selected for processing (5)
  • Sources/ContentView.swift
  • Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
  • Sources/Sidebar/SidebarDropPlanner.swift
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift
  • cmuxTests/SidebarWorkspaceDropPlannerTests.swift

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/ContentView.swift
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 12243-12259: The
overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) +
GeometryReader block is always mounted which forces collection/resolution of all
row anchors; instead only install the overlay when
shouldCollectWorkspaceDropTargets is true. Replace the unconditional
rows.overlayPreferenceValue { … GeometryReader { …
bonsplitWorkspaceDropOverlay(...) } } with a conditional that returns rows as-is
when shouldCollectWorkspaceDropTargets is false and applies the
overlayPreferenceValue/GeometryReader/bonsplitWorkspaceDropOverlay only when
shouldCollectWorkspaceDropTargets is true (referencing overlayPreferenceValue,
SidebarWorkspaceRowFramePreferenceKey, GeometryReader,
bonsplitWorkspaceDropOverlay, shouldCollectWorkspaceDropTargets, and
renderContext.tabs to locate the code).

In `@Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift`:
- Line 55: The call to nsView.performPendingDropIfPossible() inside updateNSView
can synchronously invoke the closures wired there
(nsView.performExistingWorkspaceMove / performNewWorkspaceMove /
nsView.setDropIndicator) which mutate `@Binding` state (selectedTabIds,
lastSidebarSelectionIndex, dropIndicator) during the SwiftUI view update pass;
move the performPendingDropIfPossible invocation out of updateNSView so it runs
after the current runloop tick (e.g. dispatch async to main queue or schedule
from the NSView/Coordinator callback) ensuring updateNSView only wires the
closures and does not trigger them synchronously.
- Around line 136-168: performDragOperation currently accepts a transfer and
performs the move but doesn't clear the cached pendingDrop, and
draggingExited/concludeDragOperation return early without clearing it, allowing
a stale PendingDrop to be replayed by performPendingDropIfPossible; update
performDragOperation to nil-out pendingDrop after a successful accept (i.e., in
the accepted-transfer branch after perform(action:transfer:)), and also clear
pendingDrop in draggingExited and concludeDragOperation teardown paths so the
stored PendingDrop cannot outlive the gesture and be replayed later by
performPendingDropIfPossible or updateNSView.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 71a08416-49a1-4f24-83f1-292fbf0fdaf6

📥 Commits

Reviewing files that changed from the base of the PR and between d5f7959 and 9085835.

📒 Files selected for processing (2)
  • Sources/ContentView.swift
  • Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift Outdated
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Comment thread Sources/ContentView.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread Sources/ContentView.swift Outdated
@greptile-apps

greptile-apps Bot commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates SidebarWorkspaceRowFramePreferenceKey anchor emission and the overlayPreferenceValue reader behind a new SidebarDropPlanner.shouldCollectWorkspaceDropTargets policy, stopping the sidebar from forcing SwiftUI preference/layout work on every unrelated state change when no drag is active. A TargetBridge class decouples SwiftUI frame delivery from AppKit drop completion, and a stable outer overlay preserves NSView identity across gate flips.

  • Collection gate: SidebarWorkspaceFrameAnchorModifier wraps each row/group-header so anchors are only emitted during an active sidebar drag or Bonsplit workspace drop; the overlayPreferenceValue reader is installed on the same condition.
  • Pending-drop path: When performDragOperation fires before frames arrive, a PendingDrop is queued; TargetBridge.updateTargets signals performPendingDropIfPossible on the next main-queue turn when frames land. A bounded recursive main-queue poll in completeOrClearPendingDropAfterDragTeardown acts as a teardown safety net but introduces a timing-based synchronization dependency (see inline comment).
  • Tests: Three new policy-gate unit tests in SidebarWorkspaceDropPlannerTests.swift verify the no-drag, sidebar-drag, and Bonsplit-drop cases.

Confidence Score: 4/5

Safe to merge with one follow-up: the teardown polling path can silently discard a Bonsplit workspace move on a loaded system before the real signal path is hardened.

The collection gate, TargetBridge signal path, and NSView-identity fix are all correct and well-tested. The one issue is completeOrClearPendingDropAfterDragTeardown, which bets on SwiftUI delivering frames within 3 main-queue turns after drag teardown. Under load with 100+ workspaces the preference update can take longer; if it does, a user who successfully drops a Bonsplit tab sees nothing move. The TargetBridge signal path already handles the normal case, so this polling is a timing-dependent heuristic with a hidden failure mode.

Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift — specifically the completeOrClearPendingDropAfterDragTeardown teardown path.

Important Files Changed

Filename Overview
Sources/Sidebar/SidebarDropPlanner.swift Adds the shouldCollectWorkspaceDropTargets policy seam — a trivial boolean combinator with correct logic and matching tests.
cmuxTests/SidebarWorkspaceDropPlannerTests.swift Adds three focused unit tests covering the no-drag, sidebar-drag, and Bonsplit-drop policy branches; straightforward and correct.
Sources/VerticalTabsSidebar+WorkspaceGroups.swift Threads shouldCollectWorkspaceDropTargets to group headers and applies the stable SidebarWorkspaceFrameAnchorModifier; no issues.
Sources/ContentView.swift Adds isBonsplitWorkspaceDropTargetCollectionActive state, splits workspaceRows into gated reader + stable Bonsplit overlay, and introduces SidebarWorkspaceFrameAnchorModifier to gate per-row anchor emission. Architecturally sound; the TargetBridge @State and always-mounted overlay correctly preserve NSView identity across gate flips.
Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift Largest change: adds TargetBridge, TargetWriter, PendingDrop, and deferred-completion machinery. The TargetBridge signal path (updateTargets → performPendingDropIfPossible) is correct, but completeOrClearPendingDropAfterDragTeardown introduces a bounded recursive main-queue polling loop as a timing-based safety net, which violates the blocking/timing synchronization rule.

Sequence Diagram

sequenceDiagram
    participant OS as macOS Drag Session
    participant NSView as SidebarBonsplitTabWorkspaceDropView
    participant Bridge as TargetBridge
    participant SW as SwiftUI (TargetWriter)
    participant State as VerticalTabsSidebar @State

    OS->>NSView: draggingEntered
    NSView->>State: setWorkspaceDropTargetCollectionActive(true)
    State->>SW: overlayPreferenceValue reader installed
    SW->>Bridge: updateTargets([...frames...])
    Bridge-->>NSView: (stores targets)

    OS->>NSView: performDragOperation (targets empty)
    NSView->>NSView: "pendingDrop = PendingDrop(requestId, point, transfer)"
    NSView-->>OS: return true (deferred)

    SW->>Bridge: updateTargets([...frames arrive...])
    Bridge->>NSView: async performPendingDropIfPossible()
    NSView->>NSView: perform(action, transfer)
    NSView->>State: setWorkspaceDropTargetCollectionActive(false)
    State->>SW: overlayPreferenceValue reader removed
    SW->>Bridge: clearTargets()

    Note over OS,NSView: Teardown path (draggingExited / concludeDragOperation with pendingDrop set)
    OS->>NSView: draggingExited
    NSView->>NSView: "completeOrClearPendingDropAfterDragTeardown(remainingFrameWaits=3)"
    loop up to 3 main-queue hops
        NSView->>NSView: targets.isEmpty? recurse
    end
    NSView->>NSView: clearPendingDrop() or performPendingDropIfPossible()
    NSView->>State: setWorkspaceDropTargetCollectionActive(false)
Loading

Reviews (3): Last reviewed commit: "fix(sidebar): keep Bonsplit drop overlay..." | Re-trigger Greptile

Comment thread Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift
Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/VerticalTabsSidebar+WorkspaceGroups.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Sources/ContentView.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread Sources/ContentView.swift
@cubic-dev-ai

cubic-dev-ai Bot commented Jun 4, 2026

Copy link
Copy Markdown

You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment @cubic-dev-ai review.

…target gate flips

PR #5325 gates the row-frame anchor collection to active drag, but returns the
drop-capture NSView from both branches of the if/else (it lives inside
rowsWithDropOverlay). The two branches have distinct SwiftUI identity, so the
gate flip on draggingEntered tears down and recreates SidebarBonsplitTabWorkspaceDropView
mid-drag, orphaning the in-flight drag (the pendingDrop/requestId/frame-wait
machinery exists to paper over this).

Move the overlayPreferenceValue reader (the virtualization-defeating part) into
a gated helper applied to the rows, and mount the drop-capture overlay at the
stable outer level so its identity never changes when the gate flips.
Virtualization is preserved (the reader stays conditional); only the consumer
NSView becomes identity-stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor

Reviewed this against the #2586 diagnosis, built it locally, and dogfooded drag-and-drop. The fix is correct and the right one — gating the per-row .anchorPreference(.bounds) collection restores LazyVStack virtualization, which is the dominant cost of the sidebar beachball. Both row and group-header emitters are covered; the Bonsplit capture-overlay + TargetBridge + deferred replay keep cross-window drops working.

One fix pushed (beb318ef2)

Drop NSView identity instability across the gate flip. The drop-capture SidebarBonsplitTabWorkspaceDropView was inside rowsWithDropOverlay, which is returned from both branches of if shouldCollectWorkspaceDropTargets { … } else { … }. The two branches have distinct SwiftUI identity, so the gate flip on draggingEntered (shouldCollect false→true) tore down and recreated the drop NSView mid-drag, orphaning the in-flight drag — which is what the pendingDrop/requestId/frame-wait machinery is papering over.

Fix: moved the overlayPreferenceValue reader (the virtualization-defeating part) into a gated helper applied to the rows, and mounted the drop-capture overlay at the stable outer level so its identity never changes when the gate flips. Virtualization is preserved (the reader stays conditional); only the consumer NSView becomes identity-stable. Dogfooded: local reorder + cross-window Bonsplit drop both work, including drops at the gate-flip moment and onto just-scrolled-in rows.

Follow-ups (not blocking; not yet applied — they're drag-timing/cleanup that want their own verification)

  • completeOrClearPendingDropAfterDragTeardown polls 3 frames then silently clears — a Bonsplit drop can be lost if row anchors take >3 frames to publish (large list / mid-scroll). The real "targets arrived" signal already exists in TargetBridge.updateTargets; driving teardown off that would remove both the lost-drop and the wasted-poll cases.
  • Duplicate pending-drop kick (updateNSView :96 and TargetBridge.updateTargets :14 both schedule performPendingDropIfPossible on the same condition).
  • isWorkspaceDropTargetCollectionActive prop is a round-tripped mirror; its only use (clearPendingDropIfIdle) already self-guards on the NSView's authoritative isRequestingWorkspaceDropTargets.
  • bonsplitWorkspaceDropOverlay() rebuilds ~7 escaping closures per workspaceRows body pass via the always-on .overlay.

Process note: I could not run an after-profile through the usual harness (instance conflict during the run), but the perf win holds by construction — the reader stays gated, so at rest the all-row anchor collection is not installed.

@lawrencecchen

Copy link
Copy Markdown
Contributor

Fresh field evidence: a real 15.5 s hang spindump, scroll-triggered, same LazySubviewPlacements loop this PR fixes

A user force-quit a beachballed nightly during sidebar scroll and captured the macOS hang report. It corroborates this PR's root cause directly.

  • Build: 0.64.12-nightly.2693970953401 (pre-Fix sidebar workspace frame collection virtualization #5325 — has the bug, not the fix), macOS 26.5, Mac17,6, 128 GB.
  • Event: hang, Duration: 15.55s (unresponsive ~14 s before sampling).
  • Main thread is CPU-bound spinning — 1.19 s of CPU in the 1.3 s sample window — i.e. grinding, not deadlocked. 100% of main-thread samples are inside one SwiftUI ViewGraph transaction.

Main-thread stack (trimmed) — bottoms out in the sidebar LazyVStack placement/measure loop:

NSApplication run → _DPSNextEvent → __CFRunLoopDoObservers → NSRunLoop.flushObservers
 → NSHostingView.beginTransaction → ViewGraphRootValueUpdater.updateGraph
  → GraphHost.flushTransactions → runTransaction → AG::Subgraph::update → UpdateStack::update
   → LazySubviewPlacements.updateValue → .placeSubviews
    → _LazyLayoutViewCache.withMutableCacheState
     → LazyStack.place → ForEachList.applyNodes → ForEachState.forEachItem
      → (nested) ModifiedViewList.applyNodes ×4 → BaseViewList.applyNodes
       → StackPlacement.place → LazyHVStack.lengthAndSpacing → LayoutEngineBox.lengthThatFits
   (sibling branch) → LayoutEngineBox.childGeometries(at:origin:) → ViewLayoutEngine.childGeometries

This is the #5323 mechanism — per-row frame collection defeating LazyVStack virtualization so a placement pass re-measures the whole list — and scroll is the trigger: scrolling drives LazySubviewPlacements.placeSubviews, which with all-row frame collection re-lays-out every row instead of the ~viewport, producing a multi-second hang.

This PR gates that collection to active drag, so a no-drag scroll pass only places visible rows. The hang can't be re-measured on this exact build (it predates the PR), but it pins the failure mode and the scroll trigger.

Caveat: the nightly is a release build with no dSYM available to the OS spindump, so the app's own frames (VerticalTabsSidebar.workspaceRows, the overlayPreferenceValue reader) aren't symbol-named — but the SwiftUI LazyVStack/ForEachList layout signature during sidebar scroll is unambiguous. Full report retained locally.

@lawrencecchen
lawrencecchen dismissed stale reviews from coderabbitai[bot] and coderabbitai[bot] June 4, 2026 09:22

Stale: reviewed an older commit (d5f7959). The 3 points are addressed/guarded in the current tip (beb318e) — reader gated, drop kicks deferred via DispatchQueue.main.async, stale-replay guarded by requestId.

@austinywang
austinywang merged commit 572e948 into main Jun 4, 2026
20 checks passed
@lawrencecchen

Copy link
Copy Markdown
Contributor

@azooz2003-bit heads up — the workspace-sidebar virtualization issue (#2586 / #5323) was causing real hard hangs: a user hit a 15.5s beachball just scrolling the sidebar (CPU-bound spinning in the LazyVStack placeSubviews / ForEachList layout loop, captured in a hang spindump).

This just merged here in #5325 (572e9483) — it gates the per-row drop-target .anchorPreference(.bounds) collection to active-drag, so idle/scroll passes stop re-measuring every workspace row and LazyVStack virtualization is restored.

Since you did the recent sidebar lazy + @Observable dragState work in #4736, you might want to look over the merged change when you get a chance — especially the drop-overlay identity handling and the deferred Bonsplit drop replay.

lawrencecchen added a commit that referenced this pull request Jun 9, 2026
…lock (#2586) (#5708)

* Remove sidebar row-ids preference aggregation that fed the layout livelock

The workspace sidebar published SidebarWorkspaceRowIdsPreferenceKey from
every row and group header, reduced it sidebar-wide on every layout pass,
and wrote the aggregated set into @State from onPreferenceChange to gate
selected-workspace scroll-into-view on "row is laid out". During lazy
layout churn (scroll, tab switch) that preference value changes on every
pass, so each pass scheduled another transaction from inside
GraphHost.flushTransactions; once the cycle stopped converging the main
thread never returned to the run loop (100% CPU, unbounded autorelease
growth, CLI dispatch_sync deadlock).

ScrollViewProxy.scrollTo resolves .id() values in lazy containers without
the row being realized, and unknown ids are a no-op, so the laid-out
gate is unnecessary: scroll pending requests unconditionally and delete
the preference key, the per-row emitters, the sidebar-wide
onPreferenceChange, and the laidOutWorkspaceRowIds state.

Same class-elimination as #5325 did for the sibling
SidebarWorkspaceRowFramePreferenceKey (drop-target anchors), which no
longer appears in hang samples since it shipped.

Issue: #2586

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scroll to the group header when the selected workspace is in a collapsed group

Codex review: a member of a collapsed group renders no row, so scrollTo
with its UUID is a no-op, and clearing the pending request dropped the
old deferred-scroll-on-expand behavior. Resolve the scroll target from
render-context data instead: hidden members target their group header
(anchor workspace id), which is always present. Never decided from what
the lazy layout realized, so it cannot re-enter the preference/layout
feedback cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move collapsed-group scroll target into SidebarSelectedWorkspaceScrollPolicy

Keeps ContentView.swift under the Swift file length budget
(workflow-guard-tests) and makes the collapsed-group fallback unit
testable. Adds coverage: no group / expanded group target the workspace
itself, collapsed group targets the group anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Import Foundation in SidebarState for UUID

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
…ions branch

Dogfood verdict on the NSDraggingSession live reorder: jank. The logged
root cause is structural — per-row drop delegates hit-test rows that the
reorder itself animates, so every fix was a damping patch on a feedback
loop, and the system drag image (the floating snapshot) is not the
interaction Lawrence wants anyway: the row itself should track the
pointer's Y with X pinned to the sidebar.

This merges the task-sidebar-reorder-gesture engine (#5226) and adapts
it to this branch:

- A DragGesture owns the whole interaction. The picked-up row renders
  invisible in the list; a 1:1 follower copy paints over the list, glued
  to the cursor Y (X fixed), with implicit animations disabled. No
  NSDraggingSession, no drag image.
- The model never mutates mid-drag: the list reflows to a preview
  (dragPreviewItems) so the gap animates open, and the single
  reorderSidebarWorkspace commit happens on release. The feedback-loop
  class is gone by construction — committed row frames are frozen during
  the drag, so hit-testing never sees its own preview.
- Hysteresis (SidebarReorderIndicatorResolver) holds the landing slot
  near row midpoints.
- Branch adaptations: ForEach identity stays on representedWorkspaceId
  so the promote/ungroup morph keeps working; the #5325 gated bonsplit
  drop-target reader and always-mounted overlay are preserved; group
  headers keep the rollup badge and reorder through the same gesture.
- Escape cancels: a clear request latches cancelledReorderTabId so the
  still-alive gesture cannot re-begin, and since nothing mutated there
  is nothing to restore — the TabManager drag-restore snapshot API and
  its tests are removed as obsolete.
- Autoscroll: each tick reports its actual scroll delta and the reorder
  advances the cursor by it, keeping the follower under the pointer and
  the landing slot moving while the pointer rests at a list edge.

Known regression, accepted for now: dragging a workspace to another
window's sidebar no longer works (there is no NSDraggingSession to
carry it); the context menu's Move to Window remains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
#5708,
#5846,
#5855, and
#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
#5325 (frame anchors) and
#5708 (row IDs) removed their siblings.

Same approach as #5852, re-ported on
top of the #5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes #5764.
Helps #2586,
#5570,
#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for #5764,
#5845,
#2586; complements the structural
loop fix in #6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (#5764,
#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…(Fix 5: F9, F11)

F9 — SidebarWorkspaceFrameAnchorModifier used if/else around `content`, so the
drop-target frame anchor was structurally present only while collecting. Flipping
isEnabled at drag start/end (shouldCollectWorkspaceDropTargets) gave every
materialized row a new structural identity, recreating each TabItemView subtree
(dropping per-row @State, forcing fresh snapshot builds + relayout) inside the
drag transaction. Make it branchless: always apply anchorPreference, emit
`isEnabled ? [id: anchor] : [:]`. Row identity stays stable across the flip; the
emitted preference is byte-identical to before while dragging, and the
virtualization-defeating *reader* stays gated on the drag (#5325), so emitting an
empty dict when idle costs nothing.

F11 — SidebarWorkspaceDescriptionText carried `.id(description)`, keying the
view's identity to agent-updated text, so every description rewrite tore down and
recreated the view (markdown re-parse, layout caches) on top of the in-place
re-render the text change already triggers. Delete it; the view updates in place.

Verified on tag rowapat: simulated sidebar drag drives the frame-anchor path
(drop indicator tracked across all steps, socket 31ms, zero layout-loop symbols);
markdown description renders bold/italic and wraps with no .id.

Deferred from Fix 5 with rationale: F8 (per-row rowHeight @State feeding the drop
delegate) and F13 (per-row Finder .task pre-warm) — both now low-value post-#6026
(rows are height-stable) and both carry real regression surface (reorder
hit-testing via a SidebarTabDropDelegate interface change; Reveal-in-Finder is
`.disabled(url == nil)` gated on the pre-warm), so they warrant a focused
follow-up rather than riding with these contained changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…lazy Finder resolve (Fix 5: F9/F11/F13) (#6052)

* Sidebar: branchless frame-anchor modifier + drop the description .id (Fix 5: F9, F11)

F9 — SidebarWorkspaceFrameAnchorModifier used if/else around `content`, so the
drop-target frame anchor was structurally present only while collecting. Flipping
isEnabled at drag start/end (shouldCollectWorkspaceDropTargets) gave every
materialized row a new structural identity, recreating each TabItemView subtree
(dropping per-row @State, forcing fresh snapshot builds + relayout) inside the
drag transaction. Make it branchless: always apply anchorPreference, emit
`isEnabled ? [id: anchor] : [:]`. Row identity stays stable across the flip; the
emitted preference is byte-identical to before while dragging, and the
virtualization-defeating *reader* stays gated on the drag (#5325), so emitting an
empty dict when idle costs nothing.

F11 — SidebarWorkspaceDescriptionText carried `.id(description)`, keying the
view's identity to agent-updated text, so every description rewrite tore down and
recreated the view (markdown re-parse, layout caches) on top of the in-place
re-render the text change already triggers. Delete it; the view updates in place.

Verified on tag rowapat: simulated sidebar drag drives the frame-anchor path
(drop indicator tracked across all steps, socket 31ms, zero layout-loop symbols);
markdown description renders bold/italic and wraps with no .id.

Deferred from Fix 5 with rationale: F8 (per-row rowHeight @State feeding the drop
delegate) and F13 (per-row Finder .task pre-warm) — both now low-value post-#6026
(rows are height-stable) and both carry real regression surface (reorder
hit-testing via a SidebarTabDropDelegate interface change; Reveal-in-Finder is
`.disabled(url == nil)` gated on the pre-warm), so they warrant a focused
follow-up rather than riding with these contained changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: resolve Finder dir at context-menu time, drop per-row pre-warm (Fix 5: F13)

Each TabItemView ran `.task(id: finderDirectoryCacheKey)` that stat'd the
workspace's directory (FileManager.fileExists) on every row appearance, just to
populate the `.disabled(finderDirectoryURL == nil)` gate on the "Show in Finder"
context-menu item. Scrolling N workspaces issued N disk stats + N deferred row
invalidations.

"Show in Finder" already re-validates the directory at click time
(WorkspaceFinderDirectoryOpener.openInFinder), so the pre-warm only gated the
item's enabled state. Resolve it synchronously while the context menu builds
(one stat per right-click, user-initiated) via the new
WorkspaceFinderDirectoryResolver.existingDirectoryURLNow. Removes the per-row
@State, the `.task`, and the now-dead WorkspaceFinderDirectoryCache /
CacheKey / cache(for:). Behavior unchanged: item disabled when the dir is
missing, reveals it when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: read row height from a reference cell, not @State (Fix 5: F8)

Each sidebar row (TabItemView) and group header (SidebarWorkspaceGroupHeaderView)
kept its measured height in @State, written from a background GeometryReader on
appearance (a guaranteed 1 -> realHeight write) and on every later height change.
That write forced a second full body evaluation of the row inside the lazy
placement pass — and the write was load-bearing: the drop delegate captured the
height at body-eval time, so without the re-eval it would have been stuck at 1.

Replace @State with SidebarRowHeightStore, a tiny reference cell the probe
mutates in place (no invalidation). The drop delegates
(SidebarTabDropDelegate, SidebarWorkspaceGroupHeaderDropDelegate) now hold the
store and read `height` LAZILY at drop time via a computed property, so they
always see the current measurement without a body re-eval reconstructing them.
Drop targets with no measured row (empty area, first-row gap) pass a nil store;
the extension-sidebar delegates keep their fixed constants.

Height value at drop time is unchanged (same measurement the probe always fed),
so reorder hit-testing behavior is preserved — only the read path changes.
Verified at runtime: drags over rows + group header mount the delegates, app
responsive (64ms), zero layout-loop symbols, sidebar renders. Real-drag drop
position needs human dogfood (no socket path drives DropInfo hit-testing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* F13 fix: gate Show-in-Finder on configured path, no body-time stat (autoreview P1)

The previous F13 commit resolved the Finder directory with a synchronous
existence stat in the context-menu builder, assuming it ran only on right-click.
SwiftUI evaluates the `.contextMenu { }` items as part of the row body, so that
reintroduced a synchronous FileManager.fileExists on the main actor for every
row body eval of a configured workspace — exactly the hot-path filesystem IO the
sidebar avoids.

Gate the menu item on whether a directory is configured
(WorkspaceFinderDirectoryResolver.path, in-memory, IO-free) and defer the stat
to the click action, where WorkspaceFinderDirectoryOpener already re-validates
and beeps if the directory is gone. Removes the synchronous helper. Behavior
unchanged for existing directories; a configured-but-deleted directory now shows
an enabled item that beeps on click instead of a disabled item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert "Sidebar: read row height from a reference cell, not @State (Fix 5: F8)"

This reverts commit 3aaf643.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
austinywang added a commit that referenced this pull request Jun 29, 2026
…#6870)

* Guard sidebar LazyVStack against re-livelock at scale (#6384)

The workspace-sidebar rows render as a LazyVStack inside a vertical
ScrollView. Keeping that stack lazy at *measure time* is load-bearing:
the sidebar is re-diffed on every workspace/telemetry update, so any code
that forces SwiftUI to realize and measure the whole row list on each
layout pass turns a routine update into a multi-second
GraphHost.flushTransactions() main-thread livelock once enough
workspaces/surfaces are open. That is exactly the ~1s beachball reported
in #6384.

The root cause -- SidebarRowsFillLayout, a custom Layout that called
subviews.first?.sizeThatFits(ProposedViewSize(width:, height: nil)) on
the LazyVStack every pass -- was removed in #6188 (#6210), and the rows
are lazy again in main. But this same class of bug has now regressed four
times (#2586, #5764, #5845, #6033 -> #6210/#6384) and is defended only by
inline comments, which CI cannot enforce.

Add a source-scan regression guard so the contract fails CI on
re-introduction:

- scripts/check-sidebar-lazy-layout.py neutralizes comments/string
  literals (the guarded functions deliberately *name* the forbidden
  anti-patterns in explanatory comments), extracts the bodies of
  workspaceScrollContent and workspaceRows from Sources/ContentView.swift,
  and fails if either reintroduces a whole-list measurement signature
  (GeometryReader, ProposedViewSize(..., nil), .sizeThatFits(, or
  SidebarRowsFillLayout) or drops a lazy-fill primitive the fix relies on
  (LazyVStack( in workspaceRows, .frame(minHeight:) in
  workspaceScrollContent). A renamed/removed guarded function fails loudly
  rather than silently skipping.

- tests/test_ci_sidebar_lazy_layout_guard.py proves the guard catches the
  bug: it passes the real repo and a clean fixture whose comments/strings
  name every forbidden token, and fails synthetic fixtures for each
  regression mode (force-measure, reintroduced custom Layout,
  GeometryReader, eager VStack, missing minHeight, renamed function).

- Wire the self-test into the workflow-guard-tests CI job.

The drag-only drop-target reader (rowsWithGatedDropTargetReader) is not
scanned: it intentionally uses a GeometryReader to resolve per-row drop
anchors and is gated behind an active drag (#5325), so it never runs
during the steady-state layout this guard protects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Guard: handle Swift multi-line string literals in neutralize_swift

Greptile P2 (#6870 review): neutralize_swift treated every `"` as a
regular string boundary, so a Swift multi-line string `"""..."""` parsed
as two empty strings plus an unclosed string. A bare `"` inside such a
literal (e.g. `"""... he said "GeometryReader" ..."""`) would close the
outer string early and expose the remaining content -- including a
forbidden token named in prose -- as apparent code, tripping the guard
with a false positive and a spurious CI failure.

Add a MULTILINE_STRING tokenizer state: `"""` opens it, only a closing
`"""` ends it, and a lone `"` inside is neutralized like any other string
content. Add self-test case (b2) with a multi-line string containing a
bare quote plus GeometryReader / sizeThatFits(ProposedViewSize(height:
nil)) / SidebarRowsFillLayout, asserting the guard still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Guard: ban any custom Layout applied to the sidebar rows, not just by name

Codex autoreview P2 (#6870): the guard only banned the literal deleted
type name `SidebarRowsFillLayout`, so a future regression could wrap
`workspaceRows(...)` in a differently named custom `Layout` (with the
`subview.sizeThatFits(ProposedViewSize(... height: nil ...))` body living
outside the two scanned functions) and CI would pass -- the exact #6033
shape under a new name.

Generalize the guard: discover every type conforming to SwiftUI's
`Layout` protocol across the whole Sources/ tree (comment/string-
neutralized, pre-filtered to files that mention `Layout`), then fail if
ANY of those type names is applied within `workspaceScrollContent` /
`workspaceRows`. A custom Layout wrapping the LazyVStack measures it on
every pass regardless of the type's name; rows must be sized by
`.frame(minHeight:)` instead. The literal-name and direct force-measure
token bans are kept as belt-and-suspenders.

Add self-test case (d2): a `struct RowsFillLayout: Layout` (NOT the old
name) whose force-measure lives in the layout type, applied to the rows
in `workspaceScrollContent`; the guard must fail it. Without the
generalization this is a false negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Guard: discover custom Layouts across Packages/ too, document scope

Codex autoreview P2 (#6870): custom-Layout discovery scanned only
Sources/, but cmux migrates app code into Packages/. A force-measuring
sidebar layout defined in a repo-owned package and applied in
workspaceScrollContent would not be discovered, leaving the guard
bypassable exactly where code is moving.

- Replace the Sources-only glob with repo_owned_swift_files(), which walks
  both Sources/ and Packages/ and prunes build/VCS/vendored dirs
  (.build, .git, DerivedData, Vendor, Pods, Carthage, node_modules, ...).
  External-dependency Layouts remain out of scope by design.
- Add self-test case (i): repo_owned_swift_files() covers Sources/ and
  Packages/, discovers their Layout types, and excludes a .build/checkouts
  vendored Layout.
- Document the guard's scope boundary: it protects the rows layout as
  expressed in workspaceScrollContent/workspaceRows and does not chase a
  force-measure relocated into an arbitrary transitively-called helper
  (fragile to track in a lint; such an extraction should re-review this
  guard). Custom Layout types are the exception chased across files, since
  a renamed force-measuring layout is the concrete #6033 regression.

Real-repo scan stays ~2s (Layout-substring pre-filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: cmux <cmux@cmuxs-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
manaflow-ai/cmux#5708,
manaflow-ai/cmux#5846,
manaflow-ai/cmux#5855, and
manaflow-ai/cmux#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
manaflow-ai/cmux#5325 (frame anchors) and
manaflow-ai/cmux#5708 (row IDs) removed their siblings.

Same approach as manaflow-ai/cmux#5852, re-ported on
top of the manaflow-ai/cmux#5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes manaflow-ai/cmux#5764.
Helps manaflow-ai/cmux#2586,
manaflow-ai/cmux#5570,
manaflow-ai/cmux#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
manaflow-ai/cmux#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845,
manaflow-ai/cmux#2586; complements the structural
loop fix in manaflow-ai/cmux#6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — beb318ef Deployed Jun 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants