Skip to content

fix: rewrite sidebar workspace context menu in AppKit NSMenu (#2003) - #4633

Open
nanami-he wants to merge 1 commit into
manaflow-ai:mainfrom
nanami-he:feature/nsmenu-sidebar-context-menu
Open

nanami-he wants to merge 1 commit into
manaflow-ai:mainfrom
nanami-he:feature/nsmenu-sidebar-context-menu

Conversation

@nanami-he

@nanami-he nanami-he commented May 23, 2026 •

Copy link
Copy Markdown

Summary

Replaces SwiftUI .contextMenu / nested Menu for the sidebar workspace right-click menu with an AppKit NSMenu hosted by a transparent NSViewRepresentable overlay. Closes #2003.

Why the previous patches didn't fully fix it

Earlier fixes (9d4220b17, cdcb16a34, b0e6b35fd) each shaved off a specific invalidation source but couldn't address the architecture: SwiftUI tears down any expanded Menu whenever the host view body re-evaluates. The sidebar body re-evaluates at 1–4 Hz under normal terminal output, so the color submenu — which has the most items and the longest hover distance — gets dismissed before the cursor reaches a color.

Detailed source analysis with SwiftUI's _printChanges() + a publish-frequency watcher identified 5 invalidation sources in the ContentView body:

  1. titlebarText: @State writes (≈1 Hz)
  2. .onReceive(.ghosttyDidSetTitle) — emit invalidates host body even with an empty closure (undocumented Apple behavior; NotificationCenter.addObserver directly avoids it)
  3. fileExplorerStore: @StateObject internal @Published (≈1.5 Hz)
    4–5. notificationStore: @EnvironmentObject re-renders on every read

Patching all of those still leaves a ≈1 Hz floor from \PaneState.tabs (@Observable macro). The repository's existing Snapshot boundary for list subtrees rule (CLAUDE.md, motivated by #2586) mitigates row churn but not parent body churn — and parent body churn is what kills the SwiftUI submenu. The fix is to move the menu off the SwiftUI render tree entirely.

Approach

New file Sources/Sidebar/WorkspaceContextMenuOverlay.swift

  • WorkspaceContextMenuOverlay: NSViewRepresentable (@MainActor)
  • MenuHostView: NSView with hitTest(_:) that claims the event only on .rightMouseDown or control + .leftMouseDown. All other events (drag, left-click, hover) pass through to the SwiftUI hierarchy underneath, so drag-to-reorder, selection, and hover highlight are untouched.
  • Coordinator: NSObject, NSMenuDelegate (@MainActor) builds the NSMenu per right-click from a Workspace snapshot. The @MainActor isolation lets the delegate read AppDelegate.shared and workspace properties directly without cross-actor hops.
  • WorkspaceContextMenuAction enum covers full parity with the old SwiftUI menu: pin/unpin, rename, remove custom name, edit/clear description, reconnect/disconnect SSH, scrollbar toggle, color palette (16 entries) + custom color + clear, move-to-window targets pulled dynamically via windowMoveTargets(referenceWindowId:), close current/others/above/below, mark read/unread, clear latest notification, copy UUID, show in Finder.
  • Color swatches rendered via coloredCircleImage(color:) (NSImage) since NSMenuItem doesn't accept SwiftUI views.

Sources/ContentView.swift — replaces the .contextMenu { ... Menu("Workspace Color") { ... } ... } chain with .overlay(WorkspaceContextMenuOverlay(...)). Wires menu open/close into the existing rowInteractionState.contextMenuDidAppear() / contextMenuDidDisappear() so the sidebar's anti-jitter pause logic still triggers. handleMenuAction(_:) dispatches the action enum to the existing functions previously called by the SwiftUI menu items.

cmux.xcodeproj/project.pbxproj — registers the new file in PBXBuildFile, PBXFileReference, PBXGroup, and PBXSourcesBuildPhase.

Testing

  • ./scripts/reload.sh --tag nsmenu-sidebar-context-menu — Debug build completes cleanly (reload succeeded in 16s), no concurrency warnings.
  • Manual verification on the tagged debug build with a terminal running high-rate stdout: right-click sidebar → Workspace Color submenu opens and stays open across all 16 color items, zero flicker, zero premature dismissal. Same with Move-to-Window and Workspace Settings submenus.
  • Left-click selection, drag-to-reorder, hover highlight, and scroll all still work (hitTest pass-through verified).

Not added: an automated regression test. Driving NSMenu interaction under high-rate publish in XCTest would need a non-trivial UI harness. Happy to add one if a preferred test seam exists — guidance from maintainers would help.

Closes #2003.


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


Summary by cubic

Rewrites the sidebar workspace context menu to an AppKit NSMenu via a transparent NSViewRepresentable overlay so submenus don’t close during SwiftUI re-renders. Adds workspace grouping and “Copy Workspace Link” actions; shortcuts are preserved, items use precomputed state, and drag/selection/hover/scroll behavior is unchanged.

  • Bug Fixes

    • Stops submenu flicker/early dismissal under high stdout by detaching the menu from the SwiftUI lifecycle.
    • Workspace Color and Move-to-Window submenus stay open; open/close still trigger existing anti-jitter hooks.
    • Correctly enables “Move to Top” only when the selected block isn’t already at the top.
    • Deterministic “Show in Finder” enablement using a cached Finder URL from the parent.
    • “Copy SSH Error” now sanitizes text (trims and replaces the home path).
  • Refactors

    • Adds WorkspaceContextMenuOverlay and a WorkspaceContextMenuAction enum; builds an NSMenu per click and only captures right-/control-clicks. Enum now includes workspace grouping (new group, move to group, remove from group) and “Copy Workspace Link”.
    • Replaces .contextMenu with .overlay(...), routing actions through handleMenuAction(_); menuWillOpen/DidClose drive the existing hooks.
    • Precomputes tab count, ordered workspace IDs, reference window ID, canMarkWorkspaceRead/Unread, hasLatestNotifications, hasCustomColor/title/description, and workspace grouping state (eligible target IDs, all targets in same group, has any grouped target); resolves the Finder URL in the parent and passes it down.
    • Adds a localized menu title and registers the new file in cmux.xcodeproj.

Written for commit 863fad9. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • New workspace context menu overlay with full set of actions (pin/rename/remove name, edit/clear description, remote connect/disconnect, terminal scrollbar toggle, color actions, move/close variants, mark read/unread, clear notifications, copy SSH error/ID, Show in Finder).
  • Performance

    • Sidebar rows now precompute and cache menu inputs (including Finder directory) to avoid unnecessary row recomputation.
  • Bug Fixes

    • Menu lifecycle reliably snapshots and flushes deferred workspace state.
  • Localization

    • Added localized title for the workspace context menu (English/Japanese).

Review Change Stack

@vercel

vercel Bot commented May 23, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 23, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces per-row SwiftUI .contextMenu with an AppKit-backed WorkspaceContextMenuOverlay (NSViewRepresentable), adds WorkspaceContextMenuAction, updates TabItemView inputs/Equatable, snapshots frozen presentation during menu lifecycle, precomputes Finder-directory caches, and implements an imperative handleMenuAction(_:) with remote-workspace helpers.

Changes

Workspace Context Menu Refactor

Layer / File(s) Summary
Menu Action Contract & Overlay Type Definition
Sources/Sidebar/WorkspaceContextMenuOverlay.swift
Adds WorkspaceContextMenuAction enum and WorkspaceContextMenuOverlay NSViewRepresentable with contextual inputs and onAction/onMenuWillOpen/onMenuDidClose callbacks.
Overlay Representable Plumbing
Sources/Sidebar/WorkspaceContextMenuOverlay.swift
Implements makeNSView, updateNSView, and makeCoordinator to attach the Coordinator and host AppKit view.
Menu Construction & Delegation
Sources/Sidebar/WorkspaceContextMenuOverlay.swift
Coordinator builds the dynamic NSMenu (pin/rename/description, remotes, settings, color swatches, SSH error, move/close/read-state, Finder/ID copy), configures enablement and shortcuts, and forwards lifecycle/selection events.
NSView Interaction Wiring
Sources/Sidebar/WorkspaceContextMenuOverlay.swift
MenuHostView overrides hitTest(_:) and menu(for:) to capture right/control clicks and provide the Coordinator-built menu.
ContentView Integration & Action Handler
Sources/ContentView.swift
Row logic precomputes menu inputs (tabCount, canMarkWorkspaceRead, canMarkWorkspaceUnread, hasLatestNotifications, referenceWindowId, hasCustomColorInSelection, finder cache keys/URL), updates TabItemView stored properties and Equatable, replaces .contextMenu with WorkspaceContextMenuOverlay, snapshots/clears frozenPresentation on open/close, and adds remoteContextMenuWorkspaces() and handleMenuAction(_:) to execute WorkspaceContextMenuAction cases imperatively.
Xcode Project Build Integration & Localization
cmux.xcodeproj/project.pbxproj, Resources/Localizable.xcstrings
Wires Sidebar/WorkspaceContextMenuOverlay.swift into the cmux target (PBXFileReference, PBXBuildFile, Sources group, Sources build phase) and adds the contextMenu.title localization key.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#2797: Related workspace context menu UI changes that add menu items for Git metadata watcher control.
  • manaflow-ai/cmux#4309: Related ContentView/sidebar integration changes touching the extension/sidebar rendering and wiring.

Poem

🐰 I hopped from SwiftUI into AppKit's glade,
menus stitched with care where right-clicks are made.
Actions numbered, colors caught in swatch light,
caches precomputed for a snappy bite.
Cheers — the overlay hops into the night.


Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (5 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift File And Package Boundaries ❌ Error New file WorkspaceContextMenuOverlay.swift (434 lines) exceeds 400-line threshold and mixes menu construction, AppKit bridge, actions, and localization. ContentView.swift is 606 lines over budget. Update budget file with new file limits or extract menu building responsibilities and reduce file sizes.
Cmux Swift Logging ❌ Error Sources/AppDelegate.swift adds 3 unguarded NSLog statements in production code (auth.callback, Command send, LaunchServices registration) violating swift-logging.md rule requiring #if DEBUG guards. Guard NSLog statements with #if DEBUG or use Logger from os.log with proper category/subsystem per the logging rules preference.
Cmux Full Internationalization ❌ Error PR violates full-internationalization.md: 4 localization keys missing, 11 incomplete with only 2-4 locales instead of 19 supported. Add missing contextMenu.disconnect*/reconnect* keys and complete 15 incomplete contextMenu entries with translations for all 19 supported locales in Resources/Localizable.xcstrings.
Cmux Swiftui State Layout ❌ Error WorkspaceContextMenuOverlay stores ObservableObject tab when only isPinned is needed, and TabItemView's @Binding frozenPresentation missing from == function. Replace tab: Tab with isPinned: Bool in WorkspaceContextMenuOverlay; add frozenPresentation to TabItemView's == function per review comments.
Cmux Architecture Rethink ❌ Error PR violates rule #10: frozenPresentation @Binding excluded from TabItemView equality, leaving stale state representable. Also stores Tab (ObservableObject) in overlay. Include frozenPresentation in TabItemView == function. Replace overlay's let tab: Tab with isPinned: Bool snapshot. Unify three state variables managing context menu lifecycle into one owner.
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (11 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: rewriting the sidebar workspace context menu to use AppKit NSMenu instead of SwiftUI, directly addressing the core architectural fix for issue #2003.
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 WorkspaceContextMenuOverlay and MenuHostView properly marked @MainActor; Coordinator @MainActor; no background access to UI-bound stores; SwiftUI Views permitted without explicit MainActor.
Cmux Swift Blocking Runtime ✅ Passed No blocking runtime patterns: zero DispatchQueue.main.sync, semaphores, locks, Task.sleep, or asyncAfter in WorkspaceContextMenuOverlay.swift. All callbacks perform non-blocking state updates.
Cmux No Hacky Sleeps ✅ Passed PR modifies only Swift files and Xcode/localization files. No TypeScript, JavaScript, shell, or build/runtime scripts are modified, so the rule does not apply.
Cmux Swift Concurrency ✅ Passed PR uses AppKit NSViewRepresentable+NSMenuDelegate for context menu (allowed boundary). No background queues, Combine, fire-and-forget Tasks, or completion handlers introduced.
Cmux Swift @Concurrent ✅ Passed No Swift concurrency violations found. All @MainActor isolation properly maintained, buildMenu() and image generation are synchronous UI-bound operations with no async/await or @concurrent misuse.
Cmux User-Facing Error Privacy ✅ Passed PR adds only safe menu item labels (e.g., "Pin Workspace", "Rename Workspace", "Copy SSH Error") and refactors UI from SwiftUI to AppKit. No new error messages or sensitive information exposure.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds WorkspaceContextMenuOverlay as NSViewRepresentable wrapping NSView and NSMenu context menus—both are allowed per the rule. No NSWindow/NSPanel/NSWindowController/Window/WindowGroup created.
Description check ✅ Passed PR description is thorough and well-structured, covering what changed, why (with detailed analysis of root causes and prior attempts), approach, and testing methodology.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Replaces the sidebar workspace right-click handler from SwiftUI .contextMenu / nested Menu to a transparent NSViewRepresentable overlay backed by an AppKit NSMenu. This detaches the menu lifecycle from SwiftUI's render cycle, preventing submenus from being dismissed when the host body re-evaluates under high terminal output.

  • WorkspaceContextMenuOverlay.swift (new, 504 lines): MenuHostView overrides hitTest to claim only right-/control-click events and builds a fresh NSMenu snapshot per click via Coordinator.buildMenu(). NSMenuDelegate callbacks wire back into the existing anti-jitter hooks.
  • ContentView.swift: Precomputes menu-state inputs in VerticalTabsSidebar.body and passes them as value snapshots into TabItemView; the .contextMenu chain is replaced by .overlay(WorkspaceContextMenuOverlay(...)) and workspaceContextMenu ViewBuilder is replaced by a handleMenuAction(_ action:) switch.
  • cmux.xcodeproj/project.pbxproj: Registers the new file in all four required Xcode sections.

Confidence Score: 5/5

Safe to merge — the architectural swap is well-contained, actor isolation is correct throughout, and the passthrough hitTest preserves all existing drag/selection/hover/scroll interactions.

The NSMenu lifecycle is cleanly isolated from SwiftUI's render cycle: buildMenu() is called once per right-click from menu(for:event:), the coordinator is @mainactor matching AppKit's main-thread menu dispatch, and all precomputed state (tabCount, eligibleTargetIds, hasCustomColor, etc.) was verified to match what the old ViewBuilder computed. The tabCount parameter maps to renderContext.workspaceCount which equals tabs.count, so Close Below / Above / Others enabled-state logic is equivalent to the original.

No files require special attention.

Important Files Changed

Filename Overview
Sources/Sidebar/WorkspaceContextMenuOverlay.swift New 504-line file introducing WorkspaceContextMenuOverlay (NSViewRepresentable), Coordinator (NSMenuDelegate, @mainactor), MenuHostView, and WorkspaceContextMenuAction enum. hitTest passthrough is correct; buildMenu() is called per-click for a fresh snapshot; @mainactor isolation is consistent with AppKit's main-thread menu dispatch.
Sources/ContentView.swift Precomputes menu state (canMarkRead/Unread, hasCustomColor, eligibleTargetIds, etc.) in VerticalTabsSidebar.body and passes them as value snapshots into TabItemView; replaces .contextMenu with .overlay(WorkspaceContextMenuOverlay); drops workspaceContextMenu ViewBuilder in favour of handleMenuAction(_:) dispatch switch. tabCount correctly maps to tabs.count via renderContext.workspaceCount.
cmux.xcodeproj/project.pbxproj Adds WorkspaceContextMenuOverlay.swift to PBXBuildFile, PBXFileReference, PBXGroup, and PBXSourcesBuildPhase — all four required sections present.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant MHV as MenuHostView (NSView)
    participant C as Coordinator (NSMenuDelegate)
    participant SM as SwiftUI TabItemView
    participant Store as tabManager / stores

    U->>MHV: rightMouseDown / ctrl+leftMouseDown
    MHV->>MHV: hitTest() → return self (claim event)
    MHV->>C: menu(for event:)
    C->>C: buildMenu() — snapshot overlay props at click time
    C-->>MHV: "NSMenu (prebuilt, autoenablesItems=false)"
    MHV->>U: NSMenu displayed (AppKit-owned, outside SwiftUI render cycle)

    Note over SM: SwiftUI body re-evaluations paused during menu display

    U->>C: Select menu item (e.g. Close Workspace)
    C->>C: handleMenuItemAction(_:) → overlay.onAction(.close)
    C->>SM: handleMenuAction(.close) via onAction closure
    SM->>Store: closeTabs(targetIds, allowPinned:)

    C->>SM: menuWillOpen → onMenuWillOpen()
    SM->>SM: rowInteractionState.contextMenuDidAppear()

    C->>SM: menuDidClose → onMenuDidClose()
    SM->>SM: rowInteractionState.contextMenuDidDisappear()
    SM->>SM: flushDeferredWorkspaceObservationInvalidation()
Loading

Reviews (8): Last reviewed commit: "fix: rewrite sidebar workspace context m..." | Re-trigger Greptile

Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 14005-14029: TabItemView.body is currently reading tabManager and
notificationStore and passing tabManager into WorkspaceContextMenuOverlay which
can cause stale menu state; precompute all mutable/derived values above the row
(e.g., let precomputedTabCount = tabManager.tabs.count, let
precomputedHasLatestNotifications = hasLatestNotifications(in:
contextMenuWorkspaceIds), let canMarkWorkspaceRead =
notificationStore.canMarkWorkspaceRead(forTabIds: contextMenuWorkspaceIds), let
canMarkWorkspaceUnread = notificationStore.canMarkWorkspaceUnread(forTabIds:
contextMenuWorkspaceIds)) and then pass those snapshot values (and any needed
action closures that capture tabManager lazily) into WorkspaceContextMenuOverlay
instead of the tabManager or direct notificationStore reads; remove the direct
tabManager reference from the overlay initializer and replace with explicit
value parameters and small closures for mutations so TabItemView.body performs
no store reads.

In `@Sources/Sidebar/WorkspaceContextMenuOverlay.swift`:
- Line 37: Remove the TabManager reference from WorkspaceContextMenuOverlay and
instead accept an immutable referenceWindowId input from the parent; replace
uses of the tabManager property (specifically the computation of
referenceWindowId) with this new immutable property, update the view
initializer/signature to take referenceWindowId, and propagate that value into
any row/drop-gap subviews so they no longer capture an ObservableObject; also
find the other occurrences in this file where TabManager is used (the spots
noted around the later row implementations) and refactor them the same way so
rows only receive immutable data plus action closures.
🪄 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: ffc1f31b-9937-4e11-9618-5c1c53a2e7c0

📥 Commits

Reviewing files that changed from the base of the PR and between d323780 and 2518293.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/Sidebar/WorkspaceContextMenuOverlay.swift
  • cmux.xcodeproj/project.pbxproj

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from 2518293 to ce4fc47 Compare May 23, 2026 04:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
Sources/ContentView.swift (1)

10801-10805: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Finish the row-store extraction.

Precomputing these menu flags is the right direction, but TabItemView is still being fed live tabManager / notificationStore references, so the equatable row still sits on the forbidden store-holding path under the lazy sidebar. Please finish this by passing only immutable snapshots plus action closures into TabItemView, and keep the store-owned imperative handlers above the row boundary.

As per coding guidelines, "TabItemView in ContentView.swift ... Do not read tabManager or notificationStore in the body; use precomputed let parameters instead" and "no view below that boundary may hold a reference to an ObservableObject / @Observable store. Rows and drop-gaps receive immutable value snapshots plus closure action bundles only."

Also applies to: 10836-10840

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

In `@Sources/ContentView.swift` around lines 10801 - 10805, TabItemView is still
reading live tabManager/notificationStore inside its body; finish the row-store
extraction by computing immutable snapshots (e.g. tab snapshot including id,
title, unread count, hasLatestNotification) and the precomputed flags
(precomputedTabCount, precomputedHasLatestNotifications, canMarkWorkspaceRead,
canMarkWorkspaceUnread, referenceWindowId, contextMenuWorkspaceIds) above the
row and pass only those value snapshots plus action closures into TabItemView;
remove any direct references to tabManager or notificationStore from TabItemView
and keep all imperative store calls (mark read/unread, open/close/move tab,
fetch latest) in the parent scope where you build the closures so rows and
drop-gaps hold no ObservableObject/store references.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 14174-14184: The context-menu close cases call closeTabs,
closeOtherTabs, closeTabsBelow, and closeTabsAbove without an explicit
CloseTabConfirmationTrigger, which allows the default/forbidden trigger to be
used; update each case (.close, .closeOthers, .closeBelow, .closeAbove) to pass
an explicit trigger (e.g. CloseTabConfirmationTrigger.contextMenu) into the
calls to closeTabs(targetIds, allowPinned:), closeOtherTabs(_:),
closeTabsBelow(tabId:), and closeTabsAbove(tabId:), and ensure downstream APIs
(Workspace.markExplicitClose(surfaceId:trigger:) and
TabManager.closeWorkspaceFromCloseTabGesture(_:trigger:)) are invoked with that
trigger rather than relying on default parameter values.

In `@Sources/Sidebar/WorkspaceContextMenuOverlay.swift`:
- Around line 36-37: The context menu currently checks overlay.tab.customColor
(in WorkspaceContextMenuOverlay) which uses the clicked row model; change the
overlay to accept an immutable selection-level snapshot (e.g.,
selectedWorkspaces or selectionSnapshot) passed from the parent that owns the
LazyVStack/ForEach, then replace uses of overlay.tab.customColor (lines
~200–205) with a computed check over that snapshot (e.g.,
selectionSnapshot.contains { $0.customColor != nil }) so the "Clear Color" menu
item is driven by the selection state rather than the clicked row; ensure the
overlay API does not capture any ObservableObject/store and only uses value-type
snapshots and action closures.
- Line 96: In WorkspaceContextMenuOverlay replace every hardcoded NSMenu(title:
"...") with a localized string using String(localized: "key.name", defaultValue:
"English text"); e.g. change NSMenu(title: "Workspace Context Menu") to
NSMenu(title: String(localized: "workspace.context_menu.title", defaultValue:
"Workspace Context Menu")) and do the same for the other two NSMenu initializers
(use distinct keys such as "workspace.submenu1.title" and
"workspace.submenu2.title"), then add those keys and default English values to
Resources/Localizable.xcstrings and provide translations.

---

Duplicate comments:
In `@Sources/ContentView.swift`:
- Around line 10801-10805: TabItemView is still reading live
tabManager/notificationStore inside its body; finish the row-store extraction by
computing immutable snapshots (e.g. tab snapshot including id, title, unread
count, hasLatestNotification) and the precomputed flags (precomputedTabCount,
precomputedHasLatestNotifications, canMarkWorkspaceRead, canMarkWorkspaceUnread,
referenceWindowId, contextMenuWorkspaceIds) above the row and pass only those
value snapshots plus action closures into TabItemView; remove any direct
references to tabManager or notificationStore from TabItemView and keep all
imperative store calls (mark read/unread, open/close/move tab, fetch latest) in
the parent scope where you build the closures so rows and drop-gaps hold no
ObservableObject/store references.
🪄 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: 0329053d-27d6-433e-81b5-719d117f4075

📥 Commits

Reviewing files that changed from the base of the PR and between 2518293 and ce4fc47.

📒 Files selected for processing (3)
  • Sources/ContentView.swift
  • Sources/Sidebar/WorkspaceContextMenuOverlay.swift
  • cmux.xcodeproj/project.pbxproj

Comment thread Sources/ContentView.swift
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from ce4fc47 to f14eb47 Compare May 23, 2026 05:05
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@Resources/Localizable.xcstrings`:
- Line 38310: The new localization key "contextMenu.title" only has entries for
"en" and "ja"; update the Resources/Localizable.xcstrings catalog so that
"contextMenu.title" includes a localization object for every locale already
present in that catalog (add the missing locale keys with translated strings or
approved placeholder translations), ensuring each locale's "stringUnit" has
"state" and "value" populated; modify the same "contextMenu.title" entry to add
those locale entries rather than creating a partial subset so the catalog
remains complete per the localization guidelines.

In `@Sources/ContentView.swift`:
- Around line 14060-14062: The TabItemView is mutating shared state via
handleMenuAction(_:) inside the row; move the switch that performs
tabManager-backed mutations out of TabItemView's body and into the parent that
builds the LazyVStack/.equatable() rows. Replace onAction: {
handleMenuAction(action) } with a narrow action bundle (e.g., closures like
onRename(_:), onClose(id:), onMoveUp(id:)) or a single delegated closure that
only forwards an enum action to the parent; implement the actual switch and
calls to tabManager (or other Observable stores) in the parent scope and pass
only immutable snapshot data plus these small closures into TabItemView/overlay
so the row remains snapshot-only.

In `@Sources/Sidebar/WorkspaceContextMenuOverlay.swift`:
- Around line 257-260: The "Move to Top" menu item is enabled whenever
overlay.contextMenuWorkspaceIds is non-empty, which allows a no-op when the
top-most selected workspace is already first; change the enablement logic in the
block that creates moveToTopItem (and references
WorkspaceContextMenuAction.moveToTop and overlay.contextMenuWorkspaceIds) to
compute the minimum position among the selected/target workspaces (map
overlay.contextMenuWorkspaceIds to their indices in the current workspace
ordering — e.g., via the same data source used to render rows or a helper that
maps workspace ID -> index) and set moveToTopItem.isEnabled = true only if that
minimum index > 0; for single-click context use the clicked row index as the
selection’s min index so multi-select moves as a block and the menu is disabled
when the block is already at the top.
- Line 17: The enum case copySshError(error: String) is forwarding raw SSH error
text into the menu action payload — stop passing raw upstream messages; change
the action to carry either no user-facing string or an opaque identifier (e.g.,
copySshError(token: String) or copySshError) and resolve/sanitize/localize the
clipboard text in the action handler instead (use a new sanitizer like
sanitizeSSHMessage(_:) or lookup by token and produce a redacted/localized
string), and update the clipboard helper call sites to accept the
sanitized/localized text rather than the original raw error.
🪄 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: c450fa41-5cd4-47fc-815a-04d80a64953a

📥 Commits

Reviewing files that changed from the base of the PR and between ce4fc47 and f14eb47.

📒 Files selected for processing (4)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Sidebar/WorkspaceContextMenuOverlay.swift
  • cmux.xcodeproj/project.pbxproj

Comment thread Resources/Localizable.xcstrings Outdated
Comment thread Sources/ContentView.swift
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from f14eb47 to e3f586e Compare May 23, 2026 08:39

@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

♻️ Duplicate comments (2)
Sources/Sidebar/WorkspaceContextMenuOverlay.swift (2)

257-260: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

“Move to Top” enablement still allows a multi-select no-op.

This remains enabled based on the clicked row index, not the selected block’s minimum index, so top-anchored multi-selection can still show an enabled no-op action.

🤖 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/Sidebar/WorkspaceContextMenuOverlay.swift` around lines 257 - 260,
The "Move to Top" menu item is being enabled using the clicked row index
(overlay.index) which allows a no-op for multi-selection; update the enablement
logic for moveToTopItem to compute the minimum index among the selected block's
workspace IDs (overlay.contextMenuWorkspaceIds -> map to their indexes) and set
moveToTopItem.isEnabled = (minSelectedIndex > 0) &&
!overlay.contextMenuWorkspaceIds.isEmpty so the action is disabled when the
topmost selected item is already at index 0; adjust the code around
moveToTopItem, overlay.index, overlay.contextMenuWorkspaceIds, and
WorkspaceContextMenuAction.moveToTop accordingly.

17-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop forwarding raw SSH error text in the action payload.

copySshError(error: String) still pushes raw upstream text into the menu contract and downstream clipboard path. Keep the action opaque (no raw message) and resolve a sanitized/localized string in the handler layer instead.

♻️ Proposed fix
 enum WorkspaceContextMenuAction {
@@
-    case copySshError(error: String)
+    case copySshError
@@
-            if let sshError = overlay.copyableSidebarSSHError {
+            if overlay.copyableSidebarSSHError != nil {
                 let copySshItem = NSMenuItem(title: String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error"), action: `#selector`(handleMenuItemAction(_:)), keyEquivalent: "")
                 copySshItem.target = self
-                copySshItem.representedObject = WorkspaceContextMenuAction.copySshError(error: sshError)
+                copySshItem.representedObject = WorkspaceContextMenuAction.copySshError
                 menu.addItem(copySshItem)
             }

As per coding guidelines: “user-facing errors, alerts, command output, API error bodies, or recovery copy must not expose … raw upstream messages …”.

Also applies to: 233-236

🤖 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/Sidebar/WorkspaceContextMenuOverlay.swift` at line 17, The action
case copySshError(error: String) currently forwards raw upstream SSH text;
change the action to be opaque (e.g., copySshError with no associated String)
and remove any code that places the raw error into the menu contract or
clipboard payload; update all call sites that dispatch copySshError to dispatch
the new no-payload variant; in the action handler/clipboard writer (the
reducer/handler that previously received the String) resolve a
sanitized/localized user-facing message there (sanitize/lookup a localized
message, not the raw error) and write that sanitized text to the clipboard;
repeat the same change for the other occurrences mentioned (the other
copySshError usages around the referenced block).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 10801-10808: Precompute the Finder URL(s) from
workspaceFinderDirectoryCache.url(for:) alongside the other context-menu
snapshots (e.g., add a precomputedFinderDirectoryURL or a mapping keyed by
workspace id where you already compute precomputedTabCount,
precomputedHasLatestNotifications, etc.), pass that snapshot into TabItemView as
an immutable value, and include it in TabItemView’s equality comparison (the
TabItemView == implementation) so Show in Finder enablement won’t go stale;
update TabItemView.body to use the injected precomputedFinderDirectoryURL
instead of reading workspaceFinderDirectoryCache.url(for:) directly. Ensure the
same change is applied at the other indicated spots (around the other ranges
referencing workspaceFinderDirectoryCache.url(for:)).

---

Duplicate comments:
In `@Sources/Sidebar/WorkspaceContextMenuOverlay.swift`:
- Around line 257-260: The "Move to Top" menu item is being enabled using the
clicked row index (overlay.index) which allows a no-op for multi-selection;
update the enablement logic for moveToTopItem to compute the minimum index among
the selected block's workspace IDs (overlay.contextMenuWorkspaceIds -> map to
their indexes) and set moveToTopItem.isEnabled = (minSelectedIndex > 0) &&
!overlay.contextMenuWorkspaceIds.isEmpty so the action is disabled when the
topmost selected item is already at index 0; adjust the code around
moveToTopItem, overlay.index, overlay.contextMenuWorkspaceIds, and
WorkspaceContextMenuAction.moveToTop accordingly.
- Line 17: The action case copySshError(error: String) currently forwards raw
upstream SSH text; change the action to be opaque (e.g., copySshError with no
associated String) and remove any code that places the raw error into the menu
contract or clipboard payload; update all call sites that dispatch copySshError
to dispatch the new no-payload variant; in the action handler/clipboard writer
(the reducer/handler that previously received the String) resolve a
sanitized/localized user-facing message there (sanitize/lookup a localized
message, not the raw error) and write that sanitized text to the clipboard;
repeat the same change for the other occurrences mentioned (the other
copySshError usages around the referenced block).
🪄 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: d2447680-1d2b-48fd-bf2f-fe8c90359895

📥 Commits

Reviewing files that changed from the base of the PR and between f14eb47 and e3f586e.

📒 Files selected for processing (4)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Sidebar/WorkspaceContextMenuOverlay.swift
  • cmux.xcodeproj/project.pbxproj

Comment thread Sources/ContentView.swift Outdated
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from e3f586e to 0cb0e64 Compare May 23, 2026 14:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 13324-13331: The TabItemView Equatable implementation is missing
the new `@Binding` frozenPresentation, causing SwiftUI to reuse stale rows; update
the equality checks (the == function(s) or Equatable conformance for
TabItemView) to include a comparison of lhs.frozenPresentation ==
rhs.frozenPresentation (and likewise add frozenPresentation to any other
TabItemView equality comparison site referenced in the diff), so the row
equality reflects changes to the frozenPresentation binding and SwiftUI will
rebuild the view when it changes.

In `@Sources/Sidebar/WorkspaceContextMenuOverlay.swift`:
- Around line 36-37: WorkspaceContextMenuOverlay currently captures an
ObservableObject Tab (alias Workspace) which breaks the "rows must be
value-only" rule; change the overlay to store an immutable Bool snapshot instead
(replace stored property let tab: Tab with let isPinned: Bool) and update any
logic inside WorkspaceContextMenuOverlay that reads overlay.tab.isPinned to use
isPinned; also update the call site(s) that construct
WorkspaceContextMenuOverlay to pass isPinned: tab.isPinned (not the Tab object)
and remove any remaining references to Tab within the overlay.
🪄 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: 38386449-a4ca-458b-ac1f-c6f6a56c1287

📥 Commits

Reviewing files that changed from the base of the PR and between e3f586e and 0cb0e64.

📒 Files selected for processing (4)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Sidebar/WorkspaceContextMenuOverlay.swift
  • cmux.xcodeproj/project.pbxproj

Comment thread Sources/ContentView.swift Outdated
Comment thread Sources/Sidebar/WorkspaceContextMenuOverlay.swift Outdated
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from 0cb0e64 to 0e5810c Compare May 23, 2026 14:57

Copy link
Copy Markdown
Author

@lawrencecchen Could you take a look when you have a chance?

This fixes the sidebar second-level context menu flicker by moving the menu out of SwiftUI .contextMenu and into an AppKit NSMenu overlay. I also addressed the latest review feedback around multi-select “Move to Top” and copySshError.

Local verification:

  • xcodebuild test -project cmux.xcodeproj -scheme cmux-unit -only-testing:cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests -destination 'platform=macOS'
  • ./scripts/reload.sh --tag nsmenu-sidebar-context-menu

The only remaining red checks appear to be Vercel team authorization.

Copy link
Copy Markdown
Author

@lawrencecchen gentle follow-up here. I want to flag that #2003 is still a user-visible bug: the second-level sidebar context menus, especially Workspace Color, can dismiss before the cursor reaches an item while terminal output is active.

This PR moves the menu out of SwiftUI .contextMenu because smaller invalidation fixes still left a re-render floor. I’m happy to split or adjust the implementation if the AppKit overlay approach needs a narrower review path.

Current status from my side: CodeRabbit/Greptile are green on the latest head, local verification passed, and the remaining red checks appear to be Vercel team authorization.

…w-ai#2003)

Move the workspace context menu off the SwiftUI render tree to prevent submenu flickering and disappearing when the sidebar body is invalidated at a high frequency.

Align with latest main: added workspace grouping and copy link support.
@nanami-he
nanami-he force-pushed the feature/nsmenu-sidebar-context-menu branch from b6db8a3 to 863fad9 Compare June 3, 2026 08:19
@nanami-he

Copy link
Copy Markdown
Author

Force-pushed an updated version of this PR on top of the latest main.

This is still the fix for the workspace color submenu dismissal bug tracked in #2003 and #4646 (regression of #2560), but now aligned with current main (including newer sidebar changes like workspace grouping and copy link support).

Verified locally: the workspace color submenu no longer flickers or dismisses before the cursor reaches a swatch while terminal output is active.

@nanami-he

Copy link
Copy Markdown
Author

@austinywang gentle follow-up here. This PR has been force-updated on top of the latest main and verified locally against the workspace color submenu dismissal bug tracked in #2003 and #4646.

From the current merge box, it looks blocked by maintainer-side items rather than new code changes:

  • stale Changes requested review state
  • required workflows awaiting maintainer approval
  • Vercel team authorization

If there are still concerns with the updated 863fad9 version, I’m happy to adjust or split the implementation. Otherwise, could a maintainer take a look and approve the pending workflows when possible?

@teamleaderleo teamleaderleo added bug Something isn't working S2: major A crash, hang, lost state, broken connection, or a regression on a path people use area: sidebar The workspace sidebar: list, groups, status, reordering labels Sep 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: sidebar The workspace sidebar: list, groups, status, reordering bug Something isn't working S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session color change flickers and loses toolbar when Claude Code sessions are active

2 participants