Skip to content

Fix sidebar row-height layout feedback - #6111

Merged
austinywang merged 1 commit into
mainfrom
feat-sidebar-rowheight-livelock
Jun 14, 2026
Merged

austinywang merged 1 commit into
mainfrom
feat-sidebar-rowheight-livelock

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the nightly sidebar hang path captured from cmux NIGHTLY after sidebar interaction.

The hang sample was on the main thread in SwiftUI LazyVStack/ForEach placement and AttributeGraph updates. This removes the per-row GeometryReader height probes that wrote back into SwiftUI state from sidebar rows during layout.

Changes:

  • Replace workspace row measured drop height with deterministic content/font-based drop metrics.
  • Add a stable workspace group header drop target height to SidebarWorkspaceGroupHeaderMetrics.
  • Keep drag/drop delegates using stable heights without layout-driven state writes.
  • Add focused tests for stable row/header drop target heights.

Verification:

  • CMUX_SKIP_ZIG_BUILD=1 xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-rhl-test -only-testing:cmuxTests/SidebarWorkspaceDropPlannerTests test
  • ./scripts/reload-cloud.sh --tag rhl
  • Preflight on tagged rhl build: created workspaces with CMUX_TAG=rhl ./scripts/cmux-debug-cli.sh new-workspace, ran CMUX_TAG=rhl ./scripts/cmux-debug-cli.sh simulate-sidebar-drag --window window:1 --from workspace:3 --to workspace:1 --duration-ms 250 --steps 8, then verified real reorder with CMUX_TAG=rhl ./scripts/cmux-debug-cli.sh reorder-workspace --workspace workspace:3 --before workspace:1 --window window:1 and screenshot state via Computer Use.

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


Note

Medium Risk
Sidebar drag-and-drop hit zones now rely on heuristics instead of measured row height, so reorder/group-drop UX could drift on complex rows even though the layout-feedback hang risk is reduced.

Overview
Addresses a main-thread SwiftUI hang tied to LazyVStack rows writing measured heights back into state during layout by removing per-row GeometryReader height probes from workspace rows and group headers.

Workspace rows now feed tab drop delegates an optional dropTargetHeight from SidebarWorkspaceRowDropMetrics, which estimates row height from font scale, title wrapping, subtitles, metadata expansion, and other visible sidebar details instead of live layout. For simple rows (single-line title, no description/metadata blocks), that estimate is passed through; richer rows pass nil so drop planning can fall back to pointer-based behavior. Metadata expand/collapse state moves to the parent row so height math stays aligned with what is shown, and description/metadata views gain shared line limits tied to the same metrics.

Group headers use a computed dropTargetHeight on SidebarWorkspaceGroupHeaderMetrics (font-scale–based, no layout read). TabItemViewDropSupport centralizes close, Finder-open, and snapshot-invalidation helpers that were inlined in the giant view file.

Tests in SidebarWorkspaceDropMetricsTests lock scaling and expansion behavior for row/header drop heights.

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


Summary by cubic

Fixes a SwiftUI sidebar hang by eliminating layout-driven height probes and using deterministic, optional drop-target heights for rows and stable heights for group headers. This removes main-thread layout feedback and keeps drag-and-drop stable across font scales and content.

  • Bug Fixes
    • Removed GeometryReader probes; rows and headers no longer write layout to state. Headers use SidebarWorkspaceGroupHeaderMetrics.dropTargetHeight.
    • Added SidebarWorkspaceRowDropMetrics and TabItemViewDropSupport. Row drop delegates now take CGFloat?; rows pass a height only for width‑independent rows, computed from font scale and visible content (no layout reads).
    • Centralized metadata expand/collapse state in TabItemView. Collapsed limits and max line caps moved into metrics; applied .lineLimit to title, description, and markdown blocks to match the heuristic.
    • Added SidebarWorkspaceDropMetricsTests to cover header scaling and row height heuristics (base/rich/scaled, expanded metadata, pointer‑edge gating).

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

Review in cubic

Summary by CodeRabbit

  • Refactor

    • Improved sidebar drag-and-drop hit-target detection using computed stable metrics.
    • Refactored metadata expansion state handling in sidebar workspace rows.
    • Consolidated workspace snapshot refresh and file-opening workflows.
  • Tests

    • Added comprehensive test coverage for drop-target height calculations across various row configurations.

@vercel

vercel Bot commented Jun 14, 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 14, 2026 9:58am
cmux-staging Building Building Preview, Comment Jun 14, 2026 9:58am

@coderabbitai

coderabbitai Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Replaces per-row GeometryReader-measured rowHeight state in TabItemView and SidebarWorkspaceGroupHeaderView with a new SidebarWorkspaceRowDropMetrics struct that computes stable drop-hit heights from workspace snapshots and font-scale metrics. Metadata expansion state is lifted to TabItemView and passed as @Binding to subviews. A new TabItemViewDropSupport.swift extension centralizes close, refresh, and Finder-open helpers.

