Skip to content

Fix dock icon not auto-switching with system dark mode - #1928

Merged
lawrencecchen merged 2 commits into
manaflow-ai:mainfrom
che-3:fix/dark-mode-dock-icon-auto-switch
Mar 25, 2026
Merged

lawrencecchen merged 2 commits into
manaflow-ai:mainfrom
che-3:fix/dark-mode-dock-icon-auto-switch

Conversation

@che-3

@che-3 che-3 commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • The automatic dock icon mode sets applicationIconImage = nil, expecting the asset catalog to handle dark/light switching — but the compiled Assets.car doesn't include dark variant renditions for AppIcon, so the icon stays light regardless of system appearance
  • Replaced the nil-reset approach with a KVO observer on NSApp.effectiveAppearance that programmatically swaps between AppIconDark and AppIconLight images when in automatic mode
  • Observer is started/stopped cleanly when switching between icon modes

Root Cause

The AppIcon.appiconset/Contents.json correctly defines dark variants with luminosity: dark appearance, but the compiled Assets.car strips these — all AppIcon entries have Appearance: none. The AppIconDark and AppIconLight imagesets exist separately but aren't linked as appearance variants of the main AppIcon.

Test plan

  • Set icon mode to "Automatic" in cmux settings
  • Switch macOS appearance to dark mode → dock icon should change to dark variant
  • Switch macOS appearance to light mode → dock icon should change to light variant
  • Set icon mode to "Light" or "Dark" → icon should stay fixed regardless of system appearance
  • Restart app in automatic mode → icon should match current system appearance on launch

🤖 Generated with Claude Code


Summary by cubic

Fixes the dock icon not switching with macOS dark mode by observing system appearance and swapping between AppIconDark and AppIconLight in Automatic mode. The observer starts/stops with icon mode changes, avoids stale async updates, and applies the correct icon on launch while keeping manual Light/Dark fixed.

  • Bug Fixes
    • Use KVO on NSApp.effectiveAppearance to swap AppIconDark/AppIconLight in Automatic mode.
    • Apply the right icon at startup and on appearance changes; start/stop the observer when switching modes so Light/Dark stay fixed.
    • Hardened observer: ignore stale async callbacks after stopObserving(), enforce a singleton with a private init, and remove the unused KVO option.

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

Summary by CodeRabbit

  • Bug Fixes
    • Automatic app icon now responds immediately to system light/dark appearance changes.
    • Icon updates occur in real time when switching themes without needing manual action or app restart.
    • Improved reliability in selecting the appropriate light or dark icon for the current appearance.

The automatic icon mode relied on the asset catalog to handle
appearance-based icon selection, but the compiled Assets.car does not
include dark variant renditions for AppIcon. This meant setting
applicationIconImage to nil had no effect — the icon stayed on the
light variant regardless of system appearance.

Replace the nil-reset approach with an active KVO observer on
NSApp.effectiveAppearance that programmatically swaps between
AppIconDark and AppIconLight images when in automatic mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Mar 22, 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 Mar 22, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43ade0f6-7534-4038-b4a8-eb068931a31a

📥 Commits

Reviewing files that changed from the base of the PR and between 297e501 and fb5b763.

📒 Files selected for processing (1)
  • Sources/cmuxApp.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/cmuxApp.swift

📝 Walkthrough

Walkthrough

The PR adds a singleton AppIconAppearanceObserver and updates AppIconSettings.applyIcon(_:) so .automatic starts the observer (which watches NSApp.effectiveAppearance and applies AppIconDark/AppIconLight), while switching to/from fixed .light/.dark starts or stops that observation.

Changes

Cohort / File(s) Summary
App Icon Appearance Observer
Sources/cmuxApp.swift
Added final class AppIconAppearanceObserver: NSObject (singleton) with startObserving()/stopObserving() using KVO on NSApp.effectiveAppearance; updated AppIconSettings.applyIcon(_:) to start/stop the observer for .automatic vs .light/.dark and to set the icon immediately based on effective appearance.

Sequence Diagram(s)

sequenceDiagram
    participant Settings as AppIconSettings
    participant Observer as AppIconAppearanceObserver
    participant App as NSApp (NSApplication)
    participant Icons as AppIconAssets

    Settings->>Observer: startObserving() when mode == .automatic
    Observer->>App: read effectiveAppearance
    Observer->>Icons: choose AppIconLight or AppIconDark
    Icons-->>Observer: selected icon image
    Observer->>Settings: apply icon on main queue

    Note right of App: On appearance change (KVO)
    App->>Observer: effectiveAppearance changed
    Observer->>Icons: reselect appropriate icon
    Icons-->>Observer: new icon image
    Observer->>Settings: reapply icon on main queue
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Poem

