Skip to content

Fix workspace color picker context menu blinking - #2566

Merged
austinywang merged 4 commits into
mainfrom
issue-2560-color-picker-blink
Apr 7, 2026
Merged

austinywang merged 4 commits into
mainfrom
issue-2560-color-picker-blink

Conversation

@austinywang

@austinywang austinywang commented Apr 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2560.

Summary

  • defer TabItemView workspace observation invalidations while its context menu is on screen so the workspace color submenu stays stable
  • flush one coalesced row redraw after the menu closes so sidebar metadata still catches up immediately after dismissal
  • update read_clipboard_cb in GhosttyTerminalView to match the current imported GhosttyKit callback signature so the branch builds cleanly in the current repo state

Root Cause

TabItemView was subscribing to debounced workspace updates and incrementing workspaceObservationGeneration even while the context menu was open. Those updates caused SwiftUI to re-evaluate the row body and rebuild .contextMenu, which dismissed and re-presented the workspace color submenu repeatedly.

Verification

  • ./scripts/reload.sh --tag color-picker-blink --launch
  • ./scripts/reload.sh --tag color-picker-blink

Notes

  • No local tests were run; repo policy says tests run in CI/VM only.
  • The GhosttyTerminalView callback adjustment is not part of the color-picker behavior change itself; it is included here because it was required to keep the tagged debug build green in the current branch state.

Summary by cubic

Fixes blinking in the workspace color picker by freezing only background row fields (unread count, latest notification text, modifier-hint state) and deferring updates while the context menu is open. Also updates the read_clipboard_cb to the latest GhosttyKit API.

  • Bug Fixes
    • Limit the freeze to background row fields and defer workspace observation invalidations during the menu; flush once on close and clear the snapshot if the tab disappears.
    • Keep hover visuals stable while the menu is open and reset on dismiss to avoid redraws.

Written for commit 0837d1a. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Tab presentation now “freezes” while a tab's context menu is open, preserving unread counts, notification text, and shortcut hints.
  • Bug Fixes

    • Improved stability of tab hover states while context menus are visible.
    • More reliable context menu appearance and dismissal with preserved tab visuals.
  • Refactor

    • Optimized tab state handling and deferred update processing to reduce visual glitches.

Note

Medium Risk
Changes SwiftUI invalidation and state handling for sidebar tab rows while a context menu is open; incorrect state coordination could leave sidebar badges/hover state stale or miss an update until the menu closes.

Overview
Prevents the workspace/tab context menu (including the color picker submenu) from blinking by freezing each tab row’s presentation (unread badge, latest notification text, shortcut hint visibility) while the menu is visible.

Adds context-menu-aware invalidation in TabItemView: workspace observation updates are deferred during menu display and a single coalesced redraw is flushed on dismissal, with hover updates suppressed while the menu is open. Also clears any frozen presentation if the underlying tab disappears.

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

@vercel

vercel Bot commented Apr 3, 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 7, 2026 8:54am

@coderabbitai

coderabbitai Bot commented Apr 3, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds per-tab frozen presentation snapshots and context-menu state to freeze UI and defer workspace-observation invalidation while a tab's context menu is open; clears stale frozen snapshots when tab identities change and prevents hover updates during menu visibility.

Changes

Cohort / File(s) Summary
Presentation freezing & context-menu state
Sources/ContentView.swift
Added SidebarTabItemPresentationSnapshot and a @State frozenTabItemPresentation at sidebar level; compute livePresentation per tab and pass frozen vs live values into TabItemView; clear frozen snapshot when tab ids change.
TabItemView telemetry & hover handling
Sources/ContentView.swift
Added SidebarTabItemContextMenuState (@StateObject) and refactored workspace telemetry invalidation to use scheduleWorkspaceObservationInvalidation() with deferral flag, flushDeferredWorkspaceObservationInvalidation() on menu close; freeze/unfreeze presentation on .contextMenu lifecycle and suppress .onHover updates while menu is visible.

Sequence Diagram(s)

sequenceDiagram
  participant User as User
  participant Sidebar as VerticalTabsSidebar
  participant TabView as TabItemView
  participant Telemetry as WorkspaceTelemetry

  rect rgba(200,200,255,0.5)
  User->>Sidebar: right-click tab
  Sidebar->>TabView: show context menu (set visible)
  end

  rect rgba(200,255,200,0.5)
  TabView->>TabView: frozenPresentation = livePresentation
  TabView->>Telemetry: defer invalidation (hasDeferred = true)
  TabView-->>Sidebar: suppress hover updates while visible
  end

  rect rgba(255,200,200,0.5)
  User->>TabView: close context menu
  TabView->>Telemetry: flush deferred invalidation (increment once)
  TabView->>TabView: clear frozenPresentation, restore hover behavior
  Sidebar->>TabView: resume live updates
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I froze a tab's face when the menu took stage,
Held back the telemetry, silent for a page.
No flicker, no flutter, the picker can stay—
I hop, I guard, then flush it away! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

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.
Out of Scope Changes check ❓ Inconclusive The GhosttyTerminalView callback signature update is acknowledged as necessary for the branch to build but unrelated to the color-picker fix; consider this a minor scope concern. Clarify whether the GhosttyTerminalView change should be in a separate PR or if it is a required dependency for the current branch state.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing the workspace color picker context menu from blinking, which is the primary objective.
Linked Issues check ✅ Passed The changes directly address issue #2560 by deferring workspace observation invalidations and freezing presentation state while the context menu is open, preventing the color picker from blinking.
Description check ✅ Passed The PR description comprehensively documents the changes, root cause, and verification steps, aligning with the template's core requirements.