Changes

Snapshot-based drop-hit height refactor

Layer / File(s) Summary
New SidebarWorkspaceRowDropMetrics struct and group-header dropTargetHeight
Sources/SidebarWorkspaceRowDropMetrics.swift, Sources/SidebarWorkspaceGroupHeaderMetrics.swift
SidebarWorkspaceRowDropMetrics introduces configuration constants, a primary targetHeight overload accumulating scaled height contributions from title/description/metadata/aux-detail sections, estimation helpers for line counts and branch/directory rows, a snapshot-driven overload, and dropTargetHeight returning nil unless pointer-edge conditions apply. SidebarWorkspaceGroupHeaderMetrics gains a computed dropTargetHeight from existing scaled frame/font metrics.
TabItemViewDropSupport extension helpers
Sources/TabItemViewDropSupport.swift
Adds workspaceDropTargetHeight, closeWorkspace(method:), refreshWorkspaceSnapshotAfterObservation(source:), and openPendingFinderDirectoryRequest() as a TabItemView extension, plus DEBUG-only observation-invalidation logging and a text-preview escaping helper.
Remove GeometryReader probes and wire computed heights
Sources/ContentView.swift, Sources/SidebarWorkspaceGroupHeaderView.swift
TabItemView removes @State rowHeight and rowHeightProbe, updates tabDropDelegateFactory to (CGFloat?) -> SidebarTabDropDelegate, adds @State metadataRowsExpanded/metadataBlocksExpanded, routes close/refresh/Finder calls through the new extension. SidebarWorkspaceGroupHeaderView removes its probe and passes metrics.dropTargetHeight. Metadata subviews (SidebarMetadataRows, SidebarMetadataMarkdownBlocks) switch from local @State expansion to @Binding isExpanded and adopt shared constants from SidebarWorkspaceRowDropMetrics.
Tests and Xcode project registration
cmuxTests/SidebarWorkspaceDropMetricsTests.swift, cmux.xcodeproj/project.pbxproj
SidebarWorkspaceDropMetricsTests validates dropTargetHeight scaling, row height variations across content flags, and shouldUsePointerEdgeHeight conditions. New source and test files are registered in the Xcode project build graph.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(70, 130, 180, 0.5)
        Note over TabItemView,SidebarTabDropDelegate: New computed drop-hit height flow
    end
    TabItemView->>TabItemViewDropSupport: workspaceDropTargetHeight(snapshot, effectiveSubtitle)
    TabItemViewDropSupport->>SidebarWorkspaceRowDropMetrics: dropTargetHeight(snapshot, settings, metadataEntryIsExpanded, metadataBlocksAreExpanded)
    SidebarWorkspaceRowDropMetrics->>SidebarWorkspaceRowDropMetrics: shouldUsePointerEdgeHeight(wrapsWorkspaceTitles, hasDescription, hasMetadataBlocks)
    alt pointer-edge height needed
        SidebarWorkspaceRowDropMetrics-->>TabItemViewDropSupport: CGFloat (computed targetHeight)
    else not needed
        SidebarWorkspaceRowDropMetrics-->>TabItemViewDropSupport: nil
    end
    TabItemViewDropSupport-->>TabItemView: CGFloat?
    TabItemView->>SidebarTabDropDelegate: tabDropDelegateFactory(CGFloat?)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • manaflow-ai/cmux#6052: Both PRs modify tabDropDelegateFactory and the drop-hit height source in ContentView.swift and SidebarWorkspaceGroupHeaderView.swift — this PR switches to SidebarWorkspaceRowDropMetrics while #6052 switches to a shared SidebarRowHeightStore.
  • manaflow-ai/cmux#4989: Both PRs modify SidebarWorkspaceGroupHeaderView's .onDrop delegate wiring and group-header drop-hit sizing, with direct overlap in how the target height is obtained and passed to the factory.
  • manaflow-ai/cmux#5612: Both PRs change tabDropDelegateFactory construction in TabItemView-related code — this PR changes the height input type while #5612 changes SidebarTabDropDelegate construction and reorder-scope logic.

Poem

🐇 No more probing rows with GeometryReader's eye,
A metrics struct now answers heights on the fly!
The sidebar rows stay calm, no state-write surprise,
Computed from snapshots — how perfectly wise.
I hop through the diff with a satisfied squeak,
Stable drop targets found in under a week! ✨


Important