🐰 I watch the sky and switch my hue,
Dark or light, I know what to do.
A tiny rabbit, keen and spry,
I flip the icon with a sigh.
Hooray — the app wears matching dye! 🐇✨

🚥 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 clearly and specifically describes the main change: fixing the dock icon auto-switching behavior in dark mode, which is the core problem being addressed.
Description check ✅ Passed The description includes a comprehensive Summary section explaining the problem, root cause, and solution. However, the Testing section lacks filled checkboxes for manual verification, and no demo video is provided.

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

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

Tip

Migrating from UI to YAML configuration.

Use the @coderabbitai configuration command in a PR comment to get a dump of all your UI settings in YAML format. You can then edit this YAML file and upload it to the root of your repository to configure CodeRabbit programmatically.

@greptile-apps

greptile-apps Bot commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes dock icon appearance not following the system dark/light mode by replacing the previous applicationIconImage = nil approach (which relied on the asset catalog to handle appearance variants, but the compiled Assets.car doesn't include them) with a KVO observer on NSApp.effectiveAppearance that explicitly swaps between the AppIconDark and AppIconLight imagesets.

Key changes in Sources/cmuxApp.swift:

  • AppIconSettings.applyIcon(.automatic) now delegates to a new AppIconAppearanceObserver.shared.startObserving() instead of resetting the icon to nil
  • .light and .dark modes call stopObserving() before setting the explicit image, preventing the observer from overriding explicit selections
  • AppIconAppearanceObserver (NSObject subclass) uses Swift KVO (NSApp.observe(\.effectiveAppearance, ...)) and dispatches icon updates to the main queue

Minor issues found:

  • The singleton is missing a private init(), allowing accidental external instantiation alongside shared
  • options: [.new] allocates and populates an NSKeyValueObservedChange on every appearance change, but the closure ignores the change value; options: [] is sufficient
  • A stale DispatchQueue.main.async block enqueued by the KVO callback can run after stopObserving() returns, potentially overwriting the explicitly-set .light/.dark icon in an unlikely but valid race; guarding with observation != nil inside applyIconForCurrentAppearance() would prevent it

Confidence Score: 4/5

  • PR is safe to merge; the KVO approach is correct and the identified issues are low-severity edge cases
  • The fix correctly addresses the root cause (nil-reset not working because Assets.car strips dark variants) with a well-structured KVO observer. The observer is started and stopped in the right places, and main-thread dispatch is used correctly. The three flagged issues are all P2: missing private init() on the singleton, the unused .new KVO option, and a theoretical but practically very unlikely async-dispatch race when stopping observation. None of these block correct behavior in normal usage.
  • Sources/cmuxApp.swift — review the async dispatch race and singleton visibility

Important Files Changed

Filename Overview
Sources/cmuxApp.swift Replaces nil-reset approach with a KVO-based AppIconAppearanceObserver singleton that programmatically swaps between AppIconDark/AppIconLight images when in automatic mode. Correct overall approach; minor issues around the missing private init(), unused .new KVO option, and a theoretical stale-async-block race when stopping observation.

Sequence Diagram

sequenceDiagram
    participant User
    participant AppDelegate
    participant AppIconSettings
    participant AppIconAppearanceObserver
    participant NSApp

    AppDelegate->>AppIconSettings: applyIcon(resolvedMode)
    AppIconSettings->>AppIconAppearanceObserver: startObserving()
    AppIconAppearanceObserver->>NSApp: effectiveAppearance.bestMatch(...)
    AppIconAppearanceObserver->>NSApp: applicationIconImage = AppIconDark/Light

    AppIconAppearanceObserver->>NSApp: observe(\.effectiveAppearance)

    Note over NSApp: System appearance changes
    NSApp-->>AppIconAppearanceObserver: KVO callback fires
    AppIconAppearanceObserver->>AppIconAppearanceObserver: DispatchQueue.main.async
    AppIconAppearanceObserver->>NSApp: applicationIconImage = AppIconDark/Light

    User->>AppIconSettings: applyIcon(.light)
    AppIconSettings->>AppIconAppearanceObserver: stopObserving()
    AppIconAppearanceObserver->>AppIconAppearanceObserver: observation.invalidate() + nil
    AppIconSettings->>NSApp: applicationIconImage = AppIconLight
Loading

Last reviewed commit: "Fix dock icon not au..."

Comment thread Sources/cmuxApp.swift Outdated
Comment on lines +3709 to +3713
observation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.applyIconForCurrentAppearance()
}
}

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 Stale async block can override explicit icon selection

