From 9231bcb38fe4aeadbc4ce3cd1d52a79304d1695a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 21:53:07 -0700 Subject: [PATCH] fix(macos): keep permissions reachable on short screens (#112507) * fix(macos): keep permissions reachable on short screens * fix(macos): preserve onboarding i18n inventory --- .../OpenClaw/OnboardingView+Pages.swift | 60 +++++++++---------- .../OpenClaw/OnboardingView+Testing.swift | 2 +- .../OnboardingViewSmokeTests.swift | 27 +++++++++ 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 1b6a98746b66..719df0ed9f87 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -19,7 +19,7 @@ extension OnboardingView { case 4: self.memoryImportPage(contentHeight: contentHeight) case 5: - self.permissionsPage(contentHeight: contentHeight) + self.permissionsPage() case 9: self.readyPage() default: @@ -712,41 +712,41 @@ extension OnboardingView { .buttonStyle(.plain) } - func permissionsPage(contentHeight: CGFloat) -> some View { - // Fixed layout (no ScrollView): sorted by importance and sized so all - // permissions stay visible at once — no scrollbars during onboarding. - VStack(spacing: 12) { - HStack(spacing: 8) { - Text("Grant permissions") - .font(.largeTitle.weight(.semibold)) - if self.isRequesting { - ProgressView() - .controlSize(.small) + func permissionsPage() -> some View { + onboardingPage { + VStack(spacing: 12) { + // Keep intro and rows in one document so short windows can reveal every permission. + HStack(spacing: 8) { + Text("Grant permissions") + .font(.largeTitle.weight(.semibold)) + if self.isRequesting { + ProgressView() + .controlSize(.small) + } } - } - Text( - "These macOS permissions let OpenClaw automate apps and capture context on this Mac. " + - "Status updates automatically.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) + Text( + "These macOS permissions let OpenClaw automate apps and capture context on this Mac. " + + "Status updates automatically.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) - self.onboardingCard(spacing: 4, padding: 12) { - ForEach(Capability.importanceOrdered, id: \.self) { cap in - PermissionRow( - capability: cap, - status: self.permissionMonitor.status[cap] ?? .notGranted, - compact: true) - { - Task { await self.request(cap) } + self.onboardingCard(spacing: 4, padding: 12) { + ForEach(Capability.importanceOrdered, id: \.self) { cap in + PermissionRow( + capability: cap, + status: self.permissionMonitor.status[cap] ?? .notGranted, + compact: true) + { + Task { await self.request(cap) } + } } } } } - .padding(.horizontal, 28) - .frame(width: self.pageWidth, height: contentHeight, alignment: .top) + // The root onboarding layout keeps navigation outside this scrollable document. } func cliPage() -> some View { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index cd1797ed47da..c475998c3617 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -40,7 +40,7 @@ extension OnboardingView { _ = view.connectionPage() _ = view.aiSetupPage(contentHeight: contentHeight) _ = view.memoryImportPage(contentHeight: contentHeight) - _ = view.permissionsPage(contentHeight: contentHeight) + _ = view.permissionsPage() _ = view.cliPage() _ = view.readyPage() diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index e7d6bf62d5ae..67ea0397b7ec 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -73,6 +73,25 @@ struct OnboardingViewSmokeTests { #expect(short < preferred) } + @Test func `permissions page scrolls when the onboarding window is short`() throws { + let state = AppState(preview: true) + let view = OnboardingView(state: state) + let hosting = NSHostingView(rootView: view.permissionsPage()) + let contentHeight = OnboardingView.contentHeight( + for: OnboardingView.minimumWindowHeight, + usesCompactHero: false) + hosting.frame = NSRect( + x: 0, + y: 0, + width: OnboardingView.windowWidth, + height: contentHeight) + hosting.layoutSubtreeIfNeeded() + + let scrollView = try #require(Self.firstDescendant(of: NSScrollView.self, in: hosting)) + #expect(contentHeight == 303) + #expect(scrollView.documentView != nil) + } + @Test func `local page order includes memory import only while eligible`() { let configuredOrder = OnboardingView.pageOrder( for: .local, @@ -589,4 +608,12 @@ struct OnboardingViewSmokeTests { == [.appleScript, .accessibility, .screenRecording]) #expect(Capability.importanceOrdered.last == Capability.location) } + + private static func firstDescendant(of type: T.Type, in view: NSView) -> T? { + if let match = view as? T { return match } + for child in view.subviews { + if let match = self.firstDescendant(of: type, in: child) { return match } + } + return nil + } }