Pre-merge checks failed

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

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces Task.sleep(nanoseconds: 150_000_000) in workspaceHandoffFallbackTask for workspace handoff timeout, explicitly prohibited by rule "do not use Task.sleep". Replace Task.sleep with async await alternatives like Swift.concurrency's taskTimeout or move timeout logic to a proper timer/deadline mechanism not using blocking sleep.
Cmux Swift @Concurrent ❌ Error The async function openPendingFinderDirectoryRequest() in TabItemViewDropSupport.swift (line 31) is missing the @MainActor annotation required for a function called from @MainActor context and call... Add @MainActor annotation to openPendingFinderDirectoryRequest() since it's called from .task() on @MainActor and invokes @MainActor-isolated openInFinder().
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the primary change: removing layout feedback from sidebar row-height measurements to fix a SwiftUI hang.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PR introduces only pure value structs (SidebarWorkspaceRowDropMetrics), property additions to existing structs, and extension methods on SwiftUI View types with main-thread-only @State access; no i...
Cmux Expensive Synchronous Load ✅ Passed PR removes GeometryReader layout probes and replaces with deterministic computations; no expensive synchronous loaders (RestorableAgentSessionIndex, sysctl, etc.) are added to interactive paths.
Cmux Cache Substitution Correctness ✅ Passed Drop target heights computed from transient @State snapshot used only for drag-drop hit testing UI; not in persistence/history/undo path; snapshot invalidated on observation publishers with fresh r...
Cmux No Hacky Sleeps ✅ Passed This custom check applies only to TypeScript, JavaScript, shell, and build/runtime scripts. The PR modifies only Swift source and test files; no non-Swift runtime code was changed, so the rule does...
Cmux Algorithmic Complexity ✅ Passed All algorithms have bounded complexity with explicit size limits: text processing bounded to 504 chars, metadata entries/blocks capped at 3 and 1 respectively, single-pass operations over pre-compu...
Cmux Swift Concurrency ✅ Passed PR introduces no problematic legacy async patterns; the new async method openPendingFinderDirectoryRequest() is properly called via SwiftUI's .task modifier with lifecycle management, and pure comp...
Cmux Swift File And Package Boundaries ✅ Passed PR respects file/package boundaries: SidebarWorkspaceRowDropMetrics (208 lines, pure height computation) and TabItemViewDropSupport (67 lines, small UI glue) are appropriately sized with clear sing...
Cmux Swift Logging ✅ Passed All logging in PR uses DEBUG-only cmuxDebugLog calls properly guarded with #if DEBUG; no forbidden logging functions (print, debugPrint, dump, NSLog) found; no sensitive data exposed.
Cmux User-Facing Error Privacy ✅ Passed PR contains only internal sidebar performance fixes; all debug logging is DEBUG-guarded; no user-facing errors, alerts, credentials, vendor names, or sensitive data exposed.
Cmux Full Internationalization ✅ Passed PR adds no new user-facing strings; debug logging is behind #if DEBUG; test file; no xcstrings/i18n catalog changes needed for performance/bug-fix refactoring of drag-drop metrics.
Cmux Swiftui State Layout ✅ Passed PR removes layout-driven state violations: deleted rowHeight @State and rowHeightProbe GeometryReader that measured feedback; new metadata expansion state is proper user-interaction binding, not re...
Cmux Architecture Rethink ✅ Passed PR removes layout-driven GeometryReader state feedback loops and establishes deterministic drop height computation from font scale and content. No timing repairs, locks, observers, or split lifecyc...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed This PR does not add or materially change any NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup. It only refactors sidebar views and drag/drop metrics to fix a main-thread hang.
Cmux Source Artifacts ✅ Passed All changed files are hand-written source code, tests, or legitimate project configuration. No build artifacts, temp files, logs, screenshots, cache directories, or other prohibited artifacts found.
Description check ✅ Passed PR description includes Summary and Testing sections matching the template structure with specific verification steps, but lacks Demo Video and Review Trigger sections.
✨ 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 feat-sidebar-rowheight-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 and usage tips.

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

greptile-apps Bot commented Jun 14, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates a main-thread hang in the sidebar by removing GeometryReader height probes from LazyVStack rows in TabItemView and SidebarWorkspaceGroupHeaderView. Both rows previously wrote measured height back into @State during SwiftUI layout, triggering AttributeGraph churn; they now compute drop-hit zones directly from font scale and content shape via SidebarWorkspaceRowDropMetrics and the new dropTargetHeight on SidebarWorkspaceGroupHeaderMetrics.

  • SidebarWorkspaceRowDropMetrics.swift (new): stateless, pure arithmetic height estimator for workspace rows; dropTargetHeight returns nil for variable-height rows (wrapped titles, descriptions, metadata blocks), falling back to pointer-edge detection in the drop planner.
  • TabItemViewDropSupport.swift (new): thin extension that bridges TabItemView's lifted metadataRowsExpanded / metadataBlocksExpanded state to the metrics, keeping the main view file's body free of layout-driven state writes.
  • Line-limit clamps (lineLimit(maxWrappedTitleLines) on titles, lineLimit(maxDescriptionLines) on description text) are added to bound row height so the estimator and the rendered row stay in the same order of magnitude.

Confidence Score: 5/5

Safe to merge; the GeometryReader removal is complete and the drop delegate nil-height fallback was already an established pattern in the codebase.

