Skip to content

Make workspace sidebar lazy with @Observable drag state - #3039

Closed
lawrencecchen wants to merge 5 commits into
mainfrom
task-make-sidebar-lazy
Closed

lawrencecchen wants to merge 5 commits into
mainfrom
task-make-sidebar-lazy

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Flip the workspace sidebar from VStack to LazyVStack so layout is O(visible), not O(total) workspaces.
  • Move drag transient state (draggedTabId, dropIndicator) off @State bindings onto a single @Observable @MainActor SidebarDragState, so 60fps writes during drag invalidate only the tiny overlays that read them, not the sidebar body or the LazyVStack cache.
  • Delete the SidebarTabItemPresentationSnapshot + frozen-presentation workflow; @Observable per-property tracking makes the context-menu freeze workaround unnecessary.

Why not just swap VStack → LazyVStack?

That's what regressed #2586: SidebarTabDropDelegate mutates dropIndicator at ~60fps during drag; with dropIndicator held as @State + passed as @Binding, every write invalidated the sidebar body and thrashed LazySubviewPlacements.placeSubviews, pegging the main thread and deadlocking CLI commands. The real invariant we needed was: pointer state mutated during drag must not invalidate any ancestor of the lazy list. @Observable's per-keypath tracking enforces that by construction — only views that directly read dragState.dropIndicator invalidate.

To keep that guarantee, the per-frame reads are isolated in dedicated views:

  • SidebarTabDropIndicatorOverlay — reads dragState.dropIndicator and dragState.draggedTabId, renders the indicator rectangle.
  • SidebarTabDragOpacityModifier — reads dragState.draggedTabId, applies drag opacity.

The 1600-line TabItemView body no longer reads drag transient state directly, so mid-drag writes don't invalidate it even though it's inside the LazyVStack.

What changed

  • SidebarDragState — new @Observable @MainActor class owning draggedTabId + dropIndicator.
  • VerticalTabsSidebar — three @State fields (draggedTabId, dropIndicator, frozenTabItemPresentation) collapsed into one @State dragState = SidebarDragState(). VStack → LazyVStack. The comment at the old line 10297 ("Workspaces are bounded, so prefer a non-lazy stack here") is gone.
  • TabItemView — drops three @Bindings (draggedTabId, dropIndicator, frozenPresentation) and one let livePresentation; gains one let dragState: SidebarDragState. Context-menu onAppear/onDisappear no longer set/clear a frozen snapshot.
  • SidebarEmptyArea — drops two @Bindings, gains one let dragState.
  • SidebarTabDropDelegate — drops two @Bindings, gains one let dragState. Write sites become dragState.draggedTabId = … / dragState.dropIndicator = ….
  • SidebarTabItemPresentationSnapshot — deleted (was only used by the frozen workflow).

