Skip to content

Fix sidebar scroll layout livelock - #8211

Merged
lawrencecchen merged 8 commits into
mainfrom
issue-6707-sidebar-freeze-livelock
Jul 16, 2026
Merged

lawrencecchen merged 8 commits into
mainfrom
issue-6707-sidebar-freeze-livelock

Conversation

@austinywang

@austinywang austinywang commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6707.

What changed

  • Removed the ScrollView.onGeometryChange -> @State workspaceScrollContentMinHeight feedback edge. Viewport geometry is now a downward-only input to the scroll content layout.
  • Established an actual snapshot boundary above the sidebar LazyVStack: VerticalTabsSidebar owns all live Workspace/store observation and builds immutable value snapshots; workspace and group rows receive values plus action closures only.
  • Removed TabItemView ownership of live workspace/store references, bindings, row snapshot @State, the body-mutated snapshot scratch box, per-row Combine subscriptions, and per-row async observation tasks.
  • Made render identities UUID-based and moved snapshot construction out of lazy row realization.
  • Extended the sidebar lazy-layout policy check and scale probes so live observable state cannot silently cross this boundary again.

Root cause

This is a SwiftUI update/layout feedback loop, not a Ghostty render deadlock.

The field capture shows 6,710/6,734 main-thread samples in NSRunLoop.flushObservers / NSHostingView.beginTransaction, 6,701 in GraphHost.flushTransactions, and 5,072 in AG::Subgraph::update. The same capture reports 1,532 reentrant NSHostingView layout faults in three hours, 97% CPU over 92 seconds, and +503.84 MB physical footprint in 87.5 seconds while Ghostty renderer threads slept.

The remaining cycle after #7117 was visible in code:

  1. The scroll viewport geometry callback synchronously wrote workspaceScrollContentMinHeight during the hosting view layout transaction, invalidating the same scroll/lazy graph being measured.
  2. Lazy row realization mounted TabItemView instances that each retained live models/bindings and started publishers/tasks. Their initial and CLI-driven emissions wrote row @State snapshots while LazyVStack was placing/retiring rows. The snapshot getter also filled a reference scratch box from a body computation.
  3. set_status / clear_status mutations change the row projection and sometimes its height while scrolling crosses realization boundaries. Those invalidations re-entered placement, recreated row observation, and scheduled another graph transaction. On the bad interleaving the graph never converged and allocated as it spun.

PR #7117 correctly removed the row GeometryReader, per-row AppKit hover hosts, and always-mounted drop machinery, which made each iteration cheaper. It did not remove the viewport state write or the live per-row observation/state lifecycle, so it reduced the frequency/cost without eliminating the cycle.

The pointer-frame path was also audited: its frame registries are already @ObservationIgnored and observable hover reconciliation is deferred beyond the geometry callback, so it is not another synchronous layout-state edge.

Reproduction and evidence boundary

I did not manufacture a red test result.

The first commit is therefore deliberately named a guard, not a reproducer. It adds the two stress/liveness workloads and the exact layout-fault/convergence assertions, but the field sample and logs remain the definitive reproduction evidence.

Validation

Fixed-HEAD focused runs:

Static checks completed locally without invoking xcodebuild:

  • parsed all 22 touched Swift files with swiftc -frontend -parse
  • python3 scripts/check-sidebar-lazy-layout.py
  • ./scripts/check-pbxproj.sh
  • ./scripts/lint-pbxproj-test-wiring.sh (509 test files)
  • python3 scripts/check-workspace-package-groups.py --check
  • git diff --check origin/main...HEAD
  • parsed Resources/Localizable.xcstrings with jq

The requested Swift file-length script/TSV do not exist on current main; no budget file was generated or edited. Every new Swift file is below 500 lines (largest: 339), the existing UI test file remains below 500 lines (468), and ContentView.swift shrank. .github/swift-warning-budget.tsv is untouched.

python3 scripts/check-package-resolved-policy.py currently fails on unchanged origin/main because ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved is classified as an unexpected location; this branch changes no lockfile or ignore policy.

Localization audit