The core change removes layout-feedback state writes from LazyVStack rows, matched by deterministic arithmetic in the new metrics types and backed by pinned tests. The dropTargetHeight guard correctly returns nil for variable-height rows rather than returning an inaccurate estimate, preserving existing pointer-edge fallback behavior. No new concurrency, blocking, or architectural regressions were introduced.

Sources/ContentView.swift — the title line-limit cap (nil → 8 lines) and the new description line-limit are user-visible display changes worth a quick review.

Important Files Changed

Filename Overview
Sources/SidebarWorkspaceRowDropMetrics.swift New pure-computation height estimator; stateless, single responsibility, well-tested. dropTargetHeight correctly guards to nil for variable-height rows so drop planner falls back to pointer-edge mode.
Sources/TabItemViewDropSupport.swift New extension file bridging TabItemView state to metrics; clean factoring, no new state mutations or layout reads.
Sources/SidebarWorkspaceGroupHeaderMetrics.swift Adds dropTargetHeight computed from existing scaled metrics; formula verified by the new tests at fontScale 1 and 2.
Sources/SidebarWorkspaceGroupHeaderView.swift Removes GeometryReader probe and rowHeight state; passes metrics.dropTargetHeight directly to drop delegate factory. Clean and focused.
Sources/ContentView.swift Removes GeometryReader probe, lifts isExpanded state for metadata rows/blocks, and adds line-limit caps on title/description. Line-limit changes are user-visible behavioral changes for long content.
cmuxTests/SidebarWorkspaceDropMetricsTests.swift New test file; pins header drop-target height at fontScale 1 and 2, exercises row height scaling, expand-state tracking, and pointer-edge guard logic.
cmux.xcodeproj/project.pbxproj Adds new source and test files to the project; indentation drift on some existing entries but no missing build-phase entries.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[TabItemView.body] --> B{workspaceDropTargetHeight}
    B --> C[SidebarWorkspaceRowDropMetrics.dropTargetHeight]
    C --> D{shouldUsePointerEdgeHeight?}
    D -- wrapsTitle OR hasDesc OR hasMetadataBlocks --> E[return nil]
    D -- none of above --> F[targetHeight - pure arithmetic]
    F --> G[CGFloat value]
    E --> H[tabDropDelegateFactory nil - pointer-edge fallback]
    G --> I[tabDropDelegateFactory height - half-row split drop zones]
    L[SidebarWorkspaceGroupHeaderView.body] --> M[metrics.dropTargetHeight - always CGFloat]
    M --> N[SidebarWorkspaceGroupHeaderDropDelegate targetRowHeight]
Loading

Reviews (3): Last reviewed commit: "Fix sidebar row-height layout feedback" | Re-trigger Greptile

Comment thread Sources/ContentView.swift
Comment on lines 13297 to 13379
var pendingWorkspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot?
}

struct SidebarWorkspaceRowDropMetrics {
static func targetHeight(
fontScale: CGFloat,
wrapsWorkspaceTitles: Bool,
hasDescription: Bool,
hasSubtitle: Bool,
hasRemoteStatus: Bool,
hasMetadataEntries: Bool,
hasMetadataBlocks: Bool,
hasLog: Bool,
hasProgress: Bool,
hasBranchDirectory: Bool,
hasPullRequests: Bool,
hasPorts: Bool
) -> CGFloat {
let scale = max(fontScale, 0.5)
var height = 16 + (wrapsWorkspaceTitles ? 32 : 16) * scale
if hasDescription {
height += 28 * scale
}
if hasSubtitle {
height += 24 * scale
}
if hasRemoteStatus {
height += 18 * scale
}
if hasMetadataEntries {
height += 20 * scale
}
if hasMetadataBlocks {
height += 34 * scale
}
if hasLog {
height += 16 * scale
}
if hasProgress {
height += 16 * scale
}
if hasBranchDirectory {
height += 18 * scale
}
if hasPullRequests {
height += 18 * scale
}
if hasPorts {
height += 16 * scale
}
return max(34, ceil(height))
}

static func targetHeight(
snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot,
settings: SidebarTabItemSettingsSnapshot,
effectiveSubtitle: String?
) -> CGFloat {
let visibleDetails = settings.visibleAuxiliaryDetails
return targetHeight(
fontScale: settings.sidebarFontScale,
wrapsWorkspaceTitles: settings.wrapsWorkspaceTitles,
hasDescription: snapshot.customDescription != nil,
hasSubtitle: effectiveSubtitle != nil,
hasRemoteStatus: !settings.hidesAllDetails && settings.showsSSH && snapshot.remoteWorkspaceSidebarText != nil,
hasMetadataEntries: visibleDetails.showsMetadata && !snapshot.metadataEntries.isEmpty,
hasMetadataBlocks: visibleDetails.showsMetadata && !snapshot.metadataBlocks.isEmpty,
hasLog: visibleDetails.showsLog && snapshot.latestLog != nil,
hasProgress: visibleDetails.showsProgress && snapshot.progress != nil,
hasBranchDirectory: visibleDetails.showsBranchDirectory &&
(snapshot.compactGitBranchSummaryText != nil ||
!snapshot.compactDirectoryCandidates.isEmpty ||
!snapshot.compactBranchDirectoryCandidates.isEmpty ||
!snapshot.branchDirectoryLines.isEmpty),
hasPullRequests: visibleDetails.showsPullRequests && !snapshot.pullRequestRows.isEmpty,
hasPorts: visibleDetails.showsPorts && !snapshot.listeningPorts.isEmpty
)
}
}