Testing

  • xcodebuild -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-sidebar-lazy build — succeeds.
  • Tagged dogfood build sb-lazy produced for manual verification of drag-reorder, multi-select (cmd/shift click), context menu, notification pills, and mid-drag CPU behavior. No E2E test added — XCUITest coverage for sidebar drag/CPU is brittle and the real verification signal (3s CPU sample under drag, per the Nightly freezes: sidebar LazyVStack layout loop pegs main thread at 100% CPU, deadlocks CLI #2586 methodology) is manual.

Related


Summary by cubic

Switches the workspace sidebar to LazyVStack and moves drag/hover state into an @Observable SidebarDragState so per-frame updates only re-render tiny overlays. Restores smooth scrolling, dragging, hover behavior, and keeps context‑menu visuals stable.

  • Refactors

    • Replaced VStack with LazyVStack in VerticalTabsSidebar.
    • Introduced @MainActor @Observable SidebarDragState (draggedTabId, dropIndicator, hoveredTabId); removed the global SidebarTabItemPresentationSnapshot and re‑implemented a local per‑row frozen presentation (@State) while a context menu is open.
    • Plumbed dragState through TabItemView, SidebarEmptyArea, and SidebarTabDropDelegate, removing previous @Binding chains.
    • Extracted SidebarTabDropIndicatorOverlay, SidebarEmptyAreaDropIndicatorOverlay, SidebarTabDragOpacityModifier, and SidebarTabCloseButton to isolate high‑frequency reads from the main view body.
    • Updated .onChange to the macOS 14+ two‑argument API.
  • Bug Fixes

    • Fixed stuck hover that could show close buttons on multiple rows by centralizing hover tracking in dragState.hoveredTabId and clearing it on onDisappear when rows are recycled by LazyVStack.

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

Summary by CodeRabbit

  • Refactor
    • Centralized sidebar drag state and switched to a lazy stack for smoother, more efficient drag-and-drop rendering; split drag-opacity, drop-indicator, and close-button visuals into small subcomponents to limit re-renders.
  • Bug Fixes
    • Prevented hover/context-menu presentation freezes and ensured drag/drop indicators and hover state reliably reset on appear/disappear, during drag lifecycle, and after drops; stabilized row visuals while context menus are open.

Switches the left sidebar to LazyVStack for O(visible) layout instead of
O(total) workspaces. Previously the stack had to stay eager because drag
mutations (draggedTabId, dropIndicator) were @State on the ancestor, and
the 60fps writes during drag fed back through the lazy layout cache and
pegged the main thread (#2586).

Drag transient state now lives on a @observable @mainactor SidebarDragState
class. Reads are tracked per property, so high-frequency writes invalidate
only the tiny overlays that depend on them, never the sidebar body or the
LazyVStack layout.

- Add SidebarDragState (@observable) owning draggedTabId + dropIndicator.
- Replace @State draggedTabId/dropIndicator/frozenTabItemPresentation on
  VerticalTabsSidebar with a single @State SidebarDragState.
- Replace the @binding chain through TabItemView, SidebarEmptyArea, and
  both SidebarTabDropDelegate and SidebarBonsplitTabDropDelegate with a
  direct SidebarDragState reference.
- Extract SidebarTabDropIndicatorOverlay and SidebarTabDragOpacityModifier
  so the per-frame dropIndicator/draggedTabId reads stay inside isolated
  views and don't invalidate the 1600-line TabItemView body.
- Delete SidebarTabItemPresentationSnapshot and the frozen-presentation
  workflow (context-menu freeze was a workaround for binding churn that
  @observable's per-property tracking makes unnecessary).
- Flip VStack to LazyVStack inside the sidebar ScrollView.
@vercel

vercel Bot commented Apr 20, 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 Apr 20, 2026 11:08am

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Centralizes sidebar drag-and-drop state into a single @MainActor @Observable`` reference SidebarDragState owned by `VerticalTabsSidebar`; row views and drop delegate consume it. Refactors TabItemView/SidebarEmptyArea to use shared drag state, extracts small overlays/modifiers for high-frequency updates, and switches to `LazyVStack`.

Changes

Cohort / File(s) Summary
Core drag-state & sidebar
Sources/ContentView.swift
Add SidebarDragState observable; VerticalTabsSidebar owns, resets, and passes it to rows; switch tab container to LazyVStack; clear/reset drag state on appear/disappear and list mutations.
Row & empty-area consumers
Sources/ContentView.swift (TabItemView, SidebarEmptyArea)
Consume shared dragState instead of per-row bindings; move hover tracking to dragState.hoveredTabId; capture a local FrozenPresentation snapshot only when context menu opens; cleanup on .onDisappear.
Visuals: modifiers & overlays
Sources/ContentView.swift (SidebarTabDropIndicatorOverlay, SidebarTabDragOpacityModifier, SidebarTabCloseButton, SidebarEmptyArea overlay)
Extract drop-indicator, drag opacity, and close-button visibility into small subviews/modifiers so frequent dragState changes only invalidate those components.
Drop handling
Sources/ContentView.swift (SidebarTabDropDelegate)
Refactor delegate to read/write dragState for validation and indicator updates; use guards/defer to reliably clear dragState fields during perform/exit.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User
  participant Tab as TabItemView
  participant Sidebar as VerticalTabsSidebar
  participant State as SidebarDragState
  participant Delegate as SidebarTabDropDelegate

  User->>Tab: drag start (press+move)
  Tab->>State: set draggedTabId, initial payload
  Tab->>Sidebar: notify drag started
  Sidebar->>State: initialize/clear dropIndicator
  User->>Sidebar: hover over row/empty area
  Sidebar->>State: set hoveredTabId
  Sidebar->>Delegate: validate drop (reads State.draggedTabId, hoveredTabId)
  Delegate->>State: update dropIndicator (target index)
  User->>Sidebar: release (drop)
  Delegate->>State: perform drop, then defer clear draggedTabId & dropIndicator
  State->>Tab: state changes -> opacity/indicator modifiers update
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰
I nibble codes and tidy tracks,
One heartbeat now holds all the stacks.
Hover, drag, drop — a gentle beat,
Small overlays keep updates neat.
Sidebar snug, my hops complete.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: converting the sidebar to lazy-loading and introducing @Observable drag state management.
Description check ✅ Passed The description includes a comprehensive Summary section explaining what changed and why, and a Testing section documenting build verification and manual testing approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 task-make-sidebar-lazy

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.

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

ℹ️ 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
Comment on lines 13521 to 13524
contextMenuState.isVisible = true
contextMenuState.hasDeferredWorkspaceObservationInvalidation = false
contextMenuState.pendingWorkspaceSnapshot = nil
frozenPresentation = livePresentation
}

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 row snapshot while workspace context menu is visible

Dropping the frozenPresentation snapshot in TabItemView reintroduces the context-menu redraw path that was previously fixed for sidebar color picker blinking: while the menu is open, parent-driven updates (e.g. unread count changes or modifier-key hint toggles) now flow straight into the row and can force redraws that close/blink the menu. In practice this regresses the #2566 behavior because onAppear no longer captures stable presentation values before the menu stays on screen.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Restored. The frozen-presentation workflow is back, but now lives as local @State frozenPresentation: FrozenPresentation? on TabItemView (set on context-menu onAppear, cleared on onDisappear) and is consumed via effectiveUnreadCount / effectiveLatestNotificationText / effectiveShowsModifierShortcutHints. No global snapshot type or @binding reintroduced. Fixed in a88ea8e.

— Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/ContentView.swift (1)

10257-10269: Avoid the O(n) tabManager.tabs.firstIndex scan per row on every indicator change.

showsCenteredTopDropIndicator does a linear scan over tabManager.tabs to locate the current row, executed once per visible row each time dragState.dropIndicator flips (which is frequent during drag). Since the parent already computes tabIndexById and passes index: Int into this view, you can feed the same tabIndexById (or the already-known index) in and compare directly against dropIndicator.tabId's index. This removes the per-frame/per-row linear scan with zero behavior change.

♻️ Proposed refactor
 private struct SidebarTabDropIndicatorOverlay: View {
     let dragState: SidebarDragState
     let tabId: UUID
     let index: Int
     let rowSpacing: CGFloat
-    let tabManager: TabManager
+    let tabIndexById: [UUID: Int]

     var body: some View {
         if showsCenteredTopDropIndicator {
             Rectangle()
                 .fill(cmuxAccentColor())
                 .frame(height: 2)
                 .padding(.horizontal, 8)
                 .offset(y: index == 0 ? 0 : -(rowSpacing / 2))
         }
     }

     private var showsCenteredTopDropIndicator: Bool {
         guard dragState.draggedTabId != nil, let indicator = dragState.dropIndicator else { return false }
         if indicator.tabId == tabId && indicator.edge == .top {
             return true
         }
-        guard indicator.edge == .bottom,
-              let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }),
-              currentIndex > 0
-        else {
-            return false
-        }
-        return tabManager.tabs[currentIndex - 1].id == indicator.tabId
+        guard indicator.edge == .bottom, index > 0,
+              let neighborId = indicator.tabId,
+              let neighborIndex = tabIndexById[neighborId]
+        else {
+            return false
+        }
+        return neighborIndex == index - 1
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10257 - 10269, The computed property
showsCenteredTopDropIndicator currently does an O(n) scan via
tabManager.tabs.firstIndex for each row; instead accept the parent's precomputed
index mapping (either the passed-in index: Int or a tabIndexById lookup
closure/dictionary) into this view and use that to compare indices without
scanning: when dropIndicator is present, get indicatorIndex =
tabIndexById(indicator.tabId) or use the passed index for self (e.g.,
selfIndex), then return true if indicator.tabId == tabId && indicator.edge ==
.top, else if indicator.edge == .bottom return selfIndex > 0 && indicatorIndex
== selfIndex - 1; remove the tabManager.tabs.firstIndex call and any direct
access to tabManager.tabs in showsCenteredTopDropIndicator.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 10350-10353: The comment overstates isolation: update the doc
comment near LazyVStack to acknowledge that while high-frequency dropIndicator
writes don't invalidate the sidebar body, the sidebar does read
dragState.draggedTabId (see onChange(of: dragState.draggedTabId) inside
VerticalTabsSidebar.body) so drag start/end will invalidate
VerticalTabsSidebar.body; revise the wording to note this low-frequency
invalidation and reference SidebarDragState for the 60fps vs edge-case
distinction to avoid misleading future changes.
- Line 12890: TabItemView currently holds a SidebarDragState reference (let
dragState: SidebarDragState) which violates the LazyVStack row guideline; change
TabItemView's API to accept only immutable snapshots/closure accessors (e.g.,
isDraggedTab: `@escaping` () -> Bool and dropIndicatorPosition: `@escaping` () ->
CGFloat? or concrete Bool/CGFloat? values) instead of the SidebarDragState
object, remove the dragState property and update its Equatable conformance
accordingly, and move creation of SidebarTabDragOpacityModifier and
SidebarTabDropIndicatorOverlay into the parent (VerticalTabsSidebar) so the
parent composes those views/modifiers using the live SidebarDragState while
TabItemView remains a pure value-based row (keep the existing .equatable() usage
on the row).

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 10257-10269: The computed property showsCenteredTopDropIndicator
currently does an O(n) scan via tabManager.tabs.firstIndex for each row; instead
accept the parent's precomputed index mapping (either the passed-in index: Int
or a tabIndexById lookup closure/dictionary) into this view and use that to
compare indices without scanning: when dropIndicator is present, get
indicatorIndex = tabIndexById(indicator.tabId) or use the passed index for self
(e.g., selfIndex), then return true if indicator.tabId == tabId &&
indicator.edge == .top, else if indicator.edge == .bottom return selfIndex > 0
&& indicatorIndex == selfIndex - 1; remove the tabManager.tabs.firstIndex call
and any direct access to tabManager.tabs in showsCenteredTopDropIndicator.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5daea6b5-36cb-4cb4-8a5d-be9cd8552274