When the user switches from .automatic to .light or .dark, stopObserving() invalidates the KVO token, but any DispatchQueue.main.async block that was already enqueued from a prior KVO callback will still run after the explicit icon has been set.

Concrete sequence:

  1. System appearance changes → KVO fires synchronously on main thread → DispatchQueue.main.async { applyIconForCurrentAppearance() } is enqueued
  2. Before the queued block runs, user picks "Light" → stopObserving() → applicationIconImage = lightIcon
  3. The enqueued block now runs → overwrites with the appearance-based icon, ignoring the user's explicit choice

A simple guard using a flag (or checking the current mode before applying) prevents the stale dispatch from firing:

func stopObserving() {
    observation?.invalidate()
    observation = nil
}

private func applyIconForCurrentAppearance() {
    guard observation != nil else { return }   // drop stale async calls
    let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
    let imageName = isDark ? "AppIconDark" : "AppIconLight"
    if let icon = NSImage(named: imageName) {
        NSApplication.shared.applicationIconImage = icon
    }
}

Comment thread Sources/cmuxApp.swift
Comment on lines +3702 to +3703
final class AppIconAppearanceObserver: NSObject {
static let shared = AppIconAppearanceObserver()

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 Singleton missing private init()

AppIconAppearanceObserver exposes static let shared but has no private init(), so callers outside this file can still create independent instances (e.g. AppIconAppearanceObserver()) that each set up their own KVO observation without going through the shared lifecycle. Adding a private init() makes the singleton intent explicit and prevents accidental misuse.

Suggested change
final class AppIconAppearanceObserver: NSObject {
static let shared = AppIconAppearanceObserver()
final class AppIconAppearanceObserver: NSObject {
static let shared = AppIconAppearanceObserver()
private var observation: NSKeyValueObservation?
private override init() { super.init() }

Comment thread Sources/cmuxApp.swift Outdated
func startObserving() {
applyIconForCurrentAppearance()
guard observation == nil else { return }
observation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ 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 .new option is unused overhead

options: [.new] asks KVO to allocate and populate the NSKeyValueObservedChange with the new value, but the closure's second parameter is _ — it's never read. Using options: [] eliminates the unnecessary allocation on every appearance change.

Suggested change
observation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
observation = NSApp.observe(\.effectiveAppearance, options: []) { [weak self] _, _ in

@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/cmuxApp.swift`:
- Around line 3709-3712: The NSApp appearance observer's async callback can run
after stopObserving() clears observation, so modify the closure created in
observation = NSApp.observe(\.effectiveAppearance...) to early-return if the
observer was removed: inside the DispatchQueue.main.async block, check that self
is non-nil and that self.observation != nil before calling
applyIconForCurrentAppearance(); this prevents stale appearance updates from
overriding a manually-set fixed icon and ties the update to the observer's
lifetime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43edd592-0b58-44bf-8997-988d9ed786b3

📥 Commits

Reviewing files that changed from the base of the PR and between 76c1e63 and 297e501.

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

Comment thread Sources/cmuxApp.swift Outdated

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

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:3711">
P2: Queued async appearance updates can run after leaving automatic mode and override manually selected light/dark icon.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/cmuxApp.swift Outdated
- Add guard in async callback to skip stale updates after stopObserving()
- Add private init() to prevent external instantiation of singleton
- Remove unused .new KVO option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor

Thank you for the contribution!

@lawrencecchen
lawrencecchen merged commit 4a8fd3d into manaflow-ai:main Mar 25, 2026
11 of 12 checks passed
@lawrencecchen

Copy link
Copy Markdown
Contributor

Thank you for the contribution!

bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
)

* Fix dock icon not auto-switching with system dark mode

The automatic icon mode relied on the asset catalog to handle
appearance-based icon selection, but the compiled Assets.car does not
include dark variant renditions for AppIcon. This meant setting
applicationIconImage to nil had no effect — the icon stayed on the
light variant regardless of system appearance.

Replace the nil-reset approach with an active KVO observer on
NSApp.effectiveAppearance that programmatically swaps between
AppIconDark and AppIconLight images when in automatic mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address review feedback: fix async race and harden singleton

- Add guard in async callback to skip stale updates after stopObserving()
- Add private init() to prevent external instantiation of singleton
- Remove unused .new KVO option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CHE-3 <schumannzheng@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants