fix(macos): resolve the onboarding install prompt when the gateway is already running (#128273)

Fixes #128194.

Local-mode onboarding has two rightful actors on the CLI page: the page's
own install flow, which may open an install-target prompt on unreleased
builds, and the connection-mode commit, which starts the gateway through
ConnectionModeCoordinator the moment the user picks This Mac. When the
gateway comes up on its own (dev-root builds, or an externally attached
gateway), AI setup auto-connects and finish() runs — but close() was
silently ineffective because AppKit ignores NSWindow.close() while a sheet
is attached. The result, reproduced live with os_log instrumentation: a
completed onboarding (dashboard open, onboardingSeen set) with a zombie
onboarding window showing a dead CLI page and a stale channel-choice sheet.

Two coupled fixes. OnboardingController.close() now ends any attached sheet
before closing, so completion always tears the window down. And a running
local gateway now resolves a pending install prompt directly: a new tested
static (shouldResolveInstallPromptForRunningGateway) extends the existing
gateway-status revise path to the choosingTarget phase, marking the step
installed and dismissing the moot sheet; runCLIInstall returns quietly in
that case instead of writing a cancellation over a resolved step. Genuine
declines keep the cancelled status.
This commit is contained in:
Peter Steinberger
2026-08-23 09:46:16 -07:00
committed by GitHub
parent 7d154d32c1
commit a85db8cea7
3 changed files with 57 additions and 1 deletions
@@ -605,10 +605,17 @@ final class OnboardingController: NSObject, NSWindowDelegate {
func close() {
self.busyReason = nil
// AppKit ignores close while its modal sheet is still attached.
self.dismissAttachedSheet()
self.window?.close()
self.window = nil
}
func dismissAttachedSheet() {
guard let window, let sheet = window.attachedSheet else { return }
window.endSheet(sheet)
}
func setWindowCloseEnabled(_ enabled: Bool) {
self.window?.standardWindowButton(.closeButton)?.isEnabled = enabled
}
@@ -94,8 +94,24 @@ extension OnboardingView {
}
}
static func shouldResolveInstallPromptForRunningGateway(
gatewayStatus: GatewayProcessManager.Status,
isLocal: Bool,
phase: CLIInstallPhase) -> Bool
{
guard isLocal, phase == .choosingTarget else { return false }
return switch gatewayStatus {
case .running, .attachedExisting: true
case .stopped, .starting, .failed: false
}
}
func reviseCLIActivationFailureIfGatewayReady(_ status: GatewayProcessManager.Status) {
guard Self.shouldReviseCLIActivationFailure(
let resolvesInstallPrompt = Self.shouldResolveInstallPromptForRunningGateway(
gatewayStatus: status,
isLocal: state.connectionMode == .local,
phase: cliInstallPhase)
guard resolvesInstallPrompt || Self.shouldReviseCLIActivationFailure(
gatewayStatus: status,
isLocal: state.connectionMode == .local,
executableReady: cliExecutableReady,
@@ -104,6 +120,10 @@ extension OnboardingView {
cliInstalled = true
cliStatusKnown = true
cliStatus = nil
if resolvesInstallPrompt {
// A running local gateway already fulfills the pending install prompt.
OnboardingController.shared.dismissAttachedSheet()
}
}
/// LocalGatewayActivation.failed carries the reason bound to that specific activation
@@ -167,6 +187,8 @@ extension OnboardingView {
guard let target = await CLIInstallPrompter.shared.installTargetForCurrentBuild(
presentingSheetOn: OnboardingController.shared.sheetPresentationWindow)
else {
// Gateway readiness can resolve onboarding while this sheet is open.
guard !cliInstalled else { return }
cliStatus = "CLI installation cancelled."
return
}
@@ -255,6 +255,33 @@ struct OnboardingViewSmokeTests {
installed: false))
}
@Test func `running local gateway resolves only its pending CLI install prompt`() {
for status in [GatewayProcessManager.Status.running(details: nil), .attachedExisting(details: "pid 4242")] {
#expect(OnboardingView.shouldResolveInstallPromptForRunningGateway(
gatewayStatus: status,
isLocal: true,
phase: .choosingTarget))
}
for status in [GatewayProcessManager.Status.starting, .stopped, .failed("unavailable")] {
#expect(!OnboardingView.shouldResolveInstallPromptForRunningGateway(
gatewayStatus: status,
isLocal: true,
phase: .choosingTarget))
}
for mode in [AppState.ConnectionMode.remote, .unconfigured] {
#expect(!OnboardingView.shouldResolveInstallPromptForRunningGateway(
gatewayStatus: .running(details: nil),
isLocal: mode == .local,
phase: .choosingTarget))
}
for phase in [OnboardingView.CLIInstallPhase.idle, .installing, .startingService] {
#expect(!OnboardingView.shouldResolveInstallPromptForRunningGateway(
gatewayStatus: .running(details: nil),
isLocal: true,
phase: phase))
}
}
@Test func `gateway start failure message retains the concrete reason`() {
#expect(
OnboardingView.gatewayStartFailureMessage(