📥 Commits

Reviewing files that changed from the base of the PR and between 92769d7 and 7a64372.

📒 Files selected for processing (1)
  • Sources/ContentView.swift

Comment thread Sources/ContentView.swift
Comment thread Sources/ContentView.swift
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var draggedTabId: UUID?
@Binding var dropIndicator: SidebarDropIndicator?
let dragState: SidebarDragState

@coderabbitai coderabbitai Bot Apr 20, 2026 •

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate TabItemView declaration and its static == function, and confirm .equatable() is still applied.
ast-grep --pattern 'struct TabItemView: $$$ {
  $$$
}'

rg -nP --type=swift -C3 '\bstatic\s+func\s*==\s*\(\s*lhs\s*:\s*TabItemView'

rg -nP --type=swift -C1 '\.equatable\(\)'

Repository: manaflow-ai/cmux

Length of output: 2516


TabItemView row under LazyVStack holds a reference to @Observable store — violates guideline, though intentional trade-off for fine-grained invalidation.

TabItemView (row at Line 10413 under the LazyVStack in VerticalTabsSidebar) now carries let dragState: SidebarDragState. Per coding guidelines, "In SwiftUI panels with LazyVStack / LazyHStack / List / ForEach rows, no view below the snapshot boundary may hold a reference to an ObservableObject / @Observable store. Rows must receive immutable value snapshots plus closure action bundles only."

The Equatable contract is correctly maintained: dragState is deliberately excluded from the == function (line 12849–12852) with an explicit comment acknowledging that "object references are excluded from == because they're recreated every parent eval but don't affect rendering." The .equatable() modifier remains in place (line 10413).

However, the guideline violation stands: the row shape itself violates the strict rule, even though the runtime behavior is sound (only the isolated SidebarTabDragOpacityModifier and SidebarTabDropIndicatorOverlay subviews actually read the store properties, so this becomes a fine-grained invalidation trade-off rather than whole-row re-rendering).