No user-facing strings or localization catalogs changed. Relocated UI code continues to use existing String(localized:) keys, Resources/Localizable.xcstrings parses successfully, and the diff introduces no new runtime-facing English copy.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes a sidebar scroll livelock that froze the app while scrolling (Fixes #6707). Keeps the layout responsive under status churn, preserves LazyVStack scaling, coalesces snapshot updates, and serves context menus from immutable snapshots with fresh window targets and indexed notifications.

  • Bug Fixes

    • Removed viewport geometry → state write; geometry is input-only for layout.
    • Stopped live model/binding observation inside lazy rows to prevent reentrant invalidations.
    • Preserved virtualization: stable IDs, Equatable rows, and guards/tests to keep bounded realization.
    • After drop, target selection runs via a closure; keeps list diff-only.
    • Coalesced workspace snapshot refreshes on the main run loop to avoid intermediate publications.
    • Resolved stale window move targets by resolving window topology when the context menu opens.
  • Refactors

    • Introduced a snapshot boundary in VerticalTabsSidebar; rows mount via SidebarWorkspaceRowView/SidebarWorkspaceGroupRowView from immutable values with action closures.
    • Added SidebarWorkspaceSnapshotFactory, row/group/input/context-menu snapshots, and row actions; menus read from snapshots and actions.
    • Centralized row-affecting workspace observation above the lazy list via sidebarWorkspaceObservations(...); removed unused observation conformance.
    • Indexed notifications by workspace (SidebarWorkspaceNotificationIndex) for constant-time presence checks and bounded context-menu lists.

Written for commit cb24937. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved sidebar responsiveness during frequent status updates and heavy scrolling, reducing layout reentry and view-update faults.
    • Stabilized sidebar scrolling/sizing behavior to prevent layout feedback loops.
    • Fixed sidebar selection flow after cross-workspace drag-and-drop operations.
  • Improvements
    • Made workspace row rendering and context-menu content more consistent by driving UI from stable precomputed state.
    • Updated menu behaviors for reconnect/disconnect, pin/custom naming, and notifications to match what’s displayed.
  • Tests
    • Added/expanded contract, UI, and indexing test coverage for lazy sidebar rendering and window-target resolution.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@austinywang, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9e246a62-2bcf-4ca9-b410-0d335817e754

📥 Commits

Reviewing files that changed from the base of the PR and between a5f0f7b and cb24937.

📒 Files selected for processing (9)
  • Sources/ContentView.swift
  • Sources/SidebarWorkspaceContextMenuTargetAggregate.swift
  • Sources/SidebarWorkspaceNotificationIndex.swift
  • Sources/SidebarWorkspaceRowInput.swift
  • Sources/SidebarWorkspaceRowsSnapshot.swift
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarLazyLayoutScaleTests.swift
  • cmuxTests/SidebarWorkspaceNotificationIndexTests.swift

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

The sidebar now constructs immutable workspace snapshots and injected row actions before LazyVStack realization. Workspace menus, grouping, checklist, notifications, drag/drop, and styling consume those values, while geometry handling and scroll/status-churn regression coverage enforce the lazy-layout contract.

Changes

Sidebar lazy-layout architecture

Layer / File(s) Summary
Snapshot and render contracts
Sources/SidebarWorkspaceSnapshotFactory.swift, Sources/SidebarWorkspaceRow*.swift, Sources/SidebarWorkspaceGroupRow*.swift, Sources/SidebarWorkspaceRenderItem.swift, Sources/SidebarWorkspaceNotificationIndex.swift
Adds immutable workspace, row, group, context-menu, notification, and window-target projections, plus UUID-based render items and presentation helpers.
Parent snapshot orchestration and layout
Sources/ContentView.swift, Sources/WorkspaceSidebarObservation.swift, Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift
Caches and coalesces parent-owned snapshots, refreshes them from targeted observations, passes projections into lazy rows, and computes scroll content height from viewport geometry.
Snapshot-bound rows and interactions
Sources/ContentView.swift, Sources/SidebarWorkspaceRowActions.swift, Sources/TabItemView+Workspace*.swift, Sources/VerticalTabsSidebar+WorkspaceGroups.swift, Sources/Sidebar/SidebarBonsplit*
Moves row rendering, menus, grouping, todo actions, notifications, drag/drop, navigation, and lifecycle behavior to snapshots and injected closures.
Validation and build wiring
cmuxTests/*, cmuxUITests/WorkspaceSidebarScrollUITests.swift, scripts/check-sidebar-lazy-layout.py, cmux.xcodeproj/project.pbxproj
Registers new sources, expands lazy-layout checks, tightens snapshot-boundary assertions, and adds scroll/status-churn regression coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • manaflow-ai/cmux-dev-artifacts#3856 — Concerns related sidebar interaction-lifecycle behavior.
  • manaflow-ai/cmux-dev-artifacts#4044 — Covers the sidebar status-churn regression path added here.
  • manaflow-ai/cmux-dev-artifacts#4059 — Covers related sidebar lazy-layout and reentrant-rendering behavior.
  • manaflow-ai/cmux-dev-artifacts#4062 — Addresses overlapping sidebar freeze and lazy-layout behavior.

Possibly related PRs

Suggested reviewers: lawrencecchen


Important

Pre-merge checks failed

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

❌ Failed checks (2 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Package Boundaries ❌ Error FAIL: the PR adds pure sidebar snapshot/aggregation/coalescing types in app Sources/, even though this repo already has a CmuxSidebar package boundary for such logic. Move SidebarWorkspaceNotificationIndex, SidebarWorkspaceContextMenuTargetAggregate, SidebarWorkspaceRowInput/RowsSnapshot/SnapshotFactory, and SidebarWorkspaceRenderItem into Packages/macOS/CmuxSidebar (or a small `CmuxSidebarS...
Cmux No Test Or Debug Seam In Production Source ❌ Error PR adds a test-only sidebarLazyContractProbe hook and workspaceRowInputProjection seam in shipping Sources, with only test callers. Move the probe into Tests or a test-support target and use @testable import to read internal state; remove sidebarLazyContractProbe plumbing from shipping sources.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description has useful detail, but it misses required template sections for the demo video, review-trigger block, and checklist. Add the missing template sections and fill them out: Demo Video, Review Trigger copy/paste block, and the Checklist items.
✅ Passed checks (21 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes directly address #6707 by removing the scroll feedback loop and isolating lazy rows from live observations.
Out of Scope Changes check ✅ Passed Most additions support the sidebar-scroll fix; no clearly unrelated code changes stand out.
Cmux Swift Actor Isolation ✅ Passed New snapshot/action types are main-actor UI helpers or pure values; no new background-store access or unsafe shared mutable Sendable refs.
Cmux Swift Blocking Runtime ✅ Passed PASS: Diff adds no new blocking waits/sleeps/locks; the new coalescer uses a non-blocking next-run-loop callback, and waits are confined to tests.
Cmux Browser Automation Off-Main ✅ Passed PR only changes sidebar/layout files; it does not touch TerminalController or ControlCommandExecutionPolicy, so the browser-automation off-main rule isn’t implicated.
Cmux Expensive Synchronous Load ✅ Passed No diff hunk adds RestorableAgentSessionIndex/SharedLiveAgentIndex loads; sidebar menus now read immutable snapshots and closures, not history/transcript files.
Cmux Cache Substitution Correctness ✅ Passed PASS: the row snapshot cache has a cold fallback and event-driven invalidation, and lazy rows consume only immutable snapshots plus closures.
Cmux No Hacky Sleeps ✅ Passed Only non-Swift change is scripts/check-sidebar-lazy-layout.py, and its diff just expands guarded row types/docs—no sleeps, timers, polling, or waits were added.
Cmux Algorithmic Complexity ✅ Passed New sidebar paths cache snapshots, use Set/dictionary lookups, and keep row rendering linear; I found no new nested full-collection rescans in hot paths.
Cmux Swift Concurrency ✅ Passed No new background queues, completion handlers, or unmanaged tasks were introduced; added Tasks are SwiftUI/OS-callback bound, and Combine use is existing observation plumbing.
Cmux Swift @Concurrent ✅ Passed Touched async paths stay UI-bound on @MainActor or explicitly hop to Task.detached; no missing or invalid @concurrent use found.
Cmux Swiftpm Lockfiles ✅ Passed Diff only adds Swift sources to project.pbxproj; no .gitignore, Package.resolved, or SwiftPM package-reference changes appear, so the lockfile policy isn’t violated.
Cmux Swift Logging ✅ Passed Diff adds no production Swift print/debugPrint/dump/NSLog/Logger changes; touched source files contain none, and logging-like lines are only in tests/CLI code.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error copy appears in the diff; the remote help/copy strings were moved from ContentView, not newly introduced.
Cmux Full Internationalization ✅ Passed Touched UI strings use String(localized:defaultValue:), all referenced keys exist in Resources/Localizable.xcstrings, and no xcstrings/InfoPlist localization files changed.
Cmux Swiftui State Layout ✅ Passed Changed rows use immutable snapshots/actions; no new ObservableObject/@published was introduced, and the new GeometryReader only computes local minHeight.
Cmux Architecture Rethink ✅ Passed Rows stay behind a parent-owned snapshot boundary; the only timing piece is a bounded run-loop coalescer with cancelation, not a sleep/poll/extra owner.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new standalone Window/NSWindowController/NSPanel/WindowGroup code or cmuxAuxiliaryWindowIdentifiers changes were introduced; only menus and test-only fixture windows changed.
Cmux Source Artifacts ✅ Passed All changed paths are intentional source, test, script, and project config files; no logs, screenshots, caches, DerivedData, or scratch dirs were added.
Cmux No Ambient Global State ✅ Passed No new ambient global state in production: added helpers are constructable/injectable types, and no new top-level API funcs/vars or singletons were introduced.
Title check ✅ Passed The title clearly summarizes the primary change: fixing the sidebar scroll livelock.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6707-sidebar-freeze-livelock

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.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR correctly fixes a SwiftUI layout feedback loop (issue #6707) where onGeometryChange → @State workspaceScrollContentMinHeight was writing new state synchronously during the NSHostingView layout transaction, re-entering the LazyVStack graph before it converged. The root cause was further compounded by live workspace/store references inside TabItemView generating per-row observation tasks and snapshot writes during lazy-row realization.

  • Core layout fix: workspaceScrollContentMinHeight state and its onGeometryChange writer are removed; GeometryReader now supplies contentMinHeight as a synchronous downward layout input, breaking the feedback edge entirely.
  • Snapshot boundary: VerticalTabsSidebar now owns all live Workspace/store observation above LazyVStack via a SidebarWorkspaceSnapshotRefreshCoalescer; rows receive only immutable SidebarWorkspaceRowInput values and SidebarWorkspaceRowActions closures, making the lazy-boundary a type-level invariant.
  • Observation centralization: Three task(id: ids) view modifiers replace per-row publisher/task lifecycles, using UUID-keyed coalescing so bursts of workspace status changes cross the @State boundary once per run-loop turn instead of per-emitting-workspace.

Confidence Score: 5/5

Safe to merge. The feedback-edge removal is mechanically correct and the snapshot boundary eliminates the class of re-entrant layout invalidation described in the field capture.

The root-cause fix (GeometryReader synchronous layout input replacing the onGeometryChange @State write) is structurally sound and directly breaks the NSHostingView transaction re-entry cycle. The new snapshot boundary is enforced at the type level — SidebarWorkspaceRenderItem now carries only UUIDs, rows receive immutable values plus closures, and the coalescer ensures at most one @State publication per run-loop turn. All three validation runs (macOS 15 UI stress, macOS 15 app-host stress, macOS 26 app-host stress) passed on the fixed HEAD. The one open concern — O(4N) task teardown/restart per workspace-list change in the centralised observation modifiers — is a future-scale issue at the PR's benchmarked 120-workspace ceiling, not a correctness defect in the current change.

Sources/WorkspaceSidebarObservation.swift — the three task(id: ids) modifiers recreate all N–2N child tasks on every workspace-list change; worth revisiting if the workspace ceiling grows beyond the benchmarked 120.

Important Files Changed

Filename Overview
Sources/ContentView.swift Core fix: removes @State workspaceScrollContentMinHeight and its onGeometryChange writer; replaces with GeometryReader computing contentMinHeight as a direct layout let. Centralises workspace observation above LazyVStack via coalescer, builds O(N) cheap workspaceRowInputsById per body pass with snapshot caching, and constructs SidebarWorkspaceNotificationIndex once per pass instead of per visible row.
Sources/SidebarWorkspaceSnapshotFactory.swift New file: pure value builder for SidebarWorkspaceSnapshotBuilder.Snapshot. Reads live Workspace fields once at snapshot time; result is cached in workspaceSnapshotsById and keyed by PresentationKey so settings-only changes reuse the cache. All localised strings correctly use String(localized:defaultValue:).
Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift New coalescer: batches per-workspace refresh IDs into a single RunLoop.main.perform flush per run-loop turn, using generation stamping to safely cancel stale callbacks. MainActor.assumeIsolated usage is correct and well-commented.
Sources/WorkspaceSidebarObservation.swift Adds three bulk observation modifiers (workspace, processTitle, agentRuntime) that create N–2N child tasks via withTaskGroup, replacing per-row LazyVStack subscriptions. task(id: ids) causes full task-group teardown/restart on any workspace-list change — O(4N) operations per change, unbenchmarked at 1000 workspaces.
Sources/SidebarWorkspaceNotificationIndex.swift New file: builds a per-pass immutable notification index (sort + prefix per bucket) replacing per-row store queries. contextMenuNotifications implements a k-way merge capped at 50; for single-workspace rows (k=1) this is O(50), constant.
Sources/SidebarWorkspaceRowsSnapshot.swift New snapshot container: pre-computes selectedContextMenuTargetAggregate once for multi-selected rows; non-multi rows receive an O(1) per-row aggregate inside LazyVStack realization. Clean value-only boundary — no observable store references cross into the lazy list.
Sources/SidebarWorkspaceContextMenuTargetAggregate.swift New aggregate: consolidates remote, grouping, unread, and notification facts for the context-menu target set in one O(targetIds) pass. Replaces four separate per-row store queries that previously ran during lazy row realization.
Sources/VerticalTabsSidebar+WorkspaceGroups.swift Refactored: sidebarWorkspaceGroupHeader split into snapshot builder and lazy row assembler. canMarkRead/canMarkUnread now derived from unreadSummariesByWorkspaceId snapshot; action closures re-check live store before mutating, adding correct defensive guards.
Sources/SidebarWorkspaceRenderItem.swift Enum cases changed from carrying live Workspace/WorkspaceGroup references to carrying only UUIDs, preventing LazyVStack from copying live observable graph nodes during ForEach diffing.
Sources/SidebarWorkspaceRowActions.swift New value type encoding ~40 action closures per row. Closures capture parent-owned references but the row itself holds no live model reference. currentWindowMoveTargets resolves live app-window topology at menu-open time.
cmuxTests/SidebarWorkspaceNotificationIndexTests.swift New: 264-line test covering deduplication, sort order, multi-workspace merge, and the 50-notification cap for SidebarWorkspaceNotificationIndex.
cmuxUITests/WorkspaceSidebarScrollUITests.swift New: 193-line UI stress test with 28 workspaces, 48 real swipe gestures, paired set_status/clear_status commands, and a main-actor watchdog after each gesture.
scripts/check-sidebar-lazy-layout.py Extended policy check: adds new snapshot types and observation helpers to allow-list; scale probe assertions updated to track parent-projection and realized-row counts separately.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    WS["Workspace / Store (live @Observable)"] -->|sidebarObservationPublisher| OBS["task(id: ids) observation modifiers\nabove LazyVStack boundary"]
    OBS -->|onChange per workspace| COAL["SidebarWorkspaceSnapshotRefreshCoalescer\nRunLoop.main.perform — one flush per turn"]
    COAL -->|refreshWorkspaceSnapshots| SNAP["@State workspaceSnapshotsById\nUUID to Snapshot dict"]
    SNAP --> ROWS["workspaceScrollRows\nbuild workspaceRowInputsById O(N)\nbuild notificationIndex O(K log K)\nbuild listSnapshot O(selected)"]
    ROWS -->|immutable SidebarWorkspaceRowInput| LVS["LazyVStack — realizes visible rows only"]
    LVS -->|rowSnapshot + actionFactory| RV["SidebarWorkspaceRowView\n+ SidebarWorkspaceRowActions closures"]
    GR["GeometryReader viewport.size"] -->|contentMinHeight — layout input only| SC["workspaceScrollContent ScrollView + LazyVStack"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    WS["Workspace / Store (live @Observable)"] -->|sidebarObservationPublisher| OBS["task(id: ids) observation modifiers\nabove LazyVStack boundary"]
    OBS -->|onChange per workspace| COAL["SidebarWorkspaceSnapshotRefreshCoalescer\nRunLoop.main.perform — one flush per turn"]
    COAL -->|refreshWorkspaceSnapshots| SNAP["@State workspaceSnapshotsById\nUUID to Snapshot dict"]
    SNAP --> ROWS["workspaceScrollRows\nbuild workspaceRowInputsById O(N)\nbuild notificationIndex O(K log K)\nbuild listSnapshot O(selected)"]
    ROWS -->|immutable SidebarWorkspaceRowInput| LVS["LazyVStack — realizes visible rows only"]
    LVS -->|rowSnapshot + actionFactory| RV["SidebarWorkspaceRowView\n+ SidebarWorkspaceRowActions closures"]
    GR["GeometryReader viewport.size"] -->|contentMinHeight — layout input only| SC["workspaceScrollContent ScrollView + LazyVStack"]
Loading

Reviews (6): Last reviewed commit: "perf: index sidebar notification project..." | Re-trigger Greptile

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

@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: 5

🤖 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 `@cmuxTests/SidebarLazyLayoutScaleTests.swift`:
- Around line 280-282: Rename
testRowBodyEvaluationBuildsWorkspaceSnapshotAtMostOnce to clearly state that row
body evaluation never builds a workspace snapshot, matching the strict worstBody
== 0 assertion and updated documentation.

In `@cmuxTests/SidebarPointerInteractionScaleTests.swift`:
- Around line 246-251: Replace the fixed 50 ms Task.sleep in the scrolling test
with a real snapshot-refresh completion or generation signal, or a
deadline-bounded poll of an observable refresh predicate. Ensure each cycle
waits until the parent sidebar observation and snapshot refresh have completed
before draining the run loop, while preserving the existing test flow.

In `@Sources/ContentView.swift`:
- Line 9961: Remove the DEBUG-only sidebarLazyContractProbe environment
dependency and all related production observation seams from ContentView.swift,
including the additional referenced locations. Move the lazy-contract
instrumentation into test-target observation or an isolated debug facility,
leaving production Sources code free of test-only probes while preserving the
existing test coverage.
- Around line 12742-12744: Update the X-button’s closeWorkspace action to route
through the shared close-gesture path and pass
CloseTabConfirmationTrigger.tabCloseButton explicitly, rather than calling
closeWorkspaceWithConfirmation directly. Apply the same trigger-specific change
to the corresponding close action at the other referenced location, preserving
explicit triggers for every close flow.
- Around line 12397-12438: Update moveWorkspaceRows and
moveWorkspaceRowsToNewWindow to record only workspace IDs whose
moveWorkspaceToWindow or moveWorkspaceToNewWindow operation succeeds, and
subtract only those IDs from selectedTabIds. In moveWorkspaceRowsToNewWindow,
ensure each workspace is moved to the new window exactly once while preserving
focus on the final successfully moved workspace, without re-moving
orderedIds.last.
🪄 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: 053d8a99-50c1-4dfa-8e5b-bbc58899188c

📥 Commits

Reviewing files that changed from the base of the PR and between 96a145a and ee26c26.

📒 Files selected for processing (24)
  • Sources/ContentView.swift
  • Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift
  • Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift
  • Sources/SidebarWorkspaceContextMenuSnapshot.swift
  • Sources/SidebarWorkspaceGroupRowView.swift
  • Sources/SidebarWorkspaceRenderItem.swift
  • Sources/SidebarWorkspaceRowActions.swift
  • Sources/SidebarWorkspaceRowSnapshot.swift
  • Sources/SidebarWorkspaceRowView.swift
  • Sources/SidebarWorkspaceSnapshotFactory.swift
  • Sources/SidebarWorkspaceWindowMoveTarget.swift
  • Sources/TabItemView+WorkspaceContextMenu.swift
  • Sources/TabItemView+WorkspaceGroups.swift
  • Sources/TabItemView+WorkspaceNotificationsMenu.swift
  • Sources/TabItemView+WorkspaceTodo.swift
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift
  • Sources/WorkspaceSidebarObservation.swift
  • Sources/WorkspaceSidebarProcessTitleObservationModel.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarLazyLayoutScaleTests.swift
  • cmuxTests/SidebarPointerInteractionScaleTests.swift
  • cmuxTests/WorkspaceGroupTests.swift
  • cmuxUITests/WorkspaceSidebarScrollUITests.swift
  • scripts/check-sidebar-lazy-layout.py

Comment thread cmuxTests/SidebarLazyLayoutScaleTests.swift Outdated
Comment thread cmuxTests/SidebarPointerInteractionScaleTests.swift Outdated
Comment thread Sources/ContentView.swift
Comment thread Sources/ContentView.swift
Comment thread Sources/ContentView.swift

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/ContentView.swift (1)

12795-12803: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Revalidate read/unread eligibility immediately before every mutation.

Snapshot eligibility can be heterogeneous or stale, so action handlers must filter targets using the live notification store.

  • Sources/ContentView.swift#L12795-L12803: apply each operation only when the corresponding singleton eligibility check succeeds.
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift#L194-L198: add the same checks for the group anchor.
Proposed fix
 markRead: { workspaceIds in
-    for workspaceId in workspaceIds {
+    for workspaceId in workspaceIds
+        where notificationStore.canMarkWorkspaceRead(forTabIds: [workspaceId]) {
         notificationStore.markRead(forTabId: workspaceId)
     }
 },
 markUnread: { workspaceIds in
-    for workspaceId in workspaceIds {
+    for workspaceId in workspaceIds
+        where notificationStore.canMarkWorkspaceUnread(forTabIds: [workspaceId]) {
         notificationStore.markUnread(forTabId: workspaceId)
     }
 },
🤖 Prompt for 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.

In `@Sources/ContentView.swift` around lines 12795 - 12803, Revalidate each target
immediately before mutation: in Sources/ContentView.swift lines 12795-12803,
update the markRead and markUnread handlers to call the corresponding singleton
eligibility check on notificationStore and mutate only eligible workspace IDs;
apply the same live checks to the group anchor in
Sources/VerticalTabsSidebar+WorkspaceGroups.swift lines 194-198. Preserve the
existing markRead(forTabId:) and markUnread(forTabId:) operations for eligible
targets.
🤖 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/SidebarWorkspaceRowsSnapshot.swift`:
- Around line 29-44: Build a parent-owned notification index in
SidebarWorkspaceRowsSnapshot instead of rescanning notifications: replace
hasNotification and contextMenuNotifications with indexed membership checks and
bounded merging of per-workspace buckets. In
Sources/SidebarWorkspaceRowsSnapshot.swift lines 29-44, create and reuse the
index; in Sources/SidebarWorkspaceRowInput.swift lines 122-125, consume it
without rebuilding menu data per realized row; and in
Sources/VerticalTabsSidebar+WorkspaceGroups.swift line 43, use a precomputed
workspace-ID set for group notification presence.

---

Outside diff comments:
In `@Sources/ContentView.swift`:
- Around line 12795-12803: Revalidate each target immediately before mutation:
in Sources/ContentView.swift lines 12795-12803, update the markRead and
markUnread handlers to call the corresponding singleton eligibility check on
notificationStore and mutate only eligible workspace IDs; apply the same live
checks to the group anchor in Sources/VerticalTabsSidebar+WorkspaceGroups.swift
lines 194-198. Preserve the existing markRead(forTabId:) and
markUnread(forTabId:) operations for eligible targets.
🪄 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: 3ec5b223-1547-4f5c-94cb-17a87cca0474

📥 Commits

Reviewing files that changed from the base of the PR and between ee26c26 and ae5c771.

📒 Files selected for processing (10)
  • Sources/ContentView.swift
  • Sources/SidebarWorkspaceGroupRowSnapshot.swift
  • Sources/SidebarWorkspaceRowActions.swift
  • Sources/SidebarWorkspaceRowInput.swift
  • Sources/SidebarWorkspaceRowsSnapshot.swift
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift
  • Sources/WorkspaceSidebarObservation.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarLazyLayoutScaleTests.swift
  • cmuxTests/SidebarPointerInteractionScaleTests.swift

Comment thread Sources/SidebarWorkspaceRowsSnapshot.swift Outdated
@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/SidebarWorkspaceRowInput.swift (1)

49-126: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Precompute selection-wide context-menu aggregates outside row realization.

rowSnapshot runs per realized row but repeatedly scans targetWorkspaceIds for remote, grouping, notification, and read-state projections. This is O(V×S) work for V visible rows and S selected workspaces—potentially around 1,000 records—and risks restoring sidebar scroll churn.

Compute these shared aggregates once in SidebarWorkspaceRowsSnapshot; keep only row-specific todo/pin assembly here.

As per path instructions, snapshot projection loops must run “once per snapshot generation, not repeatedly per row.”

🤖 Prompt for 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.

In `@Sources/SidebarWorkspaceRowInput.swift` around lines 49 - 126, Move the
selection-wide context-menu aggregate calculations currently performed in
rowSnapshot into SidebarWorkspaceRowsSnapshot, computing remote, grouping,
notification, and read-state projections once per snapshot generation. Expose
the precomputed values from SidebarWorkspaceRowsSnapshot and have rowSnapshot
reuse them when constructing SidebarWorkspaceContextMenuSnapshot, retaining only
row-specific todo and pin assembly and preserving single-selection behavior.

Sources: Coding guidelines, Path instructions

🤖 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.

Outside diff comments:
In `@Sources/SidebarWorkspaceRowInput.swift`:
- Around line 49-126: Move the selection-wide context-menu aggregate
calculations currently performed in rowSnapshot into
SidebarWorkspaceRowsSnapshot, computing remote, grouping, notification, and
read-state projections once per snapshot generation. Expose the precomputed
values from SidebarWorkspaceRowsSnapshot and have rowSnapshot reuse them when
constructing SidebarWorkspaceContextMenuSnapshot, retaining only row-specific
todo and pin assembly and preserving single-selection behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8d4492c-6a4f-4447-ada9-b585f47d532b

📥 Commits

Reviewing files that changed from the base of the PR and between ae5c771 and b4bdf4f.

📒 Files selected for processing (6)
  • Sources/ContentView.swift
  • Sources/Debug/SidebarLazyContractProbe.swift
  • Sources/SidebarWorkspaceRowInput.swift
  • Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarLazyLayoutScaleTests.swift

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/SidebarWorkspaceRowInput.swift (1)

48-129: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Defer expensive context-menu state evaluation to prevent scroll stutter.

rowSnapshot(list:) is evaluated eagerly for every row realized by the LazyVStack. By populating the contextMenu here, it invokes list.contextMenuNotifications(workspaceIds:) (and other group-eligibility list helpers), which re-filters and re-sorts the global notifications collection on each row render.

Because targetWorkspaceIds always contains at least the row's own ID, this causes repeated O(N log N) rescans of the notifications array (expected scale: potentially 1000+ items) on the main thread during scrolling.

As per path instructions, avoid per-item rescans (filter/sorted/...) inside loops over scalable collections, and ensure iteration is bounded or uses cached snapshots so row rendering doesn't repeatedly sort unbounded collections.

Proposed Fix:
To maintain the lazy-layout contract, compute the context menu data lazily (e.g., passing a closure like contextMenuSnapshot: @escaping () -> SidebarWorkspaceContextMenuSnapshot that evaluates only when the menu is presented, similar to actions.currentWindowMoveTargets()), or cache the resulting notification arrays at the list snapshot level rather than re-evaluating them for each row.

🤖 Prompt for 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.

In `@Sources/SidebarWorkspaceRowInput.swift` around lines 48 - 129, Defer
construction of the context-menu snapshot in rowSnapshot(list:) instead of
eagerly evaluating it for every realized row. Update the
SidebarWorkspaceRowSnapshot/context-menu flow to store an escaping lazy snapshot
closure, or reuse a list-level cached context-menu snapshot, and invoke it only
when the context menu is presented; preserve the existing target, group,
remote-state, and notification behavior without per-row rescans.

Source: Path instructions

🤖 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.

Outside diff comments:
In `@Sources/SidebarWorkspaceRowInput.swift`:
- Around line 48-129: Defer construction of the context-menu snapshot in
rowSnapshot(list:) instead of eagerly evaluating it for every realized row.
Update the SidebarWorkspaceRowSnapshot/context-menu flow to store an escaping
lazy snapshot closure, or reuse a list-level cached context-menu snapshot, and
invoke it only when the context menu is presented; preserve the existing target,
group, remote-state, and notification behavior without per-row rescans.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 368e6662-4d1a-42c2-a380-5920dd1d3e78

📥 Commits

Reviewing files that changed from the base of the PR and between b4bdf4f and a5f0f7b.

📒 Files selected for processing (8)
  • Sources/ContentView.swift
  • Sources/SidebarWorkspaceContextMenuSnapshot.swift
  • Sources/SidebarWorkspaceRowActions.swift
  • Sources/SidebarWorkspaceRowInput.swift
  • Sources/SidebarWorkspaceRowsSnapshot.swift
  • Sources/TabItemView+WorkspaceContextMenu.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift
💤 Files with no reviewable changes (2)
  • Sources/SidebarWorkspaceContextMenuSnapshot.swift
  • Sources/SidebarWorkspaceRowsSnapshot.swift

@lawrencecchen
lawrencecchen merged commit 0f40759 into main Jul 16, 2026
6 checks passed
@austinywang

Copy link
Copy Markdown
Contributor Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@austinywang I can't start this review because your workspace has reached its free monthly review limit. cubic has reviewed 241,631 of the 240,000 allowed lines of code this month. Reviews resume on 1 August 2026 (in 16 days). Paid plans include much higher monthly review limits. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

@austinywang Sure, I'll review the PR now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@austinywang

Copy link
Copy Markdown
Contributor Author

Review triage for the non-actionable policy claims:

  • Run-loop publication: SidebarWorkspaceSnapshotRefreshCoalescer is @MainActor and uses RunLoop.main.perform as a real next-turn signal, not a timing delay. That boundary is intentional: it prevents publisher callbacks from publishing observable snapshots inside the current SwiftUI layout/update transaction, coalesces keyed requests once, and equality-guards the authoritative result. Strict Swift 6 concurrency typechecking passes.
  • Package boundary: the new projections coordinate app-owned Workspace, TabManager, notification/window topology, and UI action closures; moving them into CmuxSidebar would invert the existing package boundary toward app state. The canonical cmux policy check is clean.
  • Localization: no user-facing string or localization catalog changed. Resources/Localizable.xcstrings parses, the relocated UI retains existing localized keys, and the diff adds no runtime-facing English copy.
  • Debug probe: SidebarLazyContractProbe and its environment injection predate this PR and already live under Sources/Debug; this diff only relocates/adds counters within that existing DEBUG-only facility. Greptile independently withdrew the same claim after checking origin/main.

No code changes were made for those rejected findings.

azooz2003-bit added a commit that referenced this pull request Jul 16, 2026
* Add failing demand-contract regression test for coalesceLatest

DemandTrackingSubscriber records values received while downstream demand
is zero instead of trapping. Without the fix, coalesceLatest forwards
the @published replay and every leading-edge emission regardless of
demand, which is the contract violation that crashes AsyncPublisher
(.values) consumers in WorkspaceSidebarObservation.

* Honor downstream demand in coalesceLatest

coalesceLatest ignored downstream demand by design (sink-style only),
but WorkspaceSidebarObservation consumes it through .values
(Combine.AsyncPublisher), which requests one value at a time and traps
with "received an unexpected value" when a value arrives at zero
demand. The trap lives in the system Combine binary, so it crashes
release builds too. The @published replay is forwarded synchronously in
receive(subscription:) before the async iterator has requested
anything, so the app can crash at startup (two crash reports on
2026-07-15, introduced by #8211).

Track outstanding demand; a value reaching an emission point at zero
demand is conflated into a latest-value slot and delivered from
request(_:) when demand arrives. Sink-style subscribers (unlimited
demand) keep the synchronous leading edge unchanged.

---------

Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com>
azooz2003-bit pushed a commit that referenced this pull request Jul 17, 2026
Merges origin/main (a5a70ff) including the sidebar row-layer refactor
(SidebarWorkspaceRowInput projections, container-owned snapshots and
observation, selectWorkspaceRow) plus #8211 and the #8240 terminal-resize
coalescing. The AppKit table now consumes the same projection as the SwiftUI
list: workspaceTableRowConfiguration builds its row model from
SidebarWorkspaceRowInput and the shared context-menu aggregates, container
observation replaces the per-cell pump and snapshot memo, and the AppKit
actions bundle is renamed SidebarAppKitRowActions to coexist with upstream's
type. Verified rendering and interaction on both flag states.
bn-l pushed a commit to bn-l/cmux that referenced this pull request Oct 4, 2026
Ported from upstream manaflow-ai#8211 (0f40759): "Fix sidebar scroll layout livelock (manaflow-ai#8211)".

Removes the onGeometryChange -> @State workspaceScrollContentMinHeight feedback edge (viewport height is now a downward-only GeometryReader input) and moves every live Workspace/store observation above the LazyVStack: rows receive immutable SidebarWorkspaceRowSnapshot values plus a SidebarWorkspaceRowActions closure bundle, and workspace publisher bursts are coalesced once per run-loop batch.

Adapted to this fork: stripped upstream's workspace todo/checklist/task-status fields, actions and UI (the feature does not exist here); kept the fork's sidebar footer views and SidebarWorkspaceSnapshotBuilder; moveWorkspaceRow uses the fork's reorderWorkspace(tabId:toIndex:); the §6.2 per-row geometry trace now lives on SidebarWorkspaceRowView (the minHeight trace point is gone with the state it traced).
bn-l pushed a commit to bn-l/cmux that referenced this pull request Oct 4, 2026
Ported from upstream manaflow-ai#8236 (14effdc): "Fix coalesceLatest demand-contract crash introduced by manaflow-ai#8211 (manaflow-ai#8236)".
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.

Scrolling sidebar freezes cmux

2 participants