fix(macos): prevent clipped and stalled onboarding (#107598)

* fix(macos): harden onboarding on short screens

* chore: keep release notes in PR body

* chore(i18n): refresh macOS onboarding inventory
This commit is contained in:
Peter Steinberger
2026-07-14 10:07:41 -07:00
committed by GitHub
parent 9daef57c85
commit 2bbf5e6ca0
7 changed files with 82 additions and 18 deletions

View File

@@ -31195,7 +31195,7 @@
},
{
"kind": "conditional-branch",
"line": 688,
"line": 704,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -31203,7 +31203,7 @@
},
{
"kind": "conditional-branch",
"line": 688,
"line": 704,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",

View File

@@ -514,22 +514,38 @@ final class OnboardingController: NSObject, NSWindowDelegate {
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = UIStrings.welcomeTitle
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
window.styleMask = Self.windowStyleMask
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
if let visibleFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame {
// Constrain the full window frame, not only its content. Otherwise the
// navigation bar can land below the Dock on shorter displays.
window.setFrame(Self.initialWindowFrame(visibleFrame: visibleFrame), display: false)
} else {
window.center()
}
// Keep the focused dialog width while letting taller displays give setup more breathing room.
window.contentMinSize = NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)
window.contentMinSize = NSSize(
width: OnboardingView.windowWidth,
height: OnboardingView.minimumWindowHeight)
window.contentMaxSize = NSSize(width: OnboardingView.windowWidth, height: .greatestFiniteMagnitude)
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.delegate = self
window.center()
DockIconManager.shared.temporarilyShowDock()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
}
static func initialWindowFrame(visibleFrame: NSRect) -> NSRect {
let contentRect = NSRect(
origin: .zero,
size: NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
let preferredFrame = NSWindow.frameRect(forContentRect: contentRect, styleMask: self.windowStyleMask)
return WindowPlacement.centeredFrame(size: preferredFrame.size, in: visibleFrame)
}
func close() {
self.busyReason = nil
self.window?.close()
@@ -607,6 +623,7 @@ struct OnboardingView: View {
static let windowWidth: CGFloat = 630
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
static let minimumWindowHeight: CGFloat = 520
let pageWidth: CGFloat = Self.windowWidth
let connectionPageIndex = 1
@@ -625,16 +642,15 @@ struct OnboardingView: View {
130
}
/// The baseline fits every setup control. Taller windows donate all extra room
/// to the active page instead of leaving the content pinned to a fixed canvas.
/// The active page is scrollable on short screens. Taller windows donate all
/// extra room instead of leaving the content pinned to a fixed canvas.
func contentHeight(for windowHeight: CGFloat) -> CGFloat {
Self.contentHeight(for: windowHeight, usesCompactHero: self.usesCompactHero)
}
static func contentHeight(for windowHeight: CGFloat, usesCompactHero: Bool) -> CGFloat {
let availableHeight = max(Self.windowHeight, windowHeight)
let heroHeight: CGFloat = usesCompactHero ? 78 : 145
return availableHeight - heroHeight - 72
return max(0, windowHeight - heroHeight - 72)
}
static func pageOrder(

View File

@@ -42,7 +42,7 @@ extension OnboardingView {
.frame(
minWidth: pageWidth,
maxWidth: pageWidth,
minHeight: Self.windowHeight,
minHeight: Self.minimumWindowHeight,
maxHeight: .infinity)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {

View File

@@ -54,6 +54,25 @@ struct OnboardingViewSmokeTests {
#expect(taller - baseline == 200)
}
@Test func `onboarding window fits within a short visible screen`() {
let visibleFrame = NSRect(x: 0, y: 78, width: 1600, height: 626)
let frame = OnboardingController.initialWindowFrame(visibleFrame: visibleFrame)
#expect(frame.height == visibleFrame.height)
#expect(frame.minY == visibleFrame.minY)
#expect(frame.maxY == visibleFrame.maxY)
}
@Test func `short onboarding window keeps a usable scrollable page`() {
let short = OnboardingView.contentHeight(for: 626, usesCompactHero: false)
let preferred = OnboardingView.contentHeight(
for: OnboardingView.windowHeight,
usesCompactHero: false)
#expect(short == 409)
#expect(short < preferred)
}
@Test func `page order delegates setup after inference to Crestodian`() {
let order = OnboardingView.pageOrder(
for: .local,

View File

@@ -211,8 +211,8 @@ by default, plus git-checkout installs under the same prefix flow.
</Step>
<Step title="Refresh loaded gateway service">
If a gateway service is already loaded from that same prefix, the script runs
`openclaw gateway install --force`, then `openclaw gateway restart`, and
probes gateway health best-effort.
`openclaw gateway install --force`, which activates the replacement service,
and then probes gateway health best-effort.
</Step>
</Steps>

View File

@@ -1274,12 +1274,8 @@ refresh_gateway_service_if_loaded() {
return 0
fi
if ! "$claw" gateway restart >/dev/null 2>&1; then
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}'
log "Warning: gateway service restart failed; continuing. Run: openclaw gateway restart"
return 0
fi
# `gateway install --force` activates the replacement service. A second
# restart can kill startup migrations and strand their lock until expiry.
"$claw" gateway status --probe --json >/dev/null 2>&1 || true
emit_json '{"event":"step","name":"gateway-service","status":"ok"}'
}

View File

@@ -164,6 +164,39 @@ describe("install-cli.sh", () => {
expect(result.stdout + result.stderr).not.toContain("unbound variable");
});
it("does not restart a gateway again after force-install activates it", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-gateway-refresh-"));
const prefix = join(tmp, "prefix");
const bin = join(prefix, "bin");
const commandLog = join(tmp, "commands.log");
const openclaw = join(bin, "openclaw");
mkdirSync(bin, { recursive: true });
writeFileSync(openclaw, '#!/bin/bash\nprintf "%s\\n" "$*" >> "$COMMAND_LOG"\n');
chmodSync(openclaw, 0o755);
try {
const result = runInstallCliShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`PREFIX=${JSON.stringify(prefix)}`,
"is_gateway_daemon_loaded() { return 0; }",
"refresh_gateway_service_if_loaded",
].join("\n"),
{ COMMAND_LOG: commandLog },
);
expect(result.status).toBe(0);
expect(readFileSync(commandLog, "utf8").trim().split("\n")).toEqual([
"gateway install --force",
"gateway status --probe --json",
]);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("keeps HOME for default prefix while OPENCLAW_HOME controls git checkout paths", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-home-"));
const osHome = join(tmp, "os-home");