To comply with the guideline, pass dragState's values as snapshot parameters to TabItemView and isolate the overlay/modifier creation in the parent. For example, pass isDraggedTab: @escaping () -> Bool and dropIndicatorPosition: @escaping () -> CGFloat? instead of the store reference, or compose the overlay/modifier as a ViewBuilder slot in the parent where dragState remains.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` at line 12890, TabItemView currently holds a
SidebarDragState reference (let dragState: SidebarDragState) which violates the
LazyVStack row guideline; change TabItemView's API to accept only immutable
snapshots/closure accessors (e.g., isDraggedTab: `@escaping` () -> Bool and
dropIndicatorPosition: `@escaping` () -> CGFloat? or concrete Bool/CGFloat?
values) instead of the SidebarDragState object, remove the dragState property
and update its Equatable conformance accordingly, and move creation of
SidebarTabDragOpacityModifier and SidebarTabDropIndicatorOverlay into the parent
(VerticalTabsSidebar) so the parent composes those views/modifiers using the
live SidebarDragState while TabItemView remains a pure value-based row (keep the
existing .equatable() usage on the row).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed the remaining guideline gap by extracting SidebarTabCloseButton, SidebarTabDropIndicatorOverlay, SidebarTabDragOpacityModifier, and SidebarEmptyAreaDropIndicatorOverlay so TabItemView.body no longer reads any dragState property itself; the row just threads the reference down to the isolated subviews that subscribe. Keeping the reference-typed dragState parameter is deliberate here (the subviews are defined in the same file and need the live store to subscribe), and it's excluded from == as documented. Fixed in a88ea8e.

— Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lawrencecchen Thanks for the update. Extracting SidebarTabCloseButton, SidebarTabDropIndicatorOverlay, SidebarTabDragOpacityModifier, and SidebarEmptyAreaDropIndicatorOverlay so only those lightweight pieces subscribe to SidebarDragState is a solid fix for the LazyVStack invalidation loop — and keeping TabItemView equatable with the store reference excluded from == preserves the row-skip behavior during typing.

One small note: TabItemView still mutates the store in its onHover handler (setting dragState.hoveredTabId). That means the row continues to hold and touch the store even though it no longer reads from it. If you ever want to fully satisfy the “no Observable store below the snapshot boundary” guideline without changing behavior, an optional follow-up would be:

  • Remove dragState from TabItemView and instead pass closures for writes, e.g. onHoverChange(Bool) that the parent wires to dragState.
  • Compose the drag-opacity modifier and drop-indicator overlay at the VerticalTabsSidebar call site (where dragState already lives), leaving the row itself value-only.

Given your rationale, I’ll consider this non-blocking and resolved on my side. Nice work tightening the read surface to those focused subviews.


✏️ Learnings added
Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 3039
File: Sources/ContentView.swift:12948-12948
Timestamp: 2026-04-20T10:30:34.808Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — TabItemView intentionally threads a SidebarDragState reference down to isolated subviews (SidebarTabCloseButton, SidebarTabDropIndicatorOverlay, SidebarTabDragOpacityModifier) which subscribe to the store; the row’s body should not read from the store. This is an accepted exception for the sidebar LazyVStack to achieve fine‑grained invalidation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: rodchristiansen
Repo: manaflow-ai/cmux PR: 2646
File: Sources/ContentView.swift:0-0
Timestamp: 2026-04-14T19:57:22.309Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — SidebarTabDropDelegate.performDrop(info:) funnels all sidebar drag-and-drop through a section-aware path: (1) if the drop target is inside a section, compute the insertion index from the destination section’s workspaceIds after removing the dragged id, then call TabManager.moveWorkspaceToSection(tabId:sectionId:atIndex:); (2) if the target is ungrouped, first remove the workspace from its source section via TabManager.removeWorkspaceFromSection(tabId:) and then call TabManager.reorderWorkspace(tabId:toIndex:). Same-index same-section drops intentionally no-op. This ensures cross-section moves update membership and same-section downward moves don’t overshoot.

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-25T04:48:00.216Z
Learning: Applies to **/ContentView.swift : In `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `EnvironmentObject`, `ObservedObject` (besides `tab`), or `Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use precomputed `let` parameters instead.

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-04T00:58:31.755Z
Learning: Applies to **/ContentView.swift : In `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `EnvironmentObject`, `ObservedObject` (besides `tab`), or `Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use precomputed `let` parameters instead.

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-19T00:16:24.631Z
Learning: Applies to Sources/ContentView.swift : In `TabItemView` in `ContentView.swift`, maintain `Equatable` conformance and `.equatable()` to skip body re-evaluation during typing. Do not add `EnvironmentObject`, `ObservedObject` (besides `tab`), or `Binding` properties without updating the `==` function

Learnt from: rodchristiansen
Repo: manaflow-ai/cmux PR: 2646
File: Sources/ContentView.swift:9956-9963
Timestamp: 2026-04-14T19:57:04.158Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — TabItemView.index must always reflect the flat order from TabManager.tabs (not the sidebar section/display order). VerticalTabsSidebar builds a tabIndexById from tabs.enumerated() and passes index: tabIndexById[tab.id] to all TabItemView call sites (pinned, ungrouped, and sectioned). Future changes should preserve this contract so shift-selection and moveBy(_:) continue to work correctly.