struct TabItemView: View, Equatable {
private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40)
private static let legacyVMWebSocketDescription = "VM WebSocket PTY"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 SidebarWorkspaceRowDropMetrics placed in the already-oversized ContentView.swift

SidebarWorkspaceGroupHeaderMetrics has its own dedicated file (SidebarWorkspaceGroupHeaderMetrics.swift), which is the correct pattern for drop-metrics types. Adding 83 lines of a parallel type to ContentView.swift — already at ~16 900 lines — worsens the sprawl that the file-package-boundaries rule flags. Moving SidebarWorkspaceRowDropMetrics to a new SidebarWorkspaceRowDropMetrics.swift would keep it consistent with the existing sibling, independently testable (no import burden from the app target's god-file), and free of any accidental UI coupling.

Rule Used: Flag Swift changes that add too much unrelated res... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f650303f83

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/ContentView.swift Outdated
Comment on lines +13316 to +13319
var height = 16 + (wrapsWorkspaceTitles ? 32 : 16) * scale
if hasDescription {
height += 28 * scale
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Account for unbounded row text in drop metrics

When wrapsWorkspaceTitles is enabled with a long title, or a workspace has a multi-line description, this estimate can be far shorter than the rendered row: the title is allowed to wrap without a line limit, and SidebarWorkspaceDescriptionText has no line limit and uses .fixedSize(...). SidebarDropPlanner.edgeForPointer then clamps info.location.y to this smaller targetHeight, so dragging over much of the actual upper half of a tall row is treated as a bottom-edge drop and reorders after the workspace instead of before. Please derive the metric from bounded line counts or otherwise keep the hit height at least as large as the rendered row.

Useful? React with 👍 / 👎.

@lawrencecchen
lawrencecchen force-pushed the feat-sidebar-rowheight-livelock branch from f650303 to b43386f Compare June 14, 2026 08:09

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b43386f. Configure here.

Comment thread Sources/SidebarWorkspaceRowDropMetrics.swift

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b43386fdd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

!snapshot.compactDirectoryCandidates.isEmpty ||
!snapshot.compactBranchDirectoryCandidates.isEmpty ||
!snapshot.branchDirectoryLines.isEmpty),
hasPullRequests: visibleDetails.showsPullRequests && !snapshot.pullRequestRows.isEmpty,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count repeated pull request rows in drop metrics

When a workspace exposes multiple pull requests, the rendered sidebar uses ForEach(workspaceSnapshot.pullRequestRows) to add one visible row per PR, but this metric collapses the whole non-empty array to a single 18pt increment. In that case the synthetic dropTargetHeight can be far shorter than the actual row; SidebarDropPlanner.edgeForPointer then clamps pointer y to the shorter height, so dragging over much of a tall multi-PR row is interpreted as a bottom-edge drop instead of the intended top/center position. Please base this section on the displayed row count or cap the rendered rows to match the metric.

Useful? React with 👍 / 👎.

@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

🤖 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/SidebarWorkspaceGroupHeaderMetrics.swift`:
- Around line 76-80: The dropTargetHeight property in
SidebarWorkspaceGroupHeaderMetrics uses raw fontScale without enforcing a
minimum floor value, while SidebarWorkspaceRowDropMetrics floors fontScale to
0.5. To ensure consistent drop-hit heights between headers and rows, apply the
same 0.5 floor to fontScale in the dropTargetHeight calculation by using
max(0.5, fontScale) before multiplying by 24 in the return statement.
🪄 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: a4e6e987-5c84-4dce-8c00-4e81f91d7bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 4fd0d7e and b43386f.

📒 Files selected for processing (6)
  • Sources/ContentView.swift
  • Sources/SidebarWorkspaceGroupHeaderMetrics.swift
  • Sources/SidebarWorkspaceGroupHeaderView.swift
  • Sources/SidebarWorkspaceRowDropMetrics.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarWorkspaceDropPlannerTests.swift

Comment on lines +76 to +80
/// Stable drop-hit height for the group header, without reading SwiftUI layout.
var dropTargetHeight: CGFloat {
let contentHeight = max(chevronFrame, iconFrame, plusFrame, nameFontSize + 4)
return max(24 * fontScale, contentHeight + 10)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align header scale floor with row drop metrics.

SidebarWorkspaceRowDropMetrics floors fontScale to 0.5, but this header path uses raw fontScale. If settings/config ever provide values below that floor, row/header drop-hit heights become inconsistent.

Suggested fix
     /// Stable drop-hit height for the group header, without reading SwiftUI layout.
     var dropTargetHeight: CGFloat {
-        let contentHeight = max(chevronFrame, iconFrame, plusFrame, nameFontSize + 4)
-        return max(24 * fontScale, contentHeight + 10)
+        let scale = max(fontScale, 0.5)
+        let contentHeight = max(
+            Self.baseChevronFrame * scale,
+            Self.baseIconFrame * scale,
+            Self.basePlusFrame * scale,
+            Self.baseNameFontSize * scale + 4
+        )
+        return max(24 * scale, contentHeight + 10)
     }
🤖 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/SidebarWorkspaceGroupHeaderMetrics.swift` around lines 76 - 80, The
dropTargetHeight property in SidebarWorkspaceGroupHeaderMetrics uses raw
fontScale without enforcing a minimum floor value, while
SidebarWorkspaceRowDropMetrics floors fontScale to 0.5. To ensure consistent
drop-hit heights between headers and rows, apply the same 0.5 floor to fontScale
in the dropTargetHeight calculation by using max(0.5, fontScale) before
multiplying by 24 in the return statement.

@lawrencecchen
lawrencecchen force-pushed the feat-sidebar-rowheight-livelock branch from b43386f to bc784d6 Compare June 14, 2026 08:52

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc784d6674

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +193 to +198
guard shouldUsePointerEdgeHeight(
wrapsWorkspaceTitles: settings.wrapsWorkspaceTitles,
hasDescription: snapshot.customDescription != nil,
hasMetadataBlocks: visibleDetails.showsMetadata && !snapshot.metadataBlocks.isEmpty
) else {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve pointer-based drops for wrapped rows

With wrapsWorkspaceTitles enabled, or whenever a row has a description/metadata block, this guard returns nil, so the row delegate passes no height to SidebarDropPlanner.indicator; that planner then ignores info.location.y and falls back to source-direction preferredEdge. In those configurations, dragging downward over the top half of a row still targets the bottom edge (and dragging upward over the bottom half still targets the top), so users cannot place the drop based on the pointer on exactly these detailed rows. Fresh evidence in this version is that the new guard disables the bounded targetHeight path rather than using it for these cases.

Useful? React with 👍 / 👎.

@austinywang
austinywang merged commit 499c8ea into main Jun 14, 2026
22 checks passed
lawrencecchen added a commit that referenced this pull request Jul 3, 2026
…-view guard (#7221)

* Extend sidebar lazy-layout guard to the row views (TabItemView, group header)

The source-scan guard from #6870 protected only the two container
functions, but four of the five historical livelock regressions entered
through the row views: the #2586/#6556 GeometryReader -> @State
rowHeight probes lived in TabItemView and
SidebarWorkspaceGroupHeaderView (removed by #6111, reintroduced by
#4385, removed again by #7117) and shipped in stable v0.64.17, which
livelocked in the wild on 2026-07-02 with exactly that signature
(#2586 (comment)).

Scan the TabItemView region of ContentView.swift and the whole group
header file for per-row geometry feedback: GeometryReader,
onGeometryChange, manual sizeThatFits, ProposedViewSize(nil),
per-row anchorPreference/overlayPreferenceValue, and any discovered
custom Layout. Rows must stay measurement-free; the only sanctioned
geometry path is the container's drag-gated reader. Missing row types
fail loudly so a rename cannot rot the guard into a no-op.

Verified the extended guard retroactively flags both v0.64.17 row
views. New self-test cases (j)-(m) cover clean-pass, the #6556 probe
shape, the #5323 anchorPreference shape, and rename protection.

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

* Add behavioral scale gate for the sidebar lazy-layout contract

The lazy-layout contract (sidebar layout/diff work stays O(visible
rows), never O(all workspaces)) has regressed five times through five
different mechanisms (#5323 anchorPreference aggregation, #5764 String
ids, #5845 animated height interpolation, #6210 force-measuring custom
Layout, #6556 GeometryReader -> @State feedback), each shipping to
stable before detection because nothing exercises the sidebar at the
100+ workspace scale where O(N) per pass livelocks the main thread
(#2586).

SidebarLazyLayoutScaleTests mounts the real VerticalTabsSidebar with
300 workspaces in an NSHostingView and counts actual row body
evaluations through a DEBUG-only environment probe
(SidebarLazyContractProbe, same pattern as
MinimalModeInvalidationProbe):

- mount must realize only viewport rows (catches any virtualization
  defeat, present or future, regardless of mechanism)
- a 40-burst unread-model storm (the sidebar's highest-frequency
  whole-body invalidation path) must stay row-scoped and go quiet when
  the burst stops (catches feedback loops the way #6556 manifested)
- a harness canary reproduces the GeometryReader -> @State shape in
  divergent form and asserts the harness detects it, so the gate
  cannot silently rot

This is the mechanism-independent backstop behind the source-shape
scan in scripts/check-sidebar-lazy-layout.py.

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

* Fix scale-test autorelease avalanche that hung/crashed the app host

Creating 300 workspaces inside one main-actor job accumulated every
autoreleased object from the O(N)-per-add snapshot work into a single
autorelease pool; the closing objc_autoreleasePoolPop then crashed CI
(Signal 11 in AutoreleasePoolPage::releaseUntil, masked as a green run,
see #5641) and hung for hours
when reproduced on an AWS M4 Pro (sampled: main thread pinned in
releaseUntil). The app never does this; real workspace creation happens
one per event-loop turn with AppKit popping the pool between turns.

Make the harness match real cadence: per-iteration autoreleasepool
around addWorkspace and a run-loop turn every 20 creations. Also hoist
the RunLoop.run call into a synchronous helper (fixes the Swift 6
unavailable-from-async warning) wrapped in its own pool.

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

* Fix harness NSWindow double-release that killed the app host

NSZombies named the corpse: "-[NSKVONotifying_NSWindow release]:
message sent to deallocated instance". The harness windows used the
NSWindow default isReleasedWhenClosed=true, so tearDown's close()
performed AppKit's own release on top of ARC's; the double-release
SEGV'd the host at the next autorelease-pool pop, before the pass was
recorded, and CI masked the crash as a green run
(#5641 (comment)).
With zombies absorbing the over-release, all assertions pass in under
a second, isolating the crash entirely to window teardown.

Set isReleasedWhenClosed = false on both harness windows.

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

* Guard --file mode: require container functions only when one is present

Addresses Greptile P2 on the PR: --file against a row-view source (no
workspaceScrollContent/workspaceRows) emitted false could-not-locate
violations that masked real row findings. In --file mode the container
checks now apply only when at least one guarded function exists in the
source, so ad-hoc row-view scans are clean while a fixture that renamed
one function still fails loudly. Self-test cases added for both sides.

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

* Split probe env key + extension into their own files (Aziz policy)

One major type per Swift file, matching the MinimalModeInvalidationProbe
three-file layout exactly. No content changes.

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

* Cover the group-header row wrapper (guard target + grouped scale fixture)

Codex review found the blind spot: the group-header row is assembled by
sidebarWorkspaceGroupHeader(...) in VerticalTabsSidebar+WorkspaceGroups.swift,
where modifiers wrap the header before it enters the LazyVStack — a
GeometryReader or anchorPreference added there defeats laziness exactly
like one inside the row view (the #4385 regression entered through the
header path). The guard now scans that whole file for the row-forbidden
shapes with a rename-protected marker, and the scale fixture groups the
first 20 workspaces into 5 groups so group-header realization and
convergence are asserted by the behavioral backstop (bounds on
groupHeaderBodies at mount and in the quiet check).

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s, no host restarts.

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

* Make the scale harness hermetic against persisted sidebar provider

Codex review: VerticalTabsSidebar selects between the workspace list and
extension/built-in sidebars via
@AppStorage(CmuxExtensionSidebarSelection.defaultsKey), so a host with a
persisted non-default provider would mount the wrong sidebar and the
probes would never fire. Use a scratch UserDefaults suite pinned to the
default provider via .defaultAppStorage, cleaned in tearDown — the
WorkspaceContentViewVisibilityTests pattern.

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mochiexists pushed a commit to mochiexists/cmux-mochi that referenced this pull request Jul 7, 2026
…-view guard (manaflow-ai#7221)

* Extend sidebar lazy-layout guard to the row views (TabItemView, group header)

The source-scan guard from manaflow-ai#6870 protected only the two container
functions, but four of the five historical livelock regressions entered
through the row views: the manaflow-ai#2586/manaflow-ai#6556 GeometryReader -> @State
rowHeight probes lived in TabItemView and
SidebarWorkspaceGroupHeaderView (removed by manaflow-ai#6111, reintroduced by
livelocked in the wild on 2026-07-02 with exactly that signature
(manaflow-ai#2586 (comment)).

Scan the TabItemView region of ContentView.swift and the whole group
header file for per-row geometry feedback: GeometryReader,
onGeometryChange, manual sizeThatFits, ProposedViewSize(nil),
per-row anchorPreference/overlayPreferenceValue, and any discovered
custom Layout. Rows must stay measurement-free; the only sanctioned
geometry path is the container's drag-gated reader. Missing row types
fail loudly so a rename cannot rot the guard into a no-op.

Verified the extended guard retroactively flags both v0.64.17 row
views. New self-test cases (j)-(m) cover clean-pass, the manaflow-ai#6556 probe
shape, the manaflow-ai#5323 anchorPreference shape, and rename protection.

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

* Add behavioral scale gate for the sidebar lazy-layout contract

The lazy-layout contract (sidebar layout/diff work stays O(visible
rows), never O(all workspaces)) has regressed five times through five
different mechanisms (manaflow-ai#5323 anchorPreference aggregation, manaflow-ai#5764 String
ids, manaflow-ai#5845 animated height interpolation, manaflow-ai#6210 force-measuring custom
Layout, manaflow-ai#6556 GeometryReader -> @State feedback), each shipping to
stable before detection because nothing exercises the sidebar at the
100+ workspace scale where O(N) per pass livelocks the main thread
(manaflow-ai#2586).

SidebarLazyLayoutScaleTests mounts the real VerticalTabsSidebar with
300 workspaces in an NSHostingView and counts actual row body
evaluations through a DEBUG-only environment probe
(SidebarLazyContractProbe, same pattern as
MinimalModeInvalidationProbe):

- mount must realize only viewport rows (catches any virtualization
  defeat, present or future, regardless of mechanism)
- a 40-burst unread-model storm (the sidebar's highest-frequency
  whole-body invalidation path) must stay row-scoped and go quiet when
  the burst stops (catches feedback loops the way manaflow-ai#6556 manifested)
- a harness canary reproduces the GeometryReader -> @State shape in
  divergent form and asserts the harness detects it, so the gate
  cannot silently rot

This is the mechanism-independent backstop behind the source-shape
scan in scripts/check-sidebar-lazy-layout.py.

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

* Fix scale-test autorelease avalanche that hung/crashed the app host

Creating 300 workspaces inside one main-actor job accumulated every
autoreleased object from the O(N)-per-add snapshot work into a single
autorelease pool; the closing objc_autoreleasePoolPop then crashed CI
(Signal 11 in AutoreleasePoolPage::releaseUntil, masked as a green run,
see manaflow-ai#5641) and hung for hours
when reproduced on an AWS M4 Pro (sampled: main thread pinned in
releaseUntil). The app never does this; real workspace creation happens
one per event-loop turn with AppKit popping the pool between turns.

Make the harness match real cadence: per-iteration autoreleasepool
around addWorkspace and a run-loop turn every 20 creations. Also hoist
the RunLoop.run call into a synchronous helper (fixes the Swift 6
unavailable-from-async warning) wrapped in its own pool.

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

* Fix harness NSWindow double-release that killed the app host

NSZombies named the corpse: "-[NSKVONotifying_NSWindow release]:
message sent to deallocated instance". The harness windows used the
NSWindow default isReleasedWhenClosed=true, so tearDown's close()
performed AppKit's own release on top of ARC's; the double-release
SEGV'd the host at the next autorelease-pool pop, before the pass was
recorded, and CI masked the crash as a green run
(manaflow-ai#5641 (comment)).
With zombies absorbing the over-release, all assertions pass in under
a second, isolating the crash entirely to window teardown.

Set isReleasedWhenClosed = false on both harness windows.

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

* Guard --file mode: require container functions only when one is present

Addresses Greptile P2 on the PR: --file against a row-view source (no
workspaceScrollContent/workspaceRows) emitted false could-not-locate
violations that masked real row findings. In --file mode the container
checks now apply only when at least one guarded function exists in the
source, so ad-hoc row-view scans are clean while a fixture that renamed
one function still fails loudly. Self-test cases added for both sides.

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

* Split probe env key + extension into their own files (Aziz policy)

One major type per Swift file, matching the MinimalModeInvalidationProbe
three-file layout exactly. No content changes.

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

* Cover the group-header row wrapper (guard target + grouped scale fixture)

Codex review found the blind spot: the group-header row is assembled by
sidebarWorkspaceGroupHeader(...) in VerticalTabsSidebar+WorkspaceGroups.swift,
where modifiers wrap the header before it enters the LazyVStack — a
GeometryReader or anchorPreference added there defeats laziness exactly
like one inside the row view (the manaflow-ai#4385 regression entered through the
header path). The guard now scans that whole file for the row-forbidden
shapes with a rename-protected marker, and the scale fixture groups the
first 20 workspaces into 5 groups so group-header realization and
convergence are asserted by the behavioral backstop (bounds on
groupHeaderBodies at mount and in the quiet check).

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s, no host restarts.

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

* Make the scale harness hermetic against persisted sidebar provider

Codex review: VerticalTabsSidebar selects between the workspace list and
extension/built-in sidebars via
@AppStorage(CmuxExtensionSidebarSelection.defaultsKey), so a host with a
persisted non-default provider would mount the wrong sidebar and the
probes would never fire. Use a scratch UserDefaults suite pinned to the
default provider via .defaultAppStorage, cleaned in tearDown — the
WorkspaceContentViewVisibilityTests pattern.

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2071529)

This branch was successfully deployed

1 active deployment
Preview – cmux — bc784d66 Deployed Jun 14, 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

Development

Successfully merging this pull request may close these issues.

2 participants