Skip to content

fix: apply stored appearance when observer starts so launch picks it up - #4528

Open
ma-pony wants to merge 2 commits into
manaflow-ai:mainfrom
ma-pony:fix/appearance-initial-apply-on-startup
Open

ma-pony wants to merge 2 commits into
manaflow-ai:mainfrom
ma-pony:fix/appearance-initial-apply-on-startup

Conversation

@ma-pony

@ma-pony ma-pony commented May 22, 2026 •

Copy link
Copy Markdown

Fixes #4527.

Summary

  • What changed? AppearanceSettingsUserDefaultsObserver.startObserving() now performs a one-shot apply of the persisted appearanceMode after registering the defaults observer. Subsequent calls to startObserving() only refresh the baseline (no re-apply), preserving idempotence if the observer is ever re-attached.
  • Why? PR Fix settings appearance dispatch_once reentrancy #4415 (70bcbda2) delegated live appearance to this observer but the observer only fires on UserDefaults changes. In the steady-state case — where appearanceMode is already "dark" in UserDefaults at observe-start time — no change event ever fires, so NSApplication.shared.appearance is never updated by the observer. The earlier Self.applyAppearance(_, duringLaunch: true) in cmuxApp.init() runs before NSApp is fully initialized and can fail to propagate to the first NSWindow materialized by the WindowGroup, leaving the main window and any NSVisualEffectView-backed sidebar stuck in the system default appearance. The fix runs from applicationDidFinishLaunching, well after FileStore initialization, so it does not re-enter the Ghostty reload guard Fix settings appearance dispatch_once reentrancy #4415 was fixing.

Testing

  • Added regression test testStartObservingAppliesStoredAppearanceImmediately in cmuxTests/AppearanceSettingsTests.swift that pre-seeds appearanceMode = dark, calls startObserving() against an isolated UserDefaults suite, and asserts the .darkAqua appearance was applied via the injected LiveApplyEnvironment.
  • Two-commit structure per CLAUDE.md regression-test policy: the first commit adds the failing test only, the second commit adds the fix. CI will show test red → green across the two commits.
  • I did not run tests locally per CLAUDE.md ("Never run tests locally"). Relying on CI.
  • Manually verified the bug repros on 0.64.8 against the user's own machine (cmux.json app.appearance: "dark", defaults appearanceMode = dark, Settings UI shows Dark, main window renders Light); the manual workaround in the issue (Settings Light → Dark toggle) matches the code path that this fix executes once at launch.

Demo Video

Bug is "Light instead of Dark on first launch after upgrade with app.appearance: dark set"; visible diff is identical to the issue's screenshots/state. Happy to record one if the maintainers want.

  • Video URL or attachment: on request

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally (verified bug repro on 0.64.8; fix is targeted and minimal)
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed (this is a bug fix; CHANGELOG entry can be added if maintainers want)
  • I requested bot reviews after my latest commit (will paste the trigger block after merge of any review feedback)
  • All code review bot comments are resolved
  • All human review comments are resolved

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


Summary by cubic

Apply the stored app appearance as soon as the defaults observer starts so the first window uses the correct theme on launch. Fixes cases where Dark is set but the app opens in Light until the user toggles settings.

  • Bug Fixes
    • Perform a one-shot apply of persisted appearanceMode in AppearanceSettingsUserDefaultsObserver.startObserving() after registering the observer.
    • Keep idempotence: subsequent startObserving() calls only refresh the baseline (no re-apply).
    • Add regression test testStartObservingAppliesStoredAppearanceImmediately to confirm Dark/Light is applied at launch.

Written for commit 88c4402. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Fixed timing issue where appearance settings were not immediately applied when starting the app, ensuring proper theme synchronization on launch.
  • Tests

    • Added regression test to verify appearance mode is correctly applied immediately upon observation start.

Review Change Stack

ma-pony added 2 commits May 22, 2026 10:08
After commit 70bcbda (PR manaflow-ai#4415) decoupled live appearance from the
settings-file store side-effects, `AppearanceSettingsUserDefaultsObserver`
became the single owner of live appearance. But `startObserving()` only
primes `lastObservedRawValue` and waits for change events — when the
launch begins with the stored mode already matching what's persisted
in UserDefaults (the steady state after the first import of
`app.appearance` from `cmux.json`), no change event ever fires and the
first NSWindow materializes with the wrong appearance.

This test arranges that exact scenario: pre-seed `appearanceMode = dark`
in UserDefaults, call `startObserving()`, assert that the dark
appearance was applied to the application. It fails on current HEAD
(no apply happens) and will pass once `startObserving()` performs a
one-shot apply of the persisted value.

Two-commit structure per CLAUDE.md regression-test policy: this commit
adds the failing test; the next commit applies the fix.
The observer added in manaflow-ai#4415 only fires on UserDefaults *changes*. After
the first launch that imports `app.appearance` from `cmux.json`, the
persisted value and the imported value are equal, so no change event
fires at startObserving time and `NSApplication.shared.appearance` is
never updated by the observer. The early
`Self.applyAppearance(_, duringLaunch: true)` in `cmuxApp.init()` runs
before `NSApp` is fully initialized so the value can fail to propagate
to the first NSWindow created by the SwiftUI `WindowGroup`, leaving
the main window (and any `NSVisualEffectView`-backed sidebar) stuck in
the system default appearance.

Apply the stored mode once during `startObserving()`, after the
defaults observer is registered. This runs from
`applicationDidFinishLaunching` — well after FileStore initialization,
so it does not re-enter the Ghostty reload guard that PR manaflow-ai#4415 was
fixing. Subsequent calls to `startObserving()` skip the re-apply (only
the baseline is refreshed) to avoid spurious work if the observer is
ever re-attached.

Fixes the regression where:
- `cmux.json` contains `"app": { "appearance": "dark" }`
- `defaults read com.cmuxterm.app appearanceMode` is `dark`
- Settings UI → Appearance → App Appearance shows Dark
- But main NSWindow and sidebar render Light until the user manually
  toggles App Appearance in Settings to force a change event.

Regression test added in the previous commit.
@vercel

vercel Bot commented May 22, 2026

Copy link
Copy Markdown

@ma-pony is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented May 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR fixes a regression where the main window renders Light on launch despite persisted appearance being Dark. The observer now applies the stored mode immediately when registered from applicationDidFinishLaunching, instead of waiting for change events. A regression test validates this launch-time behavior.

Changes

Appearance Mode Launch-Time Application

Layer / File(s) Summary
Immediate appearance mode application on observer start
Sources/AppearanceSettings.swift
startObserving now applies the stored appearance immediately via environment.applyStoredMode when starting fresh, and returns early if an observer is already registered (updating only lastObservedRawValue to prevent re-registration). Previously only captured lastObservedRawValue and relied on change callbacks.
Regression test for launch-time appearance application
cmuxTests/AppearanceSettingsTests.swift
New test testStartObservingAppliesStoredAppearanceImmediately validates that startObserving() applies the persisted appearance mode immediately—sets dark mode in isolated UserDefaults, starts observing, and asserts the application appearance and terminal theme sync occur immediately without requiring a change event.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related issues

  • #4527: Main window and sidebar render Light on launch despite app.appearance: dark — this PR implements the proposed fix to make AppearanceSettingsUserDefaultsObserver.startObserving() perform a one-shot apply of the persisted mode after registering the defaults observer, addressing the launch-time rendering issue.

Possibly related PRs

  • manaflow-ai/cmux#4415: Changed how persisted AppearanceSettings.appearanceModeKey is applied at startup by removing the side-effect from CmuxSettingsFileStore and deferring responsibility to the app lifecycle observer; this PR completes that responsibility by ensuring the observer actually applies the stored mode immediately.

Poem

🐰 A launch-time appearance now glows bright and dark,
No more Light windows floating in the virtual park,
Immediately applied, no callbacks to wait,
The observer speaks fast—and the theme sets straight!


Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces NSLock in startObserving() → applyStoredMode() → LiveApplyEnvironment.live code path, called during app launch on main thread. Replace NSLock with Swift actors or defer appearance apply until after NSApp is fully initialized if lock is needed for thread safety.
Cmux Architecture Rethink ❌ Error Fix relies on lifecycle timing (applicationDidFinishLaunching) repair path. Unresolved race: updating lastObservedRawValue when already observing suppresses pending UserDefaults changes. Remove lastObservedRawValue update in already-observing branch per review to prevent suppressing pending change notifications.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: applying stored appearance on observer start to ensure launch picks it up, directly addressing the regression.
Description check ✅ Passed The description covers all template sections including summary, testing details, and checklist items. It clearly explains what changed, why, how testing was done, and acknowledges not updating docs/changelog for a bug fix.
Linked Issues check ✅ Passed The PR fully addresses issue #4527 by implementing the proposed fix: startObserving() now applies stored appearance one-shot after registering the observer, runs safely from applicationDidFinishLaunching, and includes a regression test validating the fix.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the appearance initialization regression: modifications to AppearanceSettingsUserDefaultsObserver.startObserving() and addition of a regression test. No unrelated changes detected.
Cmux Swift Actor Isolation ✅ Passed PR adds appearance apply call to startObserving() without introducing actor isolation issues. Class remains MainActor-accessed singleton with no new multi-threaded access patterns.
Cmux No Hacky Sleeps ✅ Passed PR modifies only Swift files; rule explicitly covers TypeScript, JavaScript, shell, and non-Swift build/runtime scripts, excluding Swift code.
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns. NotificationCenter callback infrastructure is pre-existing. New code is synchronous, operating within required AppKit boundaries.
Cmux Swift @Concurrent ✅ Passed No async or @concurrent annotations present; all changes are synchronous refactoring of observer registration and immediate application of stored appearance mode from applicationDidFinishLaunching.
Cmux Swift File And Package Boundaries ✅ Passed AppearanceSettings.swift (347 lines) is under the 400-line threshold for new files. AppKit glue/UI/state/persistence mixing is permitted for focused bug fixes per rules.
Cmux Swift Logging ✅ Passed No logging violations found. The production code changes (Sources/AppearanceSettings.swift) contain zero instances of print, debugPrint, dump, NSLog, or ad hoc logging. Test code is exempt.
Cmux User-Facing Error Privacy ✅ Passed PR changes contain only developer-only documentation and test code. No user-facing errors or sensitive data exposed.
Cmux Full Internationalization ✅ Passed Changes add test code (allowed exception) and production code comments only; no user-facing text introduced. Assertion message is in test code, which is expressly allowed under i18n rules.
Cmux Swiftui State Layout ✅ Passed PR modifies AppearanceSettingsUserDefaultsObserver, a non-SwiftUI AppKit bridge class. No @Published, @Observable, ObservableObject, GeometryReader, or lazy list patterns added.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new windows created. Changes modify appearance observer logic and add test-only fixtures—both allowed exceptions to the auxiliary window close-shortcuts rule.
✨ 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.

@ma-pony

ma-pony commented May 22, 2026

Copy link
Copy Markdown
Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

@ma-pony Sure, I'll kick off a full review of PR #4528 now!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai

cubic-dev-ai Bot commented May 22, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@ma-pony I have started the AI code review. It will take a few minutes to complete.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@greptile-apps

greptile-apps Bot commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the steady-state launch appearance bug (#4527) where AppearanceSettingsUserDefaultsObserver never applied the persisted appearanceMode on first attach because UserDefaults.didChangeNotification only fires on changes, not on read of an already-stored value. The early cmuxApp.init() apply runs too early (before NSApp is fully initialized) to guarantee the first NSWindow picks it up.

  • AppearanceSettings.swift: startObserving() now performs a one-shot environment.applyStoredMode(initialRawValue, source) immediately after registering the defaults observer, then seeds lastObservedRawValue from the applied (normalized) mode; re-entrancy is guarded by the existing defaultsObserver == nil check.
  • AppearanceSettingsTests.swift: Adds testStartObservingAppliesStoredAppearanceImmediately — an isolated regression test that pre-seeds "dark" in a fresh UserDefaults suite, calls startObserving(), and asserts .darkAqua is applied via an injected LiveApplyEnvironment.

Confidence Score: 3/5

The fix correctly resolves the steady-state dark-mode launch gap, but startObserving() now performs an AppKit UI mutation (NSApplication.shared.appearance) synchronously without a @MainActor annotation on the method or the class, leaving the caller responsible for being on the main thread with no compiler enforcement.

The new applyStoredMode call in startObserving() writes directly to NSApplication.shared.appearance — a main-thread-only AppKit operation — while the method carries no @MainActor annotation. The existing applyIfChanged() path is safe because the live environment dispatches notifications on queue: .main, but the new synchronous call has no such dispatch. If startObserving() is called off-main in any current or future path, the result is either subtle rendering misbehaviour or a strict-concurrency compile failure. The regression test is well-isolated and correctly exercises the fix, but it runs under @MainActor and would not catch a missing annotation in production call sites.

Sources/AppearanceSettings.swift — specifically AppearanceSettingsUserDefaultsObserver.startObserving() and the class declaration.

Important Files Changed

Filename Overview
Sources/AppearanceSettings.swift Adds a one-shot applyStoredMode call in startObserving() to fix the steady-state launch appearance gap; introduces an actor-isolation gap because the new UI mutation is unsynchronised by @MainActor.
cmuxTests/AppearanceSettingsTests.swift Adds a well-isolated regression test (testStartObservingAppliesStoredAppearanceImmediately) with injected LiveApplyEnvironment and a fresh UserDefaults suite; correctly asserts the .darkAqua appearance is applied on the first startObserving() call.

Sequence Diagram

sequenceDiagram
    participant App as cmuxApp.init()
    participant DL as applicationDidFinishLaunching
    participant Obs as AppearanceSettingsUserDefaultsObserver
    participant UD as UserDefaults
    participant NSApp as NSApplication.shared

    App->>NSApp: applyAppearance(duringLaunch:true)
    Note over App,NSApp: Before NSApp fully initialized — may not stick to first NSWindow

    DL->>Obs: startObserving()
    Obs->>UD: currentRawValue() → "dark"
    Obs->>Obs: register NotificationCenter observer (queue: .main)
    Note over Obs: NEW (this PR)
    Obs->>Obs: applyStoredMode("dark", source)
    Obs->>NSApp: setApplicationAppearance(.darkAqua)
    Obs->>Obs: "lastObservedRawValue = "dark""

    Note over UD,NSApp: Later — user changes mode in Settings
    UD-->>Obs: UserDefaults.didChangeNotification (on .main queue)
    Obs->>Obs: applyIfChanged()
    Obs->>UD: currentRawValue()
    Obs->>NSApp: setApplicationAppearance(…)
Loading

Comments Outside Diff (1)

  1. Sources/AppearanceSettings.swift, line 283-313 (link)

    P1 Missing @MainActor on startObserving() after new UI mutation

    The new environment.applyStoredMode(initialRawValue, self.source) call on line 312 reaches setApplicationAppearance → NSApplication.shared.appearance = …, which is a main-thread-only AppKit mutation. Before this PR, startObserving() only registered the NotificationCenter observer (safe from any thread), and all UI work happened inside applyIfChanged(), which Environment.live() dispatches on queue: .main. After this PR, startObserving() performs the same UI mutation synchronously, with no @MainActor annotation on either the method or the class. Any caller that invokes startObserving() from a non-main context — or any future refactor that does so — will mutate AppKit state off the main thread, causing silent misbehaviour or a crash under Swift 6 strict concurrency checking.

    Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Reviews (1): Last reviewed commit: "fix: apply stored appearance when observ..." | Re-trigger Greptile

Comment on lines 294 to +313
defaultsObserver = environment.addDefaultsObserver { [weak self] in
self?.applyIfChanged()
}
// After commit 70bcbda2 (PR #4415) the settings-file store no
// longer applies appearance as a side-effect during launch —
// that responsibility was delegated to this observer to avoid
// re-entering Ghostty while the file store initializes. The
// observer's `applyIfChanged` only fires on *changes* to
// `appearanceMode`, so in the steady-state case (UserDefaults
// already has the imported value), no change event ever fires
// and the launch-time apply is missed.
//
// The early `Self.applyAppearance(_, duringLaunch: true)` in
// `cmuxApp.init()` does set `NSApplication.shared.appearance`
// but runs before `NSApp` is fully initialized, so the value
// can fail to stick to the first `NSWindow`. Re-apply once
// here, from `applicationDidFinishLaunching`, to guarantee
// the stored mode reaches the main WindowGroup window.
let appliedMode = environment.applyStoredMode(initialRawValue, self.source)
lastObservedRawValue = appliedMode.rawValue

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 Split launch-apply paths leave the invariant unnamed

After this fix there are two independent sites that apply appearance at launch: the cmuxApp.init() early path (with duringLaunch: true, which resolves .system to an explicit NSAppearance) and the new startObserving() path (with duringLaunch: false, which sends nil for .system). The two calls have different duringLaunch semantics and different windows in the app lifecycle, but neither call site names the invariant that makes the other one complementary. For .system mode the startObserving() apply silently overwrites the NSApplication.shared.appearance value that the init path carefully computed, without a comment explaining that this is intentional. A future maintainer removing either path — or changing the duringLaunch logic — would not have a clear signal that both sites must stay in sync.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a launch-time appearance gap: when appearanceMode is already persisted in UserDefaults at startup, the AppearanceSettingsUserDefaultsObserver never fires (it only fires on changes), leaving NSApp.appearance unset until the user manually toggles the setting. The fix adds a one-shot applyStoredMode call immediately after the observer is registered inside startObserving(), which is invoked from applicationDidFinishLaunching.

  • Core fix (AppearanceSettings.swift): startObserving() now reads the persisted appearanceMode and applies it once at observer-registration time; subsequent startObserving() calls only refresh lastObservedRawValue to preserve change-detection accuracy without re-applying.
  • Regression test (AppearanceSettingsTests.swift): testStartObservingAppliesStoredAppearanceImmediately seeds appearanceMode = dark in an isolated UserDefaults suite before calling startObserving(), then asserts the injected setApplicationAppearance receives .darkAqua, directly covering the fixed code path.

Confidence Score: 3/5

The fix correctly addresses the missed initial apply for .dark/.light steady-state cases, but leaves cmuxApp.init() and startObserving() as two sequential launch-apply paths with different arguments — a maintenance trap if either is modified or removed later.

Both cmuxApp.init() (with duringLaunch: true, no terminal-theme sync) and the new startObserving() path (with duringLaunch: false, full sync) run unconditionally at every cold launch. For .system mode the init() path explicitly resolves and sets a concrete system appearance, which startObserving() then resets to nil — functionally correct but semantically incoherent. The init() path is acknowledged in a comment as unreliable yet retained, meaning neither path is clearly the single owner of launch-time appearance state.

Sources/AppearanceSettings.swift — the new startObserving() side effect and its interaction with the retained early-apply in cmuxApp.init() warrant a closer look.

Important Files Changed

Filename Overview
Sources/AppearanceSettings.swift Adds a one-shot applyStoredMode call in startObserving() so the persisted appearance is applied at applicationDidFinishLaunching; the early cmuxApp.init() apply path (with duringLaunch: true) remains active, creating two sequential launch-apply paths with different semantics for .system mode.
cmuxTests/AppearanceSettingsTests.swift Adds testStartObservingAppliesStoredAppearanceImmediately, a well-isolated regression test using a UUID-scoped UserDefaults suite and injected LiveApplyEnvironment closures; covers the .dark mode path correctly with proper teardown.

Sequence Diagram

sequenceDiagram
    participant App as cmuxApp.init()
    participant AD as AppDelegate.applicationDidFinishLaunching
    participant Obs as AppearanceSettingsUserDefaultsObserver
    participant AS as AppearanceSettings
    participant NSApp as NSApplication.shared

    App->>AS: applyLiveMode(mode, duringLaunch: true, syncTheme: false)
    AS->>NSApp: "appearance = systemNSAppearance() [system] or darkAqua/aqua [dark/light]"
    Note over NSApp: NSApp may not be fully initialized — apply can fail to stick

    AD->>Obs: startObserving()
    Obs->>AS: applyStoredMode(initialRawValue, source, duringLaunch: false)
    AS->>NSApp: "appearance = nil [system] or darkAqua/aqua [dark/light]"
    Note over NSApp: Authoritative apply — NSApp fully initialized
    Obs->>Obs: "defaultsObserver = addDefaultsObserver"
    Obs->>Obs: "lastObservedRawValue = appliedMode.rawValue"

    Note over Obs: Subsequent UserDefaults changes
    Obs-->>AS: applyIfChanged() → applyStoredMode(rawValue, source)
    AS->>NSApp: appearance updated
Loading

Reviews (2): Last reviewed commit: "fix: apply stored appearance when observ..." | Re-trigger Greptile

Comment on lines +297 to +313
// After commit 70bcbda2 (PR #4415) the settings-file store no
// longer applies appearance as a side-effect during launch —
// that responsibility was delegated to this observer to avoid
// re-entering Ghostty while the file store initializes. The
// observer's `applyIfChanged` only fires on *changes* to
// `appearanceMode`, so in the steady-state case (UserDefaults
// already has the imported value), no change event ever fires
// and the launch-time apply is missed.
//
// The early `Self.applyAppearance(_, duringLaunch: true)` in
// `cmuxApp.init()` does set `NSApplication.shared.appearance`
// but runs before `NSApp` is fully initialized, so the value
// can fail to stick to the first `NSWindow`. Re-apply once
// here, from `applicationDidFinishLaunching`, to guarantee
// the stored mode reaches the main WindowGroup window.
let appliedMode = environment.applyStoredMode(initialRawValue, self.source)
lastObservedRawValue = appliedMode.rawValue

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 Dual launch-apply paths with divergent duringLaunch semantics

startObserving() now performs a one-shot applyStoredMode(duringLaunch: false) at applicationDidFinishLaunching, but cmuxApp.init() still calls Self.applyAppearance(startupAppearance, duringLaunch: true) unconditionally first. The comment here acknowledges the init() path "can fail to stick," but it remains active, so both paths run at every cold launch in sequence. They behave differently for .system mode: the init() path resolves systemNSAppearance() and sets a concrete NSAppearance, while this new path calls applicationAppearance(for: .system, duringLaunch: false) which returns nil, resetting NSApp.appearance back to the OS default. Setting nil is functionally correct for system mode at applicationDidFinishLaunching, but having two sequential launch-time apply paths — one explicitly described as unreliable and one intended to be authoritative — leaves the invariant ("appearance is applied exactly once at the right lifecycle point") unowned. A future caller who removes or conditions the init() path will not know the startObserving() apply is load-bearing, and vice versa. The cmuxApp.init() early apply should either be removed or clearly documented as intentional pre-NSApp-init scaffolding that is superseded by this call.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@Sources/AppearanceSettings.swift`:
- Around line 287-293: The bug is that startObserving() updates
lastObservedRawValue when defaultsObserver != nil, which can cancel a queued
UserDefaults.didChangeNotification and prevent applyIfChanged() from running;
fix by removing the assignment to lastObservedRawValue inside the guard branch
so that when defaultsObserver is already set (i.e., already observing) the
method simply returns without changing the baseline; update the code around
startObserving(), defaultsObserver, lastObservedRawValue,
environment.currentRawValue(), and applyIfChanged() accordingly so the queued
notification still triggers an actual change check.
🪄 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: 93fcb6a4-766a-44d4-8761-86bd664b9c58

📥 Commits

Reviewing files that changed from the base of the PR and between bcd630e and 88c4402.

📒 Files selected for processing (2)
  • Sources/AppearanceSettings.swift
  • cmuxTests/AppearanceSettingsTests.swift

Comment on lines +287 to +293
let initialRawValue = environment.currentRawValue()
guard defaultsObserver == nil else {
// Already observing — keep the change-detection baseline
// accurate without re-applying.
lastObservedRawValue = initialRawValue
return
}

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 | ⚡ Quick win

Avoid updating lastObservedRawValue when defaultsObserver != nil—it can suppress a pending appearance change.

In Sources/AppearanceSettings.swift, startObserving() captures initialRawValue and, when already observing, sets lastObservedRawValue to that new baseline before returning. If appearanceMode changes and a UserDefaults.didChangeNotification is already queued on .main, calling startObserving() again before the queued applyIfChanged() runs can turn the queued update into a no-op, leaving the app on the old appearance despite defaults having changed.

Suggested fix
-        let initialRawValue = environment.currentRawValue()
         guard defaultsObserver == nil else {
-            // Already observing — keep the change-detection baseline
-            // accurate without re-applying.
-            lastObservedRawValue = initialRawValue
             return
         }
+        let initialRawValue = environment.currentRawValue()
🤖 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/AppearanceSettings.swift` around lines 287 - 293, The bug is that
startObserving() updates lastObservedRawValue when defaultsObserver != nil,
which can cancel a queued UserDefaults.didChangeNotification and prevent
applyIfChanged() from running; fix by removing the assignment to
lastObservedRawValue inside the guard branch so that when defaultsObserver is
already set (i.e., already observing) the method simply returns without changing
the baseline; update the code around startObserving(), defaultsObserver,
lastObservedRawValue, environment.currentRawValue(), and applyIfChanged()
accordingly so the queued notification still triggers an actual change check.

@greptile-apps

greptile-apps Bot commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a launch-time appearance regression introduced by PR #4415: AppearanceSettingsUserDefaultsObserver.startObserving() now performs a one-shot applyStoredMode after registering the defaults observer, so the stored appearanceMode is applied even when UserDefaults has not changed since the last launch.

  • Sources/AppearanceSettings.swift: startObserving() now applies the persisted appearance immediately on first attach; re-entrant calls refresh the change-detection baseline without re-applying, preserving idempotence.
  • cmuxTests/AppearanceSettingsTests.swift: Adds regression test testStartObservingAppliesStoredAppearanceImmediately that pre-seeds .dark, calls startObserving(), and asserts .darkAqua was applied via the injected LiveApplyEnvironment.

Confidence Score: 4/5

Safe to merge for the targeted dark/light launch regression; the system mode path now sets NSApp.appearance to nil at observation start which is correct but untested.

The fix is small and well-commented. The two findings are a secondary launch-time apply path that duplicates cmuxApp.init() with different duringLaunch semantics, and a missing test case for system mode at startObserving() time. Neither blocks the intended fix from working correctly for the reported bug.

Sources/AppearanceSettings.swift — the interaction between the cmuxApp.init() apply (duringLaunch: true) and the new startObserving() apply (duringLaunch: false) for .system mode is worth a second look.

Important Files Changed

Filename Overview
Sources/AppearanceSettings.swift Adds a one-shot applyStoredMode call at the end of the first startObserving(), with a correct idempotency guard on re-attach. Creates a second launch-time apply path alongside the existing cmuxApp.init() call, with subtly different duringLaunch semantics for .system mode.
cmuxTests/AppearanceSettingsTests.swift Adds regression test testStartObservingAppliesStoredAppearanceImmediately covering .dark mode initial apply; misses a corresponding test for .system mode where startObserving() now unconditionally sets appearance to nil.

Sequence Diagram

sequenceDiagram
    participant App as cmuxApp.init()
    participant DFL as applicationDidFinishLaunching
    participant Obs as AppearanceSettingsUserDefaultsObserver
    participant UD as UserDefaults
    participant NSApp as NSApplication

    App->>NSApp: applyAppearance(duringLaunch: true)
    Note over NSApp: may not stick to first NSWindow
    DFL->>Obs: startObserving()
    Obs->>UD: addObserver (fires on changes only)
    Obs->>UD: "currentRawValue() -> dark"
    Obs->>NSApp: applyStoredMode(dark, source)
    Note over NSApp: duringLaunch: false -> darkAqua
    Note over Obs: lastObservedRawValue = dark
    UD-->>Obs: didChangeNotification (user toggles)
    Obs->>Obs: applyIfChanged()
    Obs->>UD: "currentRawValue() -> new value"
    Obs->>NSApp: applyStoredMode(newValue, source)
Loading

Comments Outside Diff (1)

  1. Sources/AppearanceSettings.swift, line 283-314 (link)

    P2 Two launch-time apply paths with different semantics

    startObserving() now applies the stored mode with duringLaunch: false, while the earlier call in cmuxApp.init() uses duringLaunch: true. For .system mode these produce different results: duringLaunch: true resolves and explicitly sets the system-inferred appearance, while duringLaunch: false sets NSApp.appearance = nil (back to system tracking). The fix adds a second authoritative apply site without retiring or gating the first, leaving two call sites with partial, differing ownership of the same launch-time invariant. Under the swift-architectural-rethink rule, behavior split across multiple surfaces — each with subtly different semantics — is flagged even when both are individually correct. The minimal clean-up would be to have startObserving unconditionally own the post-launch apply (with duringLaunch: false), and either remove the redundant cmuxApp.init() path or guard it to not apply for modes that startObserving will re-apply moments later.

    Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Reviews (3): Last reviewed commit: "fix: apply stored appearance when observ..." | Re-trigger Greptile

Comment on lines +451 to +504
func testStartObservingAppliesStoredAppearanceImmediately() {
let suiteName = "AppearanceSettingsTests.StartObservingInitialApply.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }

var appliedAppearanceName: NSAppearance.Name?
var synchronizedAppearanceName: NSAppearance.Name?
var synchronizedSource: String?
let liveEnvironment = AppearanceSettings.LiveApplyEnvironment(
setApplicationAppearance: { appearance in
appliedAppearanceName = appearance?.bestMatch(from: [.darkAqua, .aqua])
},
synchronizeTerminalThemeWithAppearance: { appearance, source in
synchronizedAppearanceName = appearance?.bestMatch(from: [.darkAqua, .aqua])
synchronizedSource = source
},
systemAppearance: {
XCTFail("Dark mode should not resolve system appearance")
return nil
}
)
let observer = AppearanceSettingsUserDefaultsObserver(
environment: .init(
addDefaultsObserver: { _ in NSObject() },
removeObserver: { _ in },
currentRawValue: {
defaults.string(forKey: AppearanceSettings.appearanceModeKey)
},
applyStoredMode: { rawValue, source in
AppearanceSettings.applyStoredMode(
rawValue: rawValue,
defaults: defaults,
source: source,
environment: liveEnvironment
)
}
),
source: "test.startObservingInitialApply"
)

defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey)
observer.startObserving()

XCTAssertEqual(
appliedAppearanceName,
.darkAqua,
"startObserving must apply the persisted appearance mode immediately so the main WindowGroup window picks it up on launch"
)
XCTAssertEqual(synchronizedAppearanceName, .darkAqua)
XCTAssertEqual(synchronizedSource, "test.startObservingInitialApply")
}

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 No test for .system mode behavior at startObserving() time

The regression test only seeds .dark and asserts .darkAqua. For .system mode, applyStoredMode(..., duringLaunch: false) sets NSApp.appearance = nil (the applicationAppearance function returns nil for .system when duringLaunch == false). This is different from the cmuxApp.init() call which uses duringLaunch: true and explicitly calls environment.systemAppearance(). A test pre-seeding .system should assert that appliedAppearanceName == nil and that systemAppearance is not called — confirming this nil-set behavior is intentional, and that the XCTFail guard in the existing systemAppearance stub would correctly catch an accidental regression where startObserving inadvertently calls through to system-appearance resolution.

@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

Re-trigger cubic

@teamleaderleo teamleaderleo added S3: minor Wrong behavior with a workaround area: appearance Themes, light and dark mode, window chrome 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: appearance Themes, light and dark mode, window chrome S3: minor Wrong behavior with a workaround

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Main window and sidebar render Light on launch despite app.appearance: dark (regression from #4415)

2 participants