✏️ 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 issue-2560-color-picker-blink

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 Apr 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the workspace color submenu blinking (issue #2560) by deferring TabItemView body re-evaluations — triggered by background workspace telemetry — while the context menu is on screen, then flushing one coalesced redraw after the menu closes. It also updates the read_clipboard_cb closure signature in GhosttyTerminalView to match the current GhosttyKit void-return API so the branch builds cleanly.

  • ContentView.swift: Two new @State variables (contextMenuVisible, hasDeferredWorkspaceObservationInvalidation) and two private helpers replace the direct workspaceObservationGeneration &+= 1 increments in both onReceive callbacks. .onAppear/.onDisappear modifiers on the context menu content set/clear contextMenuVisible and drive the deferred flush.
  • GhosttyTerminalView.swift: read_clipboard_cb guard early-exit changed from return false to return, and the trailing return true removed, matching the updated ghostty_runtime_config_s callback signature that now returns Void instead of Bool.
  • The fix is contained to TabItemView state only — it does not touch the Equatable conformance or the .equatable() call site, which is correct since @State properties are excluded from the == comparison.
  • One notable risk: if SwiftUI skips the .onDisappear callback on context menu content (e.g., abnormal dismissal path), contextMenuVisible will remain true permanently and all subsequent sidebar row updates for that view instance will be silently swallowed.

Confidence Score: 3/5

Safe to merge for the happy path; carries a low-probability stuck-state risk if onDisappear is skipped on abnormal context menu dismissal.

The core fix is logically sound and addresses the root cause clearly. The GhosttyTerminalView change is a trivial API alignment. The main uncertainty is the reliability of onAppear/onDisappear inside a .contextMenu { } block on macOS across all dismissal paths — if onDisappear is ever skipped, contextMenuVisible stays true and the sidebar row stops updating entirely for the lifetime of that view instance.

Sources/ContentView.swift — specifically the onAppear/onDisappear lifecycle contract inside .contextMenu { } and the stuck-state scenario if onDisappear doesn't fire.

Important Files Changed

Filename Overview
Sources/ContentView.swift Defers workspace observation invalidations in TabItemView while the context menu is open using contextMenuVisible/hasDeferredWorkspaceObservationInvalidation state; relies on onAppear/onDisappear firing correctly inside .contextMenu { } on macOS, with edge-case risk of stuck state if onDisappear is skipped.
Sources/GhosttyTerminalView.swift Aligns read_clipboard_cb closure signature with updated GhosttyKit API by removing the Bool return value; straightforward API conformance change with no logic impact.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["onReceive fires\n(immediate or debounced publisher)"] --> B["scheduleWorkspaceObservationInvalidation()"]
    B --> C{contextMenuVisible?}
    C -- Yes --> D["hasDeferredWorkspaceObservationInvalidation = true\n(return, skip increment)"]
    C -- No --> E["workspaceObservationGeneration &+= 1\n→ SwiftUI re-evaluates body"]
    F["User right-clicks row"] --> G[".onAppear fires"]
    G --> H["contextMenuVisible = true"]
    I["User dismisses menu"] --> J[".onDisappear fires"]
    J --> K["contextMenuVisible = false"]
    K --> L["flushDeferredWorkspaceObservationInvalidation()"]
    L --> M{hasDeferredWorkspaceObservationInvalidation?}
    M -- Yes --> N["workspaceObservationGeneration &+= 1\n→ one coalesced body re-eval"]
    M -- No --> O["no-op"]
    P["⚠️ Abnormal dismissal\nonDisappear skipped"] --> Q["contextMenuVisible stuck = true\nSidebar row never refreshes"]
Loading

Reviews (1): Last reviewed commit: "Update clipboard callback for current Gh..." | Re-trigger Greptile

Comment thread Sources/ContentView.swift
Comment on lines +13089 to +13098
.contextMenu {
workspaceContextMenu
.onAppear {
contextMenuVisible = true
}
.onDisappear {
contextMenuVisible = false
flushDeferredWorkspaceObservationInvalidation()
}
}

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.

P1 Potential stuck contextMenuVisible state

contextMenuVisible is set to true in .onAppear and only reset to false in .onDisappear. On macOS, SwiftUI context menus are backed by NSMenu, and .onDisappear on context menu content views may not fire in certain edge cases — for example, when the window is closed, the app is force-quit, or the menu is dismissed via an unusual path. If .onDisappear doesn't fire, contextMenuVisible remains true indefinitely.

In that stuck state, every subsequent call to scheduleWorkspaceObservationInvalidation() defers the update and sets hasDeferredWorkspaceObservationInvalidation = true, but flushDeferredWorkspaceObservationInvalidation() is never called (since that only happens in .onDisappear). The sidebar row would never refresh again for the lifetime of the TabItemView instance.

Consider adding a safety reset — for example, observing a SwiftUI scenePhase change — or defending against an abnormally long contextMenuVisible = true window with a timeout-based flush.

Comment thread Sources/ContentView.swift
Comment on lines +13089 to +13098
.contextMenu {
workspaceContextMenu
.onAppear {
contextMenuVisible = true
}
.onDisappear {
contextMenuVisible = false
flushDeferredWorkspaceObservationInvalidation()
}
}

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 onAppear/onDisappear on multi-item context menu content

workspaceContextMenu is a @ViewBuilder that returns a heterogeneous TupleView (multiple Buttons, Menu, Divider, etc.). Attaching .onAppear/.onDisappear to a composite view inside .contextMenu { } on macOS relies on SwiftUI treating the overall content block as a single lifecycle-trackable node.

In practice this works on recent macOS releases, but it may be worth wrapping the content in an explicit Group { } to clarify intent and give SwiftUI a definitive single root:

.contextMenu {
    Group {
        workspaceContextMenu
    }
    .onAppear {
        contextMenuVisible = true
    }
    .onDisappear {
        contextMenuVisible = false
        flushDeferredWorkspaceObservationInvalidation()
    }
}

This makes the attachment point unambiguous across SwiftUI versions.

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

@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 2 files

@cubic-dev-ai

cubic-dev-ai Bot commented Apr 7, 2026

Copy link
Copy Markdown

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 10149-10153: The current onChange only clears
frozenTabItemPresentation when its tabId disappears; extend this to also clear
the frozen presentation (and any frozen snapshot) when any frozen target
workspace ID no longer exists: inside the .onChange(of: tabs.map(\.id)) handler,
compute the current set of workspace IDs (including contextMenuWorkspaceIds and
remoteContextMenuWorkspaceIds), then check frozenTabItemPresentation's target
IDs (or the property that holds the frozen target workspace IDs) and set
self.frozenTabItemPresentation = nil if any of those target IDs are missing from
the current set; ensure you reference frozenTabItemPresentation,
contextMenuWorkspaceIds and remoteContextMenuWorkspaceIds when making this check
so stale plural actions are cleared.
🪄 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: 61ba40d5-611e-41cc-997c-76cb4fc6e855

📥 Commits

Reviewing files that changed from the base of the PR and between edd733f and 3175f5e.

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

Comment thread Sources/ContentView.swift
Comment on lines +10149 to +10153
.onChange(of: tabs.map(\.id)) { tabIds in
guard let frozenTabItemPresentation,
!tabIds.contains(frozenTabItemPresentation.tabId) else { return }
self.frozenTabItemPresentation = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Also clear the frozen snapshot when any frozen target disappears.

Right now this only checks the anchor tabId. If the menu was opened on a multi-selection and one of contextMenuWorkspaceIds / remoteContextMenuWorkspaceIds is removed while the menu is open, the frozen menu can keep stale target IDs and stale plural actions until dismiss.

Suggested fix
-        .onChange(of: tabs.map(\.id)) { tabIds in
-            guard let frozenTabItemPresentation,
-                  !tabIds.contains(frozenTabItemPresentation.tabId) else { return }
-            self.frozenTabItemPresentation = nil
+        .onChange(of: tabs.map(\.id)) { tabIds in
+            guard let frozenTabItemPresentation else { return }
+            let existingIds = Set(tabIds)
+            let frozenTargetIds = Set(
+                [frozenTabItemPresentation.tabId]
+                + frozenTabItemPresentation.contextMenuWorkspaceIds
+                + frozenTabItemPresentation.remoteContextMenuWorkspaceIds
+            )
+            guard frozenTargetIds.isSubset(of: existingIds) else {
+                self.frozenTabItemPresentation = nil
+                return
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10149 - 10153, The current onChange
only clears frozenTabItemPresentation when its tabId disappears; extend this to
also clear the frozen presentation (and any frozen snapshot) when any frozen
target workspace ID no longer exists: inside the .onChange(of: tabs.map(\.id))
handler, compute the current set of workspace IDs (including
contextMenuWorkspaceIds and remoteContextMenuWorkspaceIds), then check
frozenTabItemPresentation's target IDs (or the property that holds the frozen
target workspace IDs) and set self.frozenTabItemPresentation = nil if any of
those target IDs are missing from the current set; ensure you reference
frozenTabItemPresentation, contextMenuWorkspaceIds and
remoteContextMenuWorkspaceIds when making this check so stale plural actions are
cleared.

@austinywang
austinywang merged commit 9d4220b into main Apr 7, 2026
17 of 19 checks passed
lawrencecchen added a commit that referenced this pull request Apr 20, 2026
- 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).
@coderabbitai coderabbitai Bot mentioned this pull request May 5, 2026
4 tasks done

This branch was successfully deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace color picker menu blinks in and out, impossible to select a color

1 participant