Learnt from: wonbywondev
Repo: manaflow-ai/cmux PR: 1805
File: Sources/ContentView.swift:662-672
Timestamp: 2026-03-19T10:48:34.765Z
Learning: Repo manaflow-ai/cmux — Sources/ContentView.swift: FileDropOverlayView is anchored to the window’s contentView (theme frame). Within its bounds, the only non-terminal/non-browser area is the Sidebar, so performDragOperation’s folder-drop fallback (calling onDrop when no terminal/web view is under the cursor) is effectively sidebar-only and does not require additional hit-testing for the titlebar or other regions.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2528
File: Sources/ContentView.swift:8917-8921
Timestamp: 2026-04-03T03:35:56.499Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — ShortcutHintModifierPolicy.shouldShowHints(for:) reveals sidebar/titlebar shortcut hints when the current modifier flags exactly equal KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber).modifierFlags, and returns false for chorded-number mappings. This follows the configured workspace-number modifier (not strictly Command-only). TabItemView’s close-button suppression via showsModifierShortcutHints therefore tracks the configured modifier. The debug toggle showHintsOnCommandHold predates customizable shortcuts and conceptually means “show hints on holding the configured workspace-number modifier(s).”

Learnt from: Znboston
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-26T21:24:07.678Z
Learning: Repo: manaflow-ai/cmux — Sources/SidebarSelectionState.swift — In `SidebarInteractionController.registerClick(on:location:...)`, call sites that use `onTapGesture` cannot supply real coordinates (SwiftUI's `onTapGesture` does not expose the tap location), so they pass `location: nil`. The `CGPoint?`-nil-gated distance check in `registerClick` is intentionally designed to skip spatial filtering for these callers. Target identity discrimination (`.groupHeader(groupId:)`, `.workspace(workspaceId:parentGroupId:)`, `.emptyArea`) prevents false double-click triggers across distinct targets. Do not flag missing coordinates from `onTapGesture` callsites as a defect; fixing it would require switching to a different gesture recognizer system.

Learnt from: rodchristiansen
Repo: manaflow-ai/cmux PR: 2647
File: Sources/ContentView.swift:2918-2932
Timestamp: 2026-04-14T20:05:27.511Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift (PR `#2647`): On macOS 26, NavigationSplitView provides a native collapse chevron in the sidebar column header when the sidebar is visible. The code intentionally: (1) shows a custom toolbar toggle only when the sidebar is hidden to reopen it, and (2) uses SystemSidebarToggleStripper to remove the system-injected toolbar toggle to prevent duplication. Do not flag “no collapse affordance” while the sidebar is open.

Learnt from: rodchristiansen
Repo: manaflow-ai/cmux PR: 2647
File: Sources/ContentView.swift:2889-2911
Timestamp: 2026-04-14T19:59:54.878Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — On macOS 26, the NavigationSplitView sidebar width is synchronized back to model state via a GeometryReader that updates sidebarWidth and SidebarState.persistedWidth on width changes. Do not flag “write-only sidebar width” drift for this path going forward.

Learnt from: Znboston
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-26T19:00:12.434Z
Learning: Repo: manaflow-ai/cmux — Sources/TabManager.swift — In `moveWorkspaceOutOfGroup(_:toSidebarIndex:)`, do NOT call `syncUngroupedSidebarOrder()` after inserting into `sidebarOrder`. That helper derives order from the `tabs` array insertion index, which is unrelated to the user's drag-drop target position. `sidebarOrder` is the source of truth for sidebar rendering; the raw insertion at the resolved sidebar index is correct and sufficient. Calling `syncUngroupedSidebarOrder()` would overwrite the intended drop position with stale `tabs` order, causing a snap-back regression.

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-19T00:16:24.631Z
Learning: Applies to **/*.swift : In SwiftUI panels with `LazyVStack` / `LazyHStack` / `List` / `ForEach` rows, no view below the snapshot boundary may hold a reference to an `ObservableObject` / `Observable` store. Rows must receive immutable value snapshots plus closure action bundles only

Learnt from: tayl0r
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-08T03:36:30.160Z
Learning: Repo: manaflow-ai/cmux — AppDelegate.FileBrowserDrawerState threading pattern (PR `#1909`, commit e0e57809): FileBrowserDrawerState must be threaded through AppDelegate.configure() as a weak stored property (matching the sidebarState pattern), passed through both configure() call sites, with registerMainWindow parameter made non-optional. The fallback `?? FileBrowserDrawerState()` must NOT be used as it creates detached instances that are not properly owned by the window context.

Learnt from: lucasward
Repo: manaflow-ai/cmux PR: 1903
File: Sources/ContentView.swift:13069-13113
Timestamp: 2026-03-21T06:23:38.764Z
Learning: Repo: manaflow-ai/cmux — In Sources/ContentView.swift, DraggableFolderNSView.updateIcon() intentionally applies the "sidebarMonochromeIcons" setting only on view creation (no live observer). Do not add defaults observers/AppStorage for live refresh in feature-scoped PRs; a live-refresh can be considered in a separate follow-up.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2662
File: Sources/TabManager.swift:1994-1995
Timestamp: 2026-04-07T05:27:49.028Z
Learning: Repo: manaflow-ai/cmux — Sources/TabManager.swift — In applyWorkspacePullRequestRefreshResults(...), resolution .unsupportedRepository must (1) clear any existing workspace.panelPullRequests[panelId] entry and (2) reset lastTerminalState timestamp. scheduleNextWorkspacePullRequestPoll(...) sets nextPollAt to a jittered background interval for .unsupportedRepository, avoiding hot-looping `git remote -v`. Intent: when a panel moves to a non-GitHub or otherwise unsupported repo, clear the sidebar PR badge and back off polling.

Learnt from: rodchristiansen
Repo: manaflow-ai/cmux PR: 2646
File: Sources/ContentView.swift:0-0
Timestamp: 2026-04-14T19:57:11.824Z
Learning: Repo: manaflow-ai/cmux — Sources/ContentView.swift — In TabItemView’s workspaceContextMenu “Move to Section”, compute disabled states across all selected workspaces: disable “No Section” only if every selected workspace has no section, and disable a given section only if every selected workspace is already in that section (use targetSectionIds built from targetIds).

Learnt from: atani
Repo: manaflow-ai/cmux PR: 819
File: Sources/AppDelegate.swift:0-0
Timestamp: 2026-03-04T14:05:42.574Z
Learning: Guideline: In Swift files (cmux project), when handling pluralized strings, prefer using localization keys with the ICU-style plural forms .one and .other. For example, use keys like statusMenu.unreadCount.one for the singular case (1) and statusMenu.unreadCount.other for all other counts, and similarly for statusMenu.tooltip.unread.one/other. Rationale: ensures correct pluralization across locales and makes localization keys explicit. Review code to ensure any unread count strings and related tooltips follow this .one/.other key pattern and verify the correct value is chosen based on the count.

Learnt from: austinywang
Repo: manaflow-ai/cmux PR: 954
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-05T22:04:34.712Z
Learning: Adopt the convention: for health/telemetry tri-state values in Swift, prefer Optionals (Bool?) over sentinel booleans. In TerminalController.swift, socketConnectable is Bool? and only set when socketProbePerformed is true; downstream logic must treat nil as 'not probed'. Ensure downstream code checks for nil before using a value and uses explicit non-nil checks to determine state, improving clarity and avoiding misinterpretation of default false.

Learnt from: moyashin63
Repo: manaflow-ai/cmux PR: 1074
File: Sources/AppDelegate.swift:7523-7545
Timestamp: 2026-03-09T01:38:24.337Z
Learning: When the command palette is visible (as in manaflow-ai/cmux Sources/AppDelegate.swift), ensure the shortcut handling consumes most Command shortcuts to protect the palette's text input. Specifically, do not allow UI zoom shortcuts (Cmd+Shift+= / Cmd+Shift+− / Cmd+Shift+0) to trigger while the palette is open. Do not reorder shortcut handlers (e.g., uiZoomShortcutAction(...)) to bypass this guard; users must close the palette before performing zoom actions. This guideline should apply to Swift source files handling global shortcuts within the app.

Learnt from: debgotwired
Repo: manaflow-ai/cmux PR: 1149
File: Sources/ContentView.swift:3977-3978
Timestamp: 2026-03-10T09:33:29.814Z
Learning: In manaflow-ai/cmux, for Sources/ContentView.swift, do not modify the sync-seeding behavior of scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh:). It is shared infrastructure across all submenus. Treat brief initial flashes as consistent with existing submenus. Any UX improvement (for example making seeding synchronous on a forced corpus refresh) should not be implemented in feature-scoped PRs; instead implement and test such UX changes globally in a dedicated follow-up PR.

Learnt from: zlatkoc
Repo: manaflow-ai/cmux PR: 1368
File: Sources/Panels/BrowserPanel.swift:69-69
Timestamp: 2026-03-13T13:46:01.733Z
Learning: Do not wrap engine/brand name literals (e.g., displayName values such as Google, DuckDuckGo, Bing, Kagi, Startpage) in String(localized: ...). These are brand/product names that are not translatable UI text. Localization should apply to generic UI strings (labels, buttons, error messages, etc.). Apply this guideline across Swift source files under Sources/ (notably in BrowserPanel.swift and similar UI/engine-related strings) and flag only brand-name strings that are part of user-facing UI text appropriately for translation scope.

Learnt from: kjb0787
Repo: manaflow-ai/cmux PR: 1461
File: Sources/GhosttyTerminalView.swift:5904-5905
Timestamp: 2026-03-15T19:22:32.330Z
Learning: In Swift files under the Sources directory that manage terminal/scroll behavior, ensure the following: when preserving scroll across workspace switches, save savedScrollRow only if the scrollbar offset is greater than 0 (indicating the user has scrolled up). On restore, call scroll_to_row only if savedScrollRow is non-nil; if it is nil, rely on synchronizeScrollView() to keep bottom-pinned sessions following new output. This pattern should be applied wherever GhosttyTerminalView-like views implement setVisibleInUI(_:) to maintain consistent user scroll state across workspace switches.

Learnt from: MaTriXy
Repo: manaflow-ai/cmux PR: 1460
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-16T08:02:06.824Z
Learning: In Swift sources, for any panel_id-only route handling in v2PanelMarkBackground(params:) and v2PanelMarkForeground(params:), first attempt v2ResolveTabManager(params:). Use the manager only if it actually owns the panelId; otherwise fall back to AppDelegate.shared?.locateSurface(surfaceId:) to locate the correct TabManager across windows. Apply this pattern to all panel_id-only routes to avoid active-window bias.

Learnt from: pratikpakhale
Repo: manaflow-ai/cmux PR: 2011
File: Resources/Localizable.xcstrings:15256-15368
Timestamp: 2026-03-23T21:39:50.795Z
Learning: When reviewing this repo’s Swift localization usage, do not flag missing `String.localizedStringWithFormat` for calls that use the modern overload `String(localized: "key", defaultValue: "...\(variable)")` (where `defaultValue` is a `String.LocalizationValue` built with `\(…)`). That overload natively supports interpolation and the xcstrings/runtime substitution handles the resulting placeholders automatically. Only require `String.localizedStringWithFormat` when using the older `String(localized:)` overload that takes a plain `String` (i.e., where format arguments must be passed separately), such as for keys like `clipboard.sshError.single`.

Learnt from: thunter009
Repo: manaflow-ai/cmux PR: 1825
File: Sources/TerminalController.swift:3620-3622
Timestamp: 2026-03-25T00:32:54.735Z
Learning: When validating or reporting workspace/tab colors in this repo, only accept and use 6-digit hex colors in the form `#RRGGBB` (no alpha, i.e., do not allow `#RRGGBBAA`). Ensure validation logic matches the existing behavior (e.g., WorkspaceTabColorSettings.normalizedHex(...) and TabManager.setTabColor(tabId:color:) as well as CLI/cmux.swift). Update any error/help text for workspace color to reference only `#RRGGBB` (not `#RRGGBBAA`).

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:3891-3903
Timestamp: 2026-04-02T21:37:21.463Z
Learning: In Swift source files like Sources/GhosttyTerminalView.swift, avoid logging raw startup commands or initialInput even in DEBUG (to prevent leaking sensitive paths/tokens and multiline content). If you need to diagnose startup/input, log only non-sensitive metadata such as (1) presence flags (e.g., hasStartupCommand/hasInitialInput), (2) byte counts, and (3) the relevant surface id (so issues can be correlated without exposing the underlying strings).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2528
File: Sources/cmuxApp.swift:6439-6444
Timestamp: 2026-04-03T03:35:54.082Z
Learning: In this repo’s keyboard shortcut implementation, ensure `KeyboardShortcutSettings.setShortcut(...)` does nothing (no-op) when `KeyboardShortcutSettings.isManagedBySettingsFile(action)` returns `true` (i.e., the shortcut is managed via `settings.json`). This prevents writing back overrides into `UserDefaults` and keeps `settings.json` as the source of truth.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2964
File: Sources/ContentView.swift:0-0
Timestamp: 2026-04-17T21:35:25.493Z
Learning: In this repo’s shell session resume flow, always build resume commands using the cwd guard helper exposed by SessionEntry (e.g., resumeCommandWithCwd). The helper should produce a command of the form `cd <shell-quoted cwd> && <resumeCommand>`. Update all call sites that generate “resume” commands (e.g., clipboard actions, drag-drop terminal, and in-app resume) to use this helper so that rc files and newly spawned shells cannot start outside the intended directory. Avoid constructing resume commands directly without the guarded `cd` + shell-quoting + `&&` composition.

@greptile-apps

greptile-apps Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR re-enables LazyVStack in the workspace sidebar by moving the mutable drag transient state (draggedTabId, dropIndicator) from @State/@Binding pairs into a single @Observable @MainActor SidebarDragState class. The core invariant is that ~60 fps dropIndicator writes now invalidate only the tiny dedicated overlay views (SidebarTabDropIndicatorOverlay, SidebarTabDragOpacityModifier) that read them, never the VerticalTabsSidebar body or LazyVStack cache — preventing the layout-loop regression from #2586. The SidebarTabItemPresentationSnapshot frozen-presentation workaround is removed as it is superseded by per-property @Observable tracking.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style suggestions with no correctness impact.

The architecture is sound: VerticalTabsSidebar.body only tracks draggedTabId (drag start/end, not per-frame), the LazyVStack's ForEach is protected by .equatable(), and per-frame dropIndicator reads are correctly isolated to dedicated subviews. Build passes. The two P2 findings (deprecated .onChange API, inline drag-state read in SidebarEmptyArea) are non-blocking.

No files require special attention.

Important Files Changed

Filename Overview
Sources/ContentView.swift Introduces SidebarDragState (@observable @mainactor), swaps VStack → LazyVStack, and replaces three @binding drag properties with a single reference-type object. Architecture is correct: VerticalTabsSidebar.body only observes draggedTabId (drag start/end), not the 60fps dropIndicator; per-frame invalidation is correctly scoped to SidebarTabDropIndicatorOverlay and SidebarTabDragOpacityModifier. Two minor P2 findings: deprecated .onChange API and SidebarEmptyArea reads drag state inline.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    DS["SidebarDragState\n@Observable @MainActor\n• draggedTabId\n• dropIndicator"]
    VTS["VerticalTabsSidebar.body\nobserves: draggedTabId\n(drag start/end only)"]
    LVS["LazyVStack\nnever invalidated by drag"]
    TIV["TabItemView\n.equatable() — body skipped\nduring drag"]
    OPM["SidebarTabDragOpacityModifier\nobserves: draggedTabId\nre-renders on drag start/end"]
    DIO["SidebarTabDropIndicatorOverlay\nobserves: dropIndicator + draggedTabId\nre-renders at ~60 fps"]
    SEA["SidebarEmptyArea\nobserves: dropIndicator (inline)\nre-renders at ~60 fps"]
    DD["SidebarTabDropDelegate\nwrites dragState.dropIndicator\nat ~60 fps"]
    DS -->|onChange| VTS
    VTS --> LVS
    LVS --> TIV
    TIV --> OPM
    TIV --> DIO
    VTS --> SEA
    DD -->|mutates| DS
Loading

Comments Outside Diff (1)

  1. Sources/ContentView.swift, line 12699-12706 (link)

    P2 Inline drag-state read breaks the dedicated-overlay pattern

    shouldShowTopDropIndicator reads both dragState.draggedTabId and dragState.dropIndicator directly in SidebarEmptyArea.body. Because dropIndicator mutates at ~60 fps during drag, SidebarEmptyArea.body re-evaluates at that rate — the same problem the PR avoids for tab rows by delegating per-frame reads to SidebarTabDropIndicatorOverlay.

    The impact is minor (the empty area is a single Color.clear + Rectangle overlay), but it is inconsistent with the stated invariant: "per-frame reads are isolated in dedicated views". Extracting the indicator rectangle into a small private struct (mirroring SidebarTabDropIndicatorOverlay) would close the gap.

Reviews (1): Last reviewed commit: "Make workspace sidebar lazy with @Observ..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift Outdated
)
}
.onChange(of: draggedTabId) { newDraggedTabId in
.onChange(of: dragState.draggedTabId) { newDraggedTabId in

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 Deprecated single-argument .onChange closure

The { newValue in } form was deprecated in macOS 14 / iOS 17. The new two-argument { oldValue, newValue in } signature is required by the current SDK and avoids a compiler warning in Xcode 16 (Swift 6 mode).

Suggested change
.onChange(of: dragState.draggedTabId) { newDraggedTabId in
.onChange(of: dragState.draggedTabId) { _, newDraggedTabId in

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: switched to the two-argument form .onChange(of: dragState.draggedTabId) { _, newDraggedTabId in ... }. Fixed in a88ea8e.

— Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 1 file

Under LazyVStack, per-row `@State isHovering` could get stuck at true
when a row scrolled out during an in-flight hover event, leaving multiple
rows showing their close (X) button simultaneously.

Move hover to the shared SidebarDragState.hoveredTabId so at most one
row is hovered by construction. Row still derives `isHovering` locally
as a computed read against dragState, so behavior (close button opacity,
accessibility) is identical when only one row is hovered.

@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: 224ea30703

ℹ️ 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 +12906 to +12907
private var isHovering: Bool {
dragState.hoveredTabId == tab.id

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep sidebar-row hover state out of shared observable store

Each TabItemView now reads dragState.hoveredTabId via isHovering, so every hover transition invalidates every visible row in the LazyVStack instead of just the row being entered/exited. This is a regression from the previous per-row @State behavior and can create O(visible-rows) recomputation while the mouse moves across the sidebar, which is exactly the invalidation pattern warned about in /workspace/cmux/AGENTS.md ("Snapshot boundary for list subtrees"). In large workspaces this can hurt sidebar responsiveness and reintroduce lazy-layout thrash.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: extracted SidebarTabCloseButton which reads dragState.hoveredTabId inside its own body, so hover transitions now invalidate only that tiny subview (one per visible row) rather than the full ~1600-line TabItemView body. TabItemView.body no longer reads dragState.hoveredTabId at all. Fixed in a88ea8e.

— Claude Code

- Extract SidebarTabCloseButton so hover reads no longer invalidate the
  full TabItemView body on every sidebar row (Codex P1).
- Restore context-menu frozen-presentation locally on the row using
  @State instead of the deleted global snapshot, preserving the stable
  visuals while the menu is open (Codex P2; preserves #2566 fix).
- Extract SidebarEmptyAreaDropIndicatorOverlay so SidebarEmptyArea body
  does not re-evaluate at ~60fps during drag (Greptile P2).
- Update .onChange signature to the two-argument form required by the
  macOS 14+ SDK (Greptile P2).
- Tighten the LazyVStack doc comment to note that draggedTabId is also
  observed by the sidebar body via onChange (CodeRabbit minor).
Codex adversarial review flagged: a row hovered when it scrolls offscreen
under LazyVStack never receives .onHover(false), so dragState.hoveredTabId
stays pointing at its tab id. When the row scrolls back in, its
SidebarTabCloseButton renders as hovered even though the pointer is
elsewhere.

Add .onDisappear on the row that nils hoveredTabId if it matches tab.id.

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

🧹 Nitpick comments (1)
Sources/ContentView.swift (1)

10262-10274: Consider memoizing the previous-row lookup to avoid O(n) scans per frame on every visible row during drag.

showsCenteredTopDropIndicator calls tabManager.tabs.firstIndex(where:) each time it evaluates. Since every visible SidebarTabDropIndicatorOverlay reads dragState.dropIndicator (which mutates at ~60fps during drag), each frame does O(N) linear search per visible row → O(N·V) per indicator update, where V is the number of visible rows. For typical sidebars this is negligible, but if users hit the reported large-workspace-count scenarios from issue #2586 this undoes some of the laziness win.

One option: precompute previousTabId in VerticalTabsSidebar (you already have tabIndexById) and pass it in as a let previousTabId: UUID?, turning the check into an == comparison. Non-blocking; flagging for awareness since perf under many workspaces is the explicit motivation of this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10262 - 10274,
showsCenteredTopDropIndicator currently does an O(n) search via
tabManager.tabs.firstIndex(where:) each evaluation; precompute the previous tab
id and pass it in so the method can use an O(1) comparison. Add a let
previousTabId: UUID? in VerticalTabsSidebar (using existing tabIndexById or a
one-time lookup) and forward it to SidebarTabDropIndicatorOverlay, then change
showsCenteredTopDropIndicator to compare indicator.tabId == previousTabId (and
keep the existing checks for indicator.edge == .top/.bottom and tabId equality)
to avoid per-frame linear scans during drag.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 10262-10274: showsCenteredTopDropIndicator currently does an O(n)
search via tabManager.tabs.firstIndex(where:) each evaluation; precompute the
previous tab id and pass it in so the method can use an O(1) comparison. Add a
let previousTabId: UUID? in VerticalTabsSidebar (using existing tabIndexById or
a one-time lookup) and forward it to SidebarTabDropIndicatorOverlay, then change
showsCenteredTopDropIndicator to compare indicator.tabId == previousTabId (and
keep the existing checks for indicator.edge == .top/.bottom and tabId equality)
to avoid per-frame linear scans during drag.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f878c57e-9f6d-434f-b280-04a964060883

📥 Commits

Reviewing files that changed from the base of the PR and between a88ea8e and 6574946.

📒 Files selected for processing (1)
  • Sources/ContentView.swift

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 65749468 Deployed Apr 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants