Skip to content

Fix Settings panel open latency - #3389

Closed
austinywang wants to merge 2 commits into
mainfrom
issue-3384-settings-slow-open
Closed

austinywang wants to merge 2 commits into
mainfrom
issue-3384-settings-slow-open

Conversation

@austinywang

@austinywang austinywang commented May 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3384.

Measurements

Baseline DEV build, clean profile:

  • Cold Settings open: 2962 ms
  • Warm Settings reopen: 873 ms

Instrumented DEV build, clean profile:

  • Cold Settings open: 3147 ms to visible window, 3306 ms to first paint probe
  • Warm Settings reopen: 754 ms

Instrumented DEV build, heavier profile with 6 workspaces and 3 browser panes:

  • Warm Settings reopen: 1077 ms

Fixed DEV build:

  • Cold Settings open: 560 ms
  • Warm Settings refocus/reopen with the Settings window resident: 8-12 ms

Root Cause

Settings opened by constructing one giant SwiftUI detail view for every pane even when the selected pane was Account. The first open showed all 11 pane headers appearing at first presentation, eager Browser import detection on the main thread, repeated workspace palette refreshes, and hundreds of keyboard shortcut recorder rows initialized around first paint. openWindow() itself blocked the main actor for about 1283 ms cold and 371-408 ms warm, so this was main-thread view construction and synchronous pane setup rather than off-main work gating paint.

Fix

The Settings detail now constructs only the selected top-level pane and runs pane-specific refresh work only when that pane is visible. Browser import detection is moved off the main actor, dirty Browser HTTP allowlist drafts are preserved while switching sections, pending deep-link targets are applied before the first detail render, and scroll-to navigation is delayed until after the lazy target section has a chance to render its anchors.

Regression Budget

tests_v2/test_settings_open_latency.py opens Settings through the socket and asserts first present to a capturable Settings window stays at or below 1000 ms. It uses the existing debug screenshot command plus standard-library PNG header parsing, so tests_v2 does not require PyObjC/Quartz. Main measured about 2962 ms on the same path, while the fixed path measured about 560 ms.

Verification

  • ./scripts/reload.sh --tag issue-3384-settings-slow-open --launch reached BUILD SUCCEEDED during intermediate verification. LaunchServices returned -600 on that intermediate launch attempt, so I reran the required tagged launch after updating the PR.
  • Local tests were not run by instruction; CI owns the tests_v2 regression.

Notes

The investigation notes are kept in the PR body only; no Markdown PR-body draft is committed to the repository.

Summary by CodeRabbit

  • Performance Improvements

    • Settings now initialize and refresh only for the visible section, reducing background work and improving responsiveness.
  • New Features

    • Revamped theme and app-icon pickers with richer previews and selection behavior.
    • Safer, cancellable browser-import detection to prevent stale updates.
  • Bug Fixes

    • Insecure-allowlist editing now tracks draft lifecycle and avoids overwriting unsaved edits; reactive updates run only for visible sections.
  • Tests

    • Added a regression test measuring Settings open latency and a debug accessor for window state.

Note

Medium Risk
Medium risk because it restructures the Settings SwiftUI view composition and refresh side-effects (including async browser detection and draft/dirty state), which could cause missing refreshes or state regressions when switching sections.

Overview
Improves Settings window first-open latency by changing the detail view to only construct and refresh the currently selected top-level section, rather than eagerly building all panes.

Adds section-aware refresh hooks and moves installed-browser import detection off the main thread with cancellable, generation-guarded tasks; also preserves unsaved edits for the browser HTTP allowlist via explicit draft initialization/dirty tracking.

Refactors reusable Settings picker rows into Settings/SettingsPickerRows.swift, adds a DEBUG socket method debug.settings.window_state for window visibility/size introspection, and introduces a tests_v2/test_settings_open_latency.py regression test that asserts Settings becomes capturable within a 1s budget.

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

@vercel

vercel Bot commented May 1, 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 May 1, 2026 10:00am
cmux-staging Building Building Preview, Comment May 1, 2026 10:00am

@coderabbitai

coderabbitai Bot commented May 1, 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

Settings UI is made section-aware end-to-end: the root passes a selected section into the detail view, refresh and reactive updates are gated to visible sections, browser detection is moved to a cancellable async task with generation tracking, insecure-allowlist draft lifecycle is tracked, new picker rows and debug/test helpers added.

Changes

Cohort / File(s) Summary
Settings core & navigation
Sources/cmuxApp.swift
Make Settings section-aware: pass/apply navigation targets, render only selected detail section, replace unconditional .onAppear refreshes with refreshVisibleSettingsSection(), gate reactive updates to visible section, move detected-import browser detection to a cancellable Task with generation tracking, and add draft dirty lifecycle for insecure HTTP allowlist. Removed some legacy helper types.
Settings picker UI
Sources/Settings/SettingsPickerRows.swift
Add ThemePickerRow and AppIconPickerRow SwiftUI views, configuration validation to convert setting paths to search anchors and assert supported paths, and thumbnail/app-icon preview options.
Settings debug socket
Sources/Settings/SettingsDebugSocketCommands.swift, Sources/TerminalController.swift
Add debug V2 socket handler debug.settings.window_state returning window frame and flags; register handler in V2 dispatcher and capabilities list (debug builds).
Xcode project
GhosttyTabs.xcodeproj/project.pbxproj
Add new Swift source files to project sources so they compile into the app.
CMUX test client
tests_v2/cmux.py
Add client helpers: open_settings(target: Optional[str]=None, activate: bool=True) to invoke settings.open, and settings_window_state() to fetch debug.settings.window_state normalized to a dict.
Settings open latency test
tests_v2/test_settings_open_latency.py
New regression test that drives real settings.open over the debug socket, polls for window state, captures a screenshot, measures first-present latency vs. budget, validates screenshot dimensions, and returns descriptive failures.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as Client (cmux test / Presenter)
    participant AppRoot as App Root\n(`cmuxApp.swift`)
    participant SettingsView as SettingsView\n(detail)
    participant Refresher as RefreshManager\n(refreshVisibleSettingsSection)
    participant BrowserTask as BrowserDetectTask\n(cancellable Task)

    Client->>AppRoot: request open / navigation target
    AppRoot->>AppRoot: store pending NavigationTarget
    AppRoot->>SettingsView: supply `selectedSection`
    SettingsView->>Refresher: refreshVisibleSettingsSection(selectedSection)
    Refresher->>Refresher: run only relevant updates
    Refresher->>BrowserTask: start cancellable detection (gen N)
    BrowserTask-->>Refresher: return result (only apply if gen == current)
    Refresher->>SettingsView: update visible-section state
    Client->>AppRoot: (debug) query `debug.settings.window_state`
    AppRoot->>AppRoot: enumerate NSApp.windows, return window flags
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped into settings, swift and keen,
I only fetch the pane that's seen.
Async tasks cancel the stale,
Thumbnails peek without the fail.
Quick paint, small hop — no frozen screen!

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: fixing Settings panel open latency through the optimization work detailed in the changeset.
Linked Issues check ✅ Passed The PR comprehensively addresses all four coding-related objectives from issue #3384: reproduces and measures latency, identifies bottlenecks (eager pane construction, main-thread work), implements fixes (lazy rendering, async browser detection, dirty state preservation), and adds a regression test within a 1000ms budget.
Out of Scope Changes check ✅ Passed All changes are directly aligned with fixing Settings open latency: cmuxApp.swift refactors rendering, picker rows and debug socket commands support the fixes, test script and client methods enable regression testing, and project file updates ensure compilation.
Description check ✅ Passed The pull request description follows the template structure with all required sections: Summary, Testing, Checklist, and additional context including measurements, root cause analysis, and regression budget.

✏️ 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-3384-settings-slow-open

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.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/cmuxApp.swift (1)

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

Apply the pending Settings target before the first detail render.

consumePendingNavigationTarget() only runs in .onAppear, so the initial body still uses the persisted/default selectedSectionRaw. If the last-opened pane was something heavy like Keyboard Shortcuts, a deep-link open will still build that pane before switching, which partially reintroduces the eager work this PR is trying to remove.

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

In `@Sources/cmuxApp.swift` around lines 8378 - 8385, The initial view build still
uses persisted selectedSectionRaw because consumePendingNavigationTarget() is
only invoked in .onAppear; fix by applying the pending target before the first
render—call SettingsWindowPresenter.consumePendingNavigationTarget() during the
view's initialization (or when initializing selectedSection) and, if present,
set selectedSection and/or call navigate(to: target, postRequest: true) there
rather than waiting for .onAppear; update/remove the duplicate .onAppear logic
so symbols involved are
SettingsWindowPresenter.consumePendingNavigationTarget(),
selectedSection/selectedSectionRaw, and navigate(to:postRequest:) to ensure the
pending target is applied prior to the body being built.
🤖 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 5855-5862: The browser-detection call is running synchronously on
the main thread via refreshDetectedImportBrowsers() which invokes
InstalledBrowserDetector.detectInstalledBrowsers(); change
refreshDetectedImportBrowsers() (or the code path inside the
.browser/.browserImport case) to run the heavy detectInstalledBrowsers() work
off the main actor (e.g., in a detached Task or background Task) and then
publish the results back on the main actor using MainActor.run (or Task {
`@MainActor` in ... }) to update UI-bound state like browserImportHintVariantRaw,
browserHistoryEntryCount, and any properties set by
refreshDetectedImportBrowsers(); ensure
InstalledBrowserDetector.detectInstalledBrowsers() is called only from the
background task and not directly on the main thread.

In `@tests_v2/test_settings_open_latency.py`:
- Around line 94-97: The precondition only rejects already-visible Settings
windows but must also reject existing but minimized/off-screen windows to avoid
warm-path refocus; update the check before calling
_measure_visible_settings_open(c, pid) to detect any existing Settings window
(not just visible) — either extend _settings_window_visible to check for
existence/state (minimized/hidden) or add a helper like
_settings_window_exists(pid) and raise cmuxError when it returns true so the
test always measures a true first-present open.

---

Outside diff comments:
In `@Sources/cmuxApp.swift`:
- Around line 8378-8385: The initial view build still uses persisted
selectedSectionRaw because consumePendingNavigationTarget() is only invoked in
.onAppear; fix by applying the pending target before the first render—call
SettingsWindowPresenter.consumePendingNavigationTarget() during the view's
initialization (or when initializing selectedSection) and, if present, set
selectedSection and/or call navigate(to: target, postRequest: true) there rather
than waiting for .onAppear; update/remove the duplicate .onAppear logic so
symbols involved are SettingsWindowPresenter.consumePendingNavigationTarget(),
selectedSection/selectedSectionRaw, and navigate(to:postRequest:) to ensure the
pending target is applied prior to the body being built.
🪄 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: 97eb9135-d8cc-4d37-b73e-543ffc293334

📥 Commits

Reviewing files that changed from the base of the PR and between f2bbbba and 2008f6d.

📒 Files selected for processing (3)
  • Sources/cmuxApp.swift
  • docs/issue-3384-settings-open-pr-body.md
  • tests_v2/test_settings_open_latency.py

Comment thread Sources/cmuxApp.swift
Comment thread tests_v2/test_settings_open_latency.py Outdated
@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates the Settings open latency (2962 ms → 560 ms cold) by wrapping every Settings pane in an if selectedSection == .xxx guard so only the active section is constructed by SwiftUI, and by moving per-section side-effects (BrowserHistoryStore.loadIfNeeded, workspace palette reload, notification auth refresh, keyboard shortcut observer) into a new refreshVisibleSettingsSection() helper that fires on onAppear and onChange(of: selectedSection).

The Swift-side logic is sound: SettingsRootView.navigate always sets selectedSectionRaw before posting SettingsNavigationRequest, so by the time the existing DispatchQueue.main.async scroll fires, the correct pane anchors are rendered. Two minor issues exist in the new test: it uses the private _call method and leaves the Settings window open with no teardown, which will cause any retry to fail at the pre-condition check.

Confidence Score: 4/5

Safe to merge; the performance fix is correct and the only findings are P2 test/docs hygiene issues.

All findings are P2 (private method usage in test, missing teardown, stale docs file). The core SwiftUI change is logically consistent with the existing navigation contract and the measurements confirm the fix works. No P0 or P1 issues found.

tests_v2/test_settings_open_latency.py — private _call usage and missing teardown; docs/issue-3384-settings-open-pr-body.md — should be removed before merge.

Important Files Changed

Filename Overview
Sources/cmuxApp.swift Core perf fix: wraps each Settings pane in an if selectedSection == .xxx guard so only the active pane is rendered; moves per-section side-effects into refreshVisibleSettingsSection() called on onAppear and onChange(of: selectedSection); gates keyboardShortcutSettingsObserver.revision on the keyboard-shortcuts section. Logic is correct.
tests_v2/test_settings_open_latency.py New regression test measuring cold-open latency via CGWindowList; two P2 issues: uses private _call method and leaves Settings window open with no teardown, breaking subsequent runs.
docs/issue-3384-settings-open-pr-body.md Verbatim copy of the PR description committed to docs/; likely accidental, will become stale, and adds noise to the repo.

Sequence Diagram

sequenceDiagram
    participant User
    participant SettingsRootView
    participant SettingsView

    User->>SettingsRootView: click sidebar entry (e.g. Keyboard Shortcuts)
    SettingsRootView->>SettingsRootView: selectedSectionRaw = .keyboardShortcuts
    SettingsRootView->>SettingsView: SettingsNavigationRequest (notification)
    Note over SettingsView: onReceive fires synchronously
    SettingsView->>SettingsView: DispatchQueue.main.async { proxy.scrollTo(...) }
    SettingsRootView->>SettingsView: re-render with selectedSection = .keyboardShortcuts
    Note over SettingsView: only .keyboardShortcuts pane constructed
    SettingsView->>SettingsView: onChange(of: selectedSection) → refreshVisibleSettingsSection()
    Note over SettingsView: async scroll fires — anchors now registered
Loading

Reviews (1): Last reviewed commit: "Stop Settings from constructing inactive..." | Re-trigger Greptile

Comment thread tests_v2/test_settings_open_latency.py Outdated


def _open_settings(c: cmux) -> None:
c._call("settings.open", {"activate": True})

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 Private internal method used for socket call

c._call(...) uses the Python name-mangled private method directly instead of the cmux public API surface. If the cmux client's internal dispatch signature changes, this test will break without an obvious API-contract signal. Prefer whatever public method (e.g. c.call(...) or c.request(...)) the cmux class exposes for sending socket commands.

Comment thread tests_v2/test_settings_open_latency.py Outdated
Comment on lines +93 to +97
with cmux(SOCKET_PATH) as c:
if _settings_window_visible(pid):
raise cmuxError("Settings window was already visible before the latency measurement")

first_open_ms = _measure_visible_settings_open(c, pid)

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 teardown — Settings window left open between runs

After _measure_visible_settings_open succeeds the Settings window stays visible. On a subsequent run (retry, local re-run, parallel CI job on the same machine) the pre-check on line 94 raises cmuxError("Settings window was already visible") with no guidance on how to recover. Consider closing the window — e.g. via c._call("settings.close", {}) or a focus-away command — in a finally block or a teardown step so the fixture state is clean for retries.

Comment on lines +1 to +47
Fixes https://github.com/manaflow-ai/cmux/issues/3384.

## Measurements

Baseline DEV build, clean profile:
- Cold Settings open: 2962 ms
- Warm Settings reopen: 873 ms

Instrumented DEV build, clean profile:
- Cold Settings open: 3147 ms to visible window, 3306 ms to first paint probe
- Warm Settings reopen: 754 ms

Instrumented DEV build, heavier profile with 6 workspaces and 3 browser panes:
- Warm Settings reopen: 1077 ms

Fixed DEV build:
- Cold Settings open: 560 ms
- Warm Settings refocus/reopen with the Settings window resident: 8-12 ms

## Root Cause

Settings opens construct one giant SwiftUI detail view for every pane even when the selected pane is Account. The first open shows all 11 pane headers appearing at first presentation, eager Browser import detection on the main thread, repeated workspace palette refreshes, and hundreds of keyboard shortcut recorder rows initialized around first paint. `openWindow()` itself blocks the main actor for about 1283 ms cold and 371-408 ms warm, so this is main-thread view construction and synchronous pane setup rather than off-main work gating paint.

## Suspect List

Confirmed:
- Eager synchronous SwiftUI tree construction for all Settings panes
- Eager keyboard shortcut row/recorder construction
- Eager Browser import disk scan from `InstalledBrowserDetector.detectInstalledBrowsers()`
- Eager browser history load, though small in this profile

Ruled out or not implicated by code/logs:
- `ghostty +list-themes`
- font enumeration
- Localizable.xcstrings runtime scan
- eager Convex/agent profile fetch
- image asset decode
- unified config settings utility window (#3024), which uses a separate window
- recent AppDelegate bootstrap reorder side effects, which do not appear on the Settings open path

## Fix Plan

Make the Settings detail construct only the currently selected top-level pane, keep search/navigation selecting the target pane before scrolling, and move pane-specific `onAppear` work into the pane that needs it. In particular, Browser history/import detection should run only for Browser/Browser Import, workspace color palette reloads only for Workspace Colors, notification status work only for App, and keyboard shortcut rows only for Keyboard Shortcuts. This avoids both the main-thread first-present stall and the warm reopen penalty without adding sleeps or new dependencies.

## Regression Budget

Add a `tests_v2` regression that opens Settings through the app/socket and asserts first present to a visible Settings window stays at or below 1000 ms. Main measured about 2962 ms on the same path, while the fixed path measured about 560 ms.

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 PR description committed verbatim as a docs file

This file is a byte-for-byte copy of the PR description. Checking it into docs/ means it will become stale as follow-up work lands, and readers may mistake it for authoritative documentation rather than a point-in-time write-up. The investigation notes are useful but they belong in the GitHub issue/PR itself, not in the repository's docs tree. Consider removing this file before merging.

Comment thread Sources/cmuxApp.swift
Comment thread Sources/cmuxApp.swift

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

Issue 3384 reproduces as a multi-second Settings first-show pause, so the regression opens Settings through the socket and waits until the app can capture the presented Settings window. The test uses the existing debug screenshot command plus standard-library PNG header parsing so tests_v2 does not gain a PyObjC dependency.

Constraint: Local test runs are disabled for this repo; CI owns execution.

Constraint: tests_v2 runs with plain python3 and cannot assume PyObjC/Quartz is installed.

Rejected: Source-text assertions for lazy Settings panes | repo policy requires behavioral tests over implementation-shape checks

Confidence: medium

Scope-risk: narrow

Tested: Not run locally by instruction.

Not-tested: CI execution of tests_v2/test_settings_open_latency.py.

@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 (1)
tests_v2/test_settings_open_latency.py (1)

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

Precondition still permits warm-path reuse instead of true first-present measurement.

At Line 76, this only rejects a window that already looks like Settings by size. It does not reject an existing Settings window that is minimized/hidden/off-screen, so settings.open can take a fast reuse path and understate first-present latency.

Based on learnings: SettingsWindowPresenter.show(navigationTarget:) reuses an existing Settings NSWindow (including deminiaturize/refocus) instead of always creating a new one.

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

In `@tests_v2/test_settings_open_latency.py` around lines 75 - 79, The
precondition only checks size via _capture_main_window_size and
_looks_like_settings_size so it can miss an existing Settings NSWindow that is
minimized/hidden and let SettingsWindowPresenter.show(navigationTarget:) reuse
it; update the test to ensure a true first-present measurement by detecting and
forcing teardown of any existing Settings window before timing: use a stronger
check (e.g., enumerating app windows by title/role/accessibility identifier or a
helper that queries the Settings window instance) and if found, close or destroy
it (or call the presenter to force-create a new instance) so the test cannot
take the reuse path; change references to
_capture_main_window_size/_looks_like_settings_size to this stronger detection
and teardown step.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests_v2/test_settings_open_latency.py`:
- Around line 64-68: _measure_settings_first_capture currently calls
c.open_settings(activate=True) then immediately takes a capture via
_capture_main_window_size, which is racy because open_settings schedules work on
the main queue and may not have presented the Settings window yet; modify
_measure_settings_first_capture to poll (with a short sleep and deadline
timeout) after calling c.open_settings until _capture_main_window_size returns a
capture whose size matches the expected Settings window size (or until a timeout
elapses), then measure the elapsed time from start to that successful capture;
use the existing function names open_settings and _capture_main_window_size and
ensure the polling loop breaks on success or timeout to avoid hanging.

---

Duplicate comments:
In `@tests_v2/test_settings_open_latency.py`:
- Around line 75-79: The precondition only checks size via
_capture_main_window_size and _looks_like_settings_size so it can miss an
existing Settings NSWindow that is minimized/hidden and let
SettingsWindowPresenter.show(navigationTarget:) reuse it; update the test to
ensure a true first-present measurement by detecting and forcing teardown of any
existing Settings window before timing: use a stronger check (e.g., enumerating
app windows by title/role/accessibility identifier or a helper that queries the
Settings window instance) and if found, close or destroy it (or call the
presenter to force-create a new instance) so the test cannot take the reuse
path; change references to _capture_main_window_size/_looks_like_settings_size
to this stronger detection and teardown step.
🪄 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: c10aa315-0c79-47bc-8c37-e338f851d01c

📥 Commits

Reviewing files that changed from the base of the PR and between 2008f6d and 98fd07b.

📒 Files selected for processing (3)
  • Sources/cmuxApp.swift
  • tests_v2/cmux.py
  • tests_v2/test_settings_open_latency.py
✅ Files skipped from review due to trivial changes (2)
  • tests_v2/cmux.py
  • Sources/cmuxApp.swift

Comment thread tests_v2/test_settings_open_latency.py Outdated
Comment thread Sources/cmuxApp.swift Outdated
@austinywang
austinywang force-pushed the issue-3384-settings-slow-open branch 2 times, most recently from 6928a15 to 98a9232 Compare May 1, 2026 07:48

@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 the current code and only fix it if needed.

Inline comments:
In `@Sources/cmuxApp.swift`:
- Around line 5872-5877: The current
refreshBrowserInsecureHTTPAllowlistDraftIfClean relies on comparing
browserInsecureHTTPAllowlistDraft to browserInsecureHTTPAllowlist to infer
edits, which can leave a stale draft marked dirty after external/defaults
changes; introduce an explicit Bool (e.g.
browserInsecureHTTPAllowlistDraftIsDirty) and use it instead of equality to
track user edits: in refreshBrowserInsecureHTTPAllowlistDraftIfClean set
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist and set
browserInsecureHTTPAllowlistDraftIsDirty = false when initializing/overwriting
the draft; flip browserInsecureHTTPAllowlistDraftIsDirty = true only from the
editor write handler(s) that mutate the draft; update any logic that currently
checks draft != stored (including save/enablement code around Browser UI) to
check browserInsecureHTTPAllowlistDraftIsDirty so external changes won’t be
overwritten by a stale “dirty” draft.
- Around line 7655-7670: refreshDetectedImportBrowsers currently cancels only
the outer Task (detectedImportBrowserRefreshTask) but the heavy work runs in an
unstructured Task.detached (InstalledBrowserDetector.detectInstalledBrowsers())
so rapid calls can spawn multiple expensive scans; fix by making the detection
cancellable: either (preferred) remove Task.detached and call
InstalledBrowserDetector.detectInstalledBrowsers() directly inside the wrapper
Task so cancellation flows (keep using detectedImportBrowserRefreshTask and
detectedImportBrowserRefreshGeneration/generation checks), or if you must keep a
detached task, store that detached Task in a new property (e.g.
detectedImportBrowserDetectionTask), cancel it before creating a new one, and
await its .value instead of creating an unreferenced detached task; ensure you
still clear detectedImportBrowserRefreshTask and
detectedImportBrowserDetectionTask when done and respect Task.isCancelled and
the generation guard before mutating detectedImportBrowsers.
🪄 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: a9396d53-cf93-4766-8d1d-9b31451776b3

📥 Commits

Reviewing files that changed from the base of the PR and between 98fd07b and 6928a15.

📒 Files selected for processing (3)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/Settings/SettingsPickerRows.swift
  • Sources/cmuxApp.swift
✅ Files skipped from review due to trivial changes (1)
  • GhosttyTabs.xcodeproj/project.pbxproj

Comment thread Sources/cmuxApp.swift
Comment thread Sources/cmuxApp.swift

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 98a9232. Configure here.

Comment thread Sources/cmuxApp.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: 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 8083-8086: Set the initial settings pane from the pending
navigation target before the view body evaluates by calling
SettingsWindowPresenter.consumePendingNavigationTarget() and, if non-nil,
assigning it to selectedSection (instead of waiting for .onAppear); leave the
existing .onAppear handler to only perform the follow-up navigate(to: ,
postRequest: true) scroll/highlight behavior. Specifically, locate the code
using consumePendingNavigationTarget(), selectedSection/selectedSectionRaw, and
navigate(to:postRequest:), move/duplicate the pending-target assignment to the
view initialization (or immediately before body evaluation) so the first render
uses that target, and ensure .onAppear no longer overrides selectedSection but
only calls navigate(to: target, postRequest: true).
🪄 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: 34fbed62-686a-4f6e-a697-b7cdc531167e

📥 Commits

Reviewing files that changed from the base of the PR and between 6928a15 and 98a9232.

📒 Files selected for processing (4)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/Settings/SettingsPickerRows.swift
  • Sources/cmuxApp.swift
  • tests_v2/test_settings_open_latency.py

Comment thread Sources/cmuxApp.swift
@austinywang

Copy link
Copy Markdown
Contributor Author

Updated after CI review: fixed the Swift file-length guard by moving Settings picker primitives out of cmuxApp.swift, addressed the relevant Settings reopen/deep-link and latency-test race comments, and confirmed the rerun is green including workflow-guard-tests, tests, release-build, ui-regressions, compat-tests, Vercel, and CodeRabbit.

@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/cmuxApp.swift (2)

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

Avoid Task.detached here unless you also cancel it explicitly.

detectedImportBrowserRefreshTask?.cancel() only cancels the wrapper task. The expensive scan is still running in a detached task, so rapid refreshes can stack multiple full browser-detection passes in the background.

💡 Keep a handle to the detached scan so cancellation actually stops the work
+    `@State` private var detectedImportBrowserDetectionTask: Task<[InstalledBrowserCandidate], Never>?
@@
     private func refreshDetectedImportBrowsers() {
         detectedImportBrowserRefreshTask?.cancel()
+        detectedImportBrowserDetectionTask?.cancel()
         detectedImportBrowserRefreshGeneration &+= 1
         let generation = detectedImportBrowserRefreshGeneration

-        detectedImportBrowserRefreshTask = Task {
-            let browsers = await Task.detached(priority: .utility) {
-                InstalledBrowserDetector.detectInstalledBrowsers()
-            }.value
+        let detectionTask = Task.detached(priority: .utility) {
+            InstalledBrowserDetector.detectInstalledBrowsers()
+        }
+        detectedImportBrowserDetectionTask = detectionTask
+        detectedImportBrowserRefreshTask = Task {
+            let browsers = await detectionTask.value
             guard !Task.isCancelled else { return }
             await MainActor.run {
                 guard detectedImportBrowserRefreshGeneration == generation else { return }
                 detectedImportBrowsers = browsers
                 detectedImportBrowserRefreshTask = nil
+                detectedImportBrowserDetectionTask = nil
             }
         }
     }
Does `Task.detached` inherit cancellation from its parent `Task` in Swift concurrency?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 7673 - 7688, The current code uses
Task.detached inside refreshDetectedImportBrowsers so cancelling
detectedImportBrowserRefreshTask does not cancel the expensive
InstalledBrowserDetector.detectInstalledBrowsers() scan; fix by making the scan
cancellable: either run the scan in the same parent Task (replace Task.detached
with Task { InstalledBrowserDetector.detectInstalledBrowsers() }) so it inherits
cancellation from detectedImportBrowserRefreshTask, or store the detached task
in a separate property (e.g., detectedImportBrowserScanTask) and call .cancel()
on it when starting a new refresh; ensure the code checks Task.isCancelled
appropriately and clears both detectedImportBrowserRefreshTask and the scan task
when finished, while still using detectedImportBrowserRefreshGeneration and
generation guard as currently implemented.

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

Seed the pending Settings target before the first detail render.

Line 8093 still renders SettingsView from the persisted selectedSectionRaw, and Line 8103 only swaps to the pending target in .onAppear. If the stored pane is something heavy like Keyboard Shortcuts, a deep-link open still pays for that first render before the navigation target wins.

💡 One way to apply the pending target up front
 private struct SettingsRootView: View {
     `@SceneStorage`("selectedSettingsSection") private var selectedSectionRaw = SettingsNavigationTarget.account.rawValue
     `@SceneStorage`("selectedSettingsSidebarEntry") private var selectedSidebarEntryID = SettingsSearchIndex.defaultSelectionID
     `@State` private var columnVisibility: NavigationSplitViewVisibility = .all
     `@State` private var searchText = ""
+    `@State` private var pendingInitialTarget = SettingsWindowPresenter.consumePendingNavigationTarget()

     private var selectedSection: SettingsNavigationTarget {
-        SettingsNavigationTarget(rawValue: selectedSectionRaw) ?? .account
+        pendingInitialTarget
+            ?? SettingsNavigationTarget(rawValue: selectedSectionRaw)
+            ?? .account
     }
@@
         .onAppear {
             searchText = ""
-            let target = SettingsWindowPresenter.consumePendingNavigationTarget() ?? selectedSection
+            let target = pendingInitialTarget ?? selectedSection
+            pendingInitialTarget = nil
             navigate(to: target, postRequest: true)
         }
Does SwiftUI `.onAppear` run only after the initial `body` evaluation/render, or can it change the first render pass?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 8093 - 8105, The persisted selected
section is still used for the first detail render; pre-seed the pending
navigation target before the initial body evaluation by consuming
SettingsWindowPresenter.consumePendingNavigationTarget() and applying it to the
view's selection state (e.g., selectedSection or selectedSectionRaw) during view
initialization (or immediately when the view's state is created) instead of
waiting for .onAppear; then keep the existing navigate(to:postRequest:) call for
post-appearance behavior so the initial render uses the pending target
(references: SettingsView, selectedSection/selectedSectionRaw,
SettingsWindowPresenter.consumePendingNavigationTarget(),
navigate(to:postRequest:), .onAppear).
🧹 Nitpick comments (1)
Sources/Settings/SettingsPickerRows.swift (1)

13-27: ⚡ Quick win

Fail fast when a settings JSON path has no search-anchor mapping.

Line 15 currently drops unmapped paths via compactMap, and validate() only checks supportedSettingsJSONPaths. That can silently break jump-to/highlight wiring for a valid settings path.

Suggested patch
     func validate(file: StaticString = `#fileID`, line: UInt = `#line`) {
         guard case .settingsFile(let paths) = self else { return }
         let unknownPaths = paths.filter { !CmuxSettingsFileStore.supportedSettingsJSONPaths.contains($0) }
+        let missingAnchorPaths = paths.filter { SettingsSearchIndex.anchorID(forSettingsPath: $0) == nil }
         precondition(
             unknownPaths.isEmpty,
             "Unknown settings.json path(s): \(unknownPaths.joined(separator: ", "))",
             file: file,
             line: line
         )
+        precondition(
+            missingAnchorPaths.isEmpty,
+            "Missing SettingsSearchIndex anchor(s) for path(s): \(missingAnchorPaths.joined(separator: ", "))",
+            file: file,
+            line: line
+        )
     }

Based on learnings: “don’t wire navigation/search with a raw anchor string alone… add/update the matching entry in SettingsSearchIndex… This prevents broken jump-to behavior by ensuring the navigation anchor and the search index stay consistent.”

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

In `@Sources/Settings/SettingsPickerRows.swift` around lines 13 - 27, The code
currently drops unmapped settings paths in searchAnchorIDs via compactMap,
silently losing anchors; change searchAnchorIDs to detect and fail fast when
SettingsSearchIndex.anchorID(forSettingsPath:) returns nil (e.g., map the paths
to optionals, collect any nil-mapped paths and call precondition/fatalError with
a clear message listing those paths), and update validate() (which now only
checks CmuxSettingsFileStore.supportedSettingsJSONPaths) to also assert that
every path has a corresponding SettingsSearchIndex.anchorID(forSettingsPath:)
entry so missing search-anchor mappings are caught at runtime; reference
searchAnchorIDs, SettingsSearchIndex.anchorID(forSettingsPath:), and validate()
to locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests_v2/test_settings_open_latency.py`:
- Line 22: Update SOCKET_PATH in tests_v2/test_settings_open_latency.py to avoid
the hardcoded "/tmp" fallback: first read os.environ.get("CMUX_SOCKET_PATH"),
and if unset, perform dynamic discovery of the cmux socket (e.g., call an
existing helper like get_cmux_socket_path() or implement a
discover_cmux_socket() that checks the platform-specific App Support socket
locations and returns the found path or raises an error); assign that discovered
path to SOCKET_PATH instead of defaulting to "/tmp/cmux-debug.sock" so tests use
the actual cmux socket when CMUX_SOCKET_PATH is not exported.

---

Duplicate comments:
In `@Sources/cmuxApp.swift`:
- Around line 7673-7688: The current code uses Task.detached inside
refreshDetectedImportBrowsers so cancelling detectedImportBrowserRefreshTask
does not cancel the expensive InstalledBrowserDetector.detectInstalledBrowsers()
scan; fix by making the scan cancellable: either run the scan in the same parent
Task (replace Task.detached with Task {
InstalledBrowserDetector.detectInstalledBrowsers() }) so it inherits
cancellation from detectedImportBrowserRefreshTask, or store the detached task
in a separate property (e.g., detectedImportBrowserScanTask) and call .cancel()
on it when starting a new refresh; ensure the code checks Task.isCancelled
appropriately and clears both detectedImportBrowserRefreshTask and the scan task
when finished, while still using detectedImportBrowserRefreshGeneration and
generation guard as currently implemented.
- Around line 8093-8105: The persisted selected section is still used for the
first detail render; pre-seed the pending navigation target before the initial
body evaluation by consuming
SettingsWindowPresenter.consumePendingNavigationTarget() and applying it to the
view's selection state (e.g., selectedSection or selectedSectionRaw) during view
initialization (or immediately when the view's state is created) instead of
waiting for .onAppear; then keep the existing navigate(to:postRequest:) call for
post-appearance behavior so the initial render uses the pending target
(references: SettingsView, selectedSection/selectedSectionRaw,
SettingsWindowPresenter.consumePendingNavigationTarget(),
navigate(to:postRequest:), .onAppear).

---

Nitpick comments:
In `@Sources/Settings/SettingsPickerRows.swift`:
- Around line 13-27: The code currently drops unmapped settings paths in
searchAnchorIDs via compactMap, silently losing anchors; change searchAnchorIDs
to detect and fail fast when SettingsSearchIndex.anchorID(forSettingsPath:)
returns nil (e.g., map the paths to optionals, collect any nil-mapped paths and
call precondition/fatalError with a clear message listing those paths), and
update validate() (which now only checks
CmuxSettingsFileStore.supportedSettingsJSONPaths) to also assert that every path
has a corresponding SettingsSearchIndex.anchorID(forSettingsPath:) entry so
missing search-anchor mappings are caught at runtime; reference searchAnchorIDs,
SettingsSearchIndex.anchorID(forSettingsPath:), and validate() to locate the
changes.
🪄 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: 3f9871ce-d929-4fb8-bbe8-6951aeaecc4d

📥 Commits

Reviewing files that changed from the base of the PR and between 98a9232 and 08d6b18.

📒 Files selected for processing (7)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/Settings/SettingsDebugSocketCommands.swift
  • Sources/Settings/SettingsPickerRows.swift
  • Sources/TerminalController.swift
  • Sources/cmuxApp.swift
  • tests_v2/cmux.py
  • tests_v2/test_settings_open_latency.py
✅ Files skipped from review due to trivial changes (2)
  • Sources/TerminalController.swift
  • GhosttyTabs.xcodeproj/project.pbxproj

Comment thread tests_v2/test_settings_open_latency.py Outdated
Profiling showed the first Settings presentation was dominated by main-thread SwiftUI construction of every settings pane plus pane-local side effects such as browser import detection, workspace color palette reloads, and shortcut recorder row initialization. The detail now renders only the selected top-level pane, applies pending Settings navigation before the first detail render, preserves dirty Browser allowlist drafts across section changes, and moves browser detection off the main actor.

Constraint: The fix must stay surgical and avoid sleeps, new dependencies, broad Settings refactors, or Swift file-length budget growth.

Rejected: Pre-warming every pane after launch | keeps the expensive work and risks moving the stall into startup.

Rejected: Off-main shell/disk scan cleanup alone | profiling showed the dominant cost was eager main-thread view construction.

Confidence: medium

Scope-risk: moderate

Directive: Keep Settings search/navigation selecting the target section before requesting an anchor scroll; hidden panes do not have live anchors.

Tested: ./scripts/reload.sh --tag issue-3384-settings-slow-open --launch succeeded and launched the tagged app.

Not-tested: Local tests by instruction; CI owns tests_v2/test_settings_open_latency.py.
@austinywang
austinywang force-pushed the issue-3384-settings-slow-open branch from 08d6b18 to 6e91fce Compare May 1, 2026 09:57
@austinywang

Copy link
Copy Markdown
Contributor Author

Updated after the relevant review comments:

  • Preserved Browser HTTP allowlist drafts while still syncing hidden external allowlist changes by tracking an explicit dirty bit.
  • Switched the Settings latency test from window-size detection to the DEBUG Settings window identifier/state command.
  • Removed the hardcoded /tmp socket fallback by using the tests_v2 cmux default socket discovery.
  • Added Settings-window cleanup for the latency test so retries do not inherit an already-open Settings window.
  • Tightened the Browser Import refresh task so repeated refreshes cancel the tracked off-main scan task.

I did not address unrelated SettingsPickerRows anchor-validation nitpicks because they are outside the Settings-open latency regression. The old docs-file thread is stale; no markdown file is staged or committed in the current branch.

@austinywang

Copy link
Copy Markdown
Contributor Author

CI is green on the refreshed head commit 6e91fce.

Relevant checks passed: tests, release-build, tests-build-and-lag, ui-regressions, workflow-guard-tests, web-typecheck, web-db-migrations, remote-daemon-tests, build-ghosttykit, both compat-tests lanes, Vercel, Socket, CodeRabbit, and Cursor Bugbot.

@austinywang

Copy link
Copy Markdown
Contributor Author

Closing — abandoning this branch. Issue #3384 remains open and will be retried.

@austinywang austinywang closed this May 1, 2026
@austinywang
austinywang deleted the issue-3384-settings-slow-open branch May 1, 2026 20:55

This branch was successfully deployed

1 active deployment
Preview – cmux — 6e91fcef Deployed May 1, 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.

Settings panel takes several seconds to open (cold and warm) — feels frozen

1 participant