Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift
Peter Steinberger df72781ed4 fix(macos): make the whole AI candidate row clickable and clear stale exhausted verdicts (#121928)
* fix(macos): make the whole AI candidate row clickable and clear stale exhausted verdicts

Live-testing pick-during-testing on 2026-08-11 showed clicks on a candidate
row's blank stretch (between the title/subtitle texts or over the spacer)
silently doing nothing: plain-style buttons only hit-test opaque label
pixels. A user trying to pick Claude Code while Codex auto-tests can click
the visually highlighted row and get no outcome. .contentShape(Rectangle())
makes the full row hit-test.

Also, after auto-candidates exhaust, a user-picked retest left the stale
"None of the found options worked" card up while the new test visibly ran;
userSelect now clears exhaustedAutoCandidates when a fresh attempt begins.

* test(tooling): drop stateless poll tests from stateful-helper fixtures

Main went red when #121923 rewrote the outbound poll tests to be
order-independent and removed their stateful helper import/file; this
lane-config test hardcoded both as stateful-helper classification
fixtures. Folded into this PR per red-main landing policy.
2026-08-11 01:10:12 -07:00

924 lines
35 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import OpenClawProtocol
import SwiftUI
enum OnboardingProviderAuthLink {
static func safeURL(_ rawValue: String?) -> URL? {
guard let rawValue,
let url = URL(string: rawValue),
url.scheme?.lowercased() == "https",
url.host() != nil,
url.user() == nil,
url.password() == nil
else { return nil }
return url
}
static func displayHost(_ rawValue: String?) -> String? {
guard let host = self.safeURL(rawValue)?.host()?.lowercased() else { return nil }
return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
}
}
private struct OnboardingRecommendedInstallCard: View {
let install: OnboardingAISetupModel.RecommendedInstall
@State private var hovered = false
var body: some View {
Group {
if let website = OnboardingProviderAuthLink.safeURL(self.install.website) {
Link(destination: website) { self.content }
.buttonStyle(.plain)
} else {
self.content
}
}
.onHover { self.hovered = $0 }
.animation(.easeOut(duration: 0.12), value: self.hovered)
}
private var content: some View {
HStack(alignment: .top, spacing: 10) {
OnboardingProviderArtwork(
icon: self.install.icon,
brandCandidates: [self.install.brandId, self.install.id],
fallbackSymbol: "arrow.down.circle")
VStack(alignment: .leading, spacing: 2) {
Text(self.install.label)
.font(.callout.weight(.semibold))
Text(self.install.hint)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let host = OnboardingProviderAuthLink.displayHost(self.install.website) {
Text(host)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.padding(.top, 2)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(self.hovered ? 0.085 : 0.05)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1))
}
}
private struct OnboardingSurface: View {
let cornerRadius: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.045))
.overlay {
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
}
struct GatewayAuthCard: Equatable {
let title: String
let message: String
let primaryTitle: String
let secondaryTitle: String
}
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
var returnToGatewayAuthentication: () -> Void
var retryConfiguredGatewayProbe: () -> Void
@State private var openedProviderAuthURL: URL?
static func gatewayAuthCard(for issue: RemoteGatewayAuthIssue) -> GatewayAuthCard {
GatewayAuthCard(
title: "Gateway authentication required",
message: issue.statusMessage,
primaryTitle: "Back to Gateway",
secondaryTitle: "Try again")
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch self.model.phase {
case .idle, .detecting:
self.detectingView
case .ready, .testing:
self.resultsView
case .connected:
EmptyView()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.sheet(isPresented: Binding(
get: { self.model.activeAuthOption != nil },
set: {
if !$0 {
self.model.cancelProviderAuth()
}
})) {
self.providerAuthSheet
}
}
private var detectingView: some View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
VStack(alignment: .leading, spacing: 2) {
Text(self.model.waitingForPendingActivationDeadline
? "Waiting for the previous AI test to finish…"
: "Looking for AI you already use…")
.font(.callout.weight(.semibold))
Text(self.model.waitingForPendingActivationDeadline
? "OpenClaw will check again before changing any inference settings."
: "Checking CLI logins, saved API keys, and local model servers on the Gateway.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.vertical, 18)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var resultsView: some View {
if !self.model.candidates.isEmpty {
VStack(spacing: 8) {
ForEach(self.model.candidates) { candidate in
self.candidateRow(candidate)
}
}
} else if self.model.detectError == nil,
self.model.configuredGatewayAuthIssue == nil
{
// A failed detect must not claim "nothing found" — the error card
// below owns that state and the claim would be unproven.
self.noCandidatesIntro
}
if !self.model.unavailableCandidates.isEmpty {
self.unavailableCandidatesSection
}
if let authIssue = model.configuredGatewayAuthIssue {
let card = Self.gatewayAuthCard(for: authIssue)
OnboardingErrorCard(
title: card.title,
message: card.message,
docsSlug: "start/onboarding",
retryTitle: card.primaryTitle,
secondaryTitle: card.secondaryTitle,
secondary: self.retryConfiguredGatewayProbe,
retry: self.returnToGatewayAuthentication)
} else if let detectError = model.detectError {
OnboardingErrorCard(
title: self.model.configuredGatewayProbeUnavailable
? "Couldnt check this Gateway for AI accounts"
: "Couldnt check this Gateway for AI access",
message: detectError.summary,
details: detectError.detail,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
if self.model.configuredGatewayProbeUnavailable {
self.retryConfiguredGatewayProbe()
} else {
self.model.retryFromScratch()
}
}
}
if let providerCatalogError = model.providerCatalogError {
OnboardingErrorCard(
title: "Couldnt load the full provider list",
message: providerCatalogError,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
self.model.retryFromScratch()
}
}
if self.model.exhaustedAutoCandidates {
OnboardingErrorCard(
title: "None of the found options worked",
message: """
The details are listed on each option above. \
You can fix the login and retry, or connect with an API key or token below.
""",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
}
if self.model.providerCatalogLoaded {
self.providerPrepareSection
self.providerAuthSection
self.manualSection
}
}
private var noCandidatesIntro: some View {
VStack(alignment: .leading, spacing: 8) {
Text("No usable AI access found on this Gateway")
.font(.headline)
Text(self.model.recommendedInstalls.isEmpty
? "Connect a provider below with an API key or token, then check again."
: "Install one of these tools, then check again. You can also connect a provider below.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if !self.model.recommendedInstalls.isEmpty {
Text("Recommended installs")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 230), spacing: 10)],
spacing: 10)
{
ForEach(self.model.recommendedInstalls) { install in
OnboardingRecommendedInstallCard(install: install)
}
}
}
Button("Check again") {
self.model.retryFromScratch()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.vertical, 4)
}
private var unavailableCandidatesSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Detected, but not auto-tested")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(self.model.unavailableCandidates) { candidate in
HStack(alignment: .top, spacing: 8) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text("\(candidate.label)\(candidate.detail)")
.font(.caption.weight(.semibold))
Text(candidate.reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.padding(.vertical, 4)
}
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
let status = self.model.statuses[candidate.kind] ?? .untried
let selected = self.model.selectedKind == candidate.kind
let presentation = self.model.candidatePresentation[candidate.kind]
return VStack(alignment: .leading, spacing: 0) {
Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
OnboardingProviderArtwork(
icon: presentation?.icon,
brandCandidates: [presentation?.brandId, candidate.kind],
fallbackSymbol: Self.symbol(for: candidate.kind))
VStack(alignment: .leading, spacing: 2) {
Text(candidate.label)
.font(.callout.weight(.semibold))
Text(self.subtitle(for: candidate, status: status))
.font(.caption)
.foregroundStyle(self.subtitleStyle(for: status))
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
self.trailingIndicator(status: status, selected: selected)
}
// Plain buttons hit-test only opaque label pixels; without this the
// row's blank stretch (between texts, over the Spacer) ignores clicks
// and a mid-testing candidate pick silently does nothing.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!self.model.canSelectCandidate(kind: candidate.kind))
if case let .failed(failure) = status {
OnboardingErrorDetails(text: failure.copyText)
.padding(.leading, 38)
.padding(.top, 6)
}
}
.openClawSelectableRowChrome(selected: selected && !Self.isFailed(status))
}
private func subtitle(
for candidate: OnboardingAISetupModel.Candidate,
status: OnboardingAISetupModel.CandidateStatus) -> String
{
switch status {
case .testing:
"Testing — asking \(candidate.modelRef) for a quick reply…"
case let .failed(failure):
failure.summary
case .untried:
"\(candidate.modelRef) · \(candidate.detail)"
}
}
private func subtitleStyle(
for status: OnboardingAISetupModel.CandidateStatus) -> Color
{
if case .failed = status {
return .orange
}
return .secondary
}
@ViewBuilder
private func trailingIndicator(
status: OnboardingAISetupModel.CandidateStatus,
selected: Bool) -> some View
{
switch status {
case .testing:
ProgressView()
.controlSize(.small)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .untried:
SelectionStateIndicator(selected: selected)
}
}
private static func symbol(for kind: String) -> String {
switch kind {
case "claude-cli": "sparkle"
case "codex-cli": "chevron.left.forwardslash.chevron.right"
case "gemini-cli": "diamond"
case "existing-model": "checkmark.seal"
default: "key.fill"
}
}
private static func isFailed(_ status: OnboardingAISetupModel.CandidateStatus) -> Bool {
if case .failed = status {
return true
}
return false
}
@ViewBuilder
private var manualSection: some View {
if self.model.manualProviders.isEmpty {
if self.model.authOptions.isEmpty {
OnboardingErrorCard(
title: "No key-based providers are available",
message: "Enable or install a text-inference provider plugin on this Gateway, then check again.",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
}
} else if self.model.candidates.isEmpty || self.model.showManualEntry {
self.manualForm
}
}
@ViewBuilder
private var providerPrepareSection: some View {
if !self.model.prepareOptions.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Set up a local model")
.font(.headline)
Text("Connect a local model service, or prepare a model on this Gateway.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.model.prepareOptions) { option in
Button {
self.openedProviderAuthURL = nil
self.model.startProviderPrepare(option)
} label: {
HStack(spacing: 10) {
OnboardingProviderArtwork(
icon: option.icon,
brandCandidates: [option.brandId, option.id],
fallbackSymbol: "arrow.down.circle")
VStack(alignment: .leading, spacing: 2) {
Text(option.label)
.font(.callout.weight(.semibold))
if let hint = option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
Text(option.actionLabel ?? String(localized: "Connect / Set up"))
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy)
.openClawSelectableRowChrome(selected: false)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(OnboardingSurface(cornerRadius: 12))
}
}
@ViewBuilder
private var providerAuthSection: some View {
if !self.model.authOptions.isEmpty || !self.model.manualProviders.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Sign in with a provider")
.font(.headline)
Text(
"Use an existing subscription or provider account. " +
"OpenClaw opens the providers own sign-in flow, then verifies it with a real reply.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
let featured = self.model.authOptions.filter(\.featured)
let more = self.model.authOptions.filter { !$0.featured }
ForEach(featured) { option in
self.providerAuthRow(option)
}
if !self.model.manualProviders.isEmpty {
self.apiKeysRow
}
if !more.isEmpty {
DisclosureGroup("More sign-in options") {
VStack(spacing: 8) {
ForEach(more) { option in
self.providerAuthRow(option)
}
}
.padding(.top, 6)
}
.font(.callout)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(OnboardingSurface(cornerRadius: 12))
}
}
private var apiKeysRow: some View {
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.model.showManualEntry = true
}
} label: {
HStack(spacing: 10) {
OnboardingProviderArtwork(
icon: nil,
brandCandidates: [],
fallbackSymbol: "key.fill")
VStack(alignment: .leading, spacing: 2) {
Text("API Keys")
.font(.callout.weight(.semibold))
Text("Connect with an API key or token")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Text("Connect")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy)
.openClawSelectableRowChrome(selected: self.model.showManualEntry)
}
private func providerAuthRow(_ option: OnboardingAISetupModel.AuthOption) -> some View {
Button {
self.openedProviderAuthURL = nil
self.model.startProviderAuth(option)
} label: {
HStack(spacing: 10) {
OnboardingProviderArtwork(
icon: option.icon,
brandCandidates: [option.brandId, option.id],
fallbackSymbol: option.kind == "device-code"
? "link.badge.plus"
: "person.crop.circle.badge.checkmark")
VStack(alignment: .leading, spacing: 2) {
Text(option.label)
.font(.callout.weight(.semibold))
if let hint = option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
Text(option.kind == "device-code" ? "Pair" : "Sign in")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy)
.openClawSelectableRowChrome(selected: false)
}
private var providerAuthSheet: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.model.activeAuthOption?.label ?? "Provider setup")
.font(.title3.weight(.semibold))
Text(self.model.isPreparingModel
? "OpenClaw will detect and verify the prepared model before using it."
: "Credentials stay on this Gateway and are saved only after the live test succeeds.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if let step = self.model.authStep {
let deviceCode = parseWizardDeviceCode(step.devicecode)
if deviceCode == nil,
let title = step.title,
!title.isEmpty,
title != self.model.activeAuthOption?.label
{
Text(title).font(.headline)
}
if let deviceCode {
self.deviceCodeStep(deviceCode)
} else if let message = step.message, !message.isEmpty {
ScrollView {
Text(message)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 190)
}
if deviceCode == nil,
let url = OnboardingProviderAuthLink.safeURL(step.externalurl)
{
Link("Open sign-in page…", destination: url)
.font(.caption.weight(.semibold))
}
self.authStepInput(step)
} else if self.model.authBusy {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(self.model.isPreparingModel
? "Starting local model setup…"
: "Starting secure sign-in…")
}
}
if let error = self.model.authError {
OnboardingErrorCard(
title: self.model.isPreparingModel
? "Model setup didnt complete"
: "Sign-in didnt complete",
message: error.summary,
details: error.detail,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
}
Spacer(minLength: 0)
HStack {
Button("Cancel") { self.model.cancelProviderAuth() }
Spacer(minLength: 0)
if self.model.authStep != nil {
Button(self.authContinueTitle) { self.model.continueProviderAuth() }
.buttonStyle(.borderedProminent)
.disabled(self.model.authBusy)
}
}
}
.padding(22)
.frame(width: 560)
.frame(minHeight: 330)
.onAppear {
self.openProviderAuthURLIfNeeded(self.model.authStep?.externalurl)
}
.onChange(of: self.model.authStep?.externalurl) { _, rawURL in
self.openProviderAuthURLIfNeeded(rawURL)
}
}
private func deviceCodeStep(_ deviceCode: WizardDeviceCodePresentation) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Finish in your browser")
.font(.headline)
Text(deviceCode.message ?? "Enter this one-time code on the provider's sign-in page.")
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Text(deviceCode.code)
.font(.system(.title2, design: .monospaced).weight(.semibold))
.textSelection(.enabled)
Spacer(minLength: 8)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(deviceCode.code, forType: .string)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(OnboardingSurface(cornerRadius: 10))
HStack(spacing: 12) {
if let minutes = deviceCode.expiresInMinutes {
Label("Expires in \(minutes) minutes", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if let url = OnboardingProviderAuthLink.safeURL(self.model.authStep?.externalurl) {
Link(destination: url) {
Label("Open sign-in page", systemImage: "arrow.up.right.square")
}
.font(.caption.weight(.semibold))
}
}
}
}
private func openProviderAuthURLIfNeeded(_ rawURL: String?) {
guard let url = OnboardingProviderAuthLink.safeURL(rawURL),
url != openedProviderAuthURL
else { return }
self.openedProviderAuthURL = url
NSWorkspace.shared.open(url)
}
@ViewBuilder
private func authStepInput(_ step: WizardStep) -> some View {
switch wizardStepType(step) {
case "text":
if step.sensitive == true {
SecureField(step.placeholder ?? "Value", text: self.$model.authText)
.textFieldStyle(.roundedBorder)
} else {
TextField(step.placeholder ?? "Value", text: self.$model.authText)
.textFieldStyle(.roundedBorder)
}
case "select":
Picker("Option", selection: self.$model.authSelection) {
ForEach(Array(self.model.authWizardOptions.enumerated()), id: \.offset) { index, option in
Text(option.label).tag(index)
}
}
case "confirm":
Toggle("Confirm", isOn: self.$model.authConfirmation)
default:
EmptyView()
}
}
private var authContinueTitle: String {
guard let step = model.authStep else { return "Continue" }
if parseWizardDeviceCode(step.devicecode) != nil {
return String(localized: "I've signed in")
}
return wizardStepType(step) == "note" ? "Continue" : "Submit"
}
private var manualForm: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Connect with an API key or token")
.font(.headline)
HStack(spacing: 8) {
if let provider = self.model.selectedManualProvider {
OnboardingProviderArtwork(
icon: provider.icon,
brandCandidates: [provider.brandId, provider.id],
fallbackSymbol: "key.fill")
}
Picker("Provider", selection: self.$model.manualProviderID) {
ForEach(self.model.manualProviders) { provider in
Text(provider.label).tag(provider.id)
}
}
.labelsHidden()
.frame(width: 230)
SecureField("API key or token", text: self.$model.manualKey)
.textFieldStyle(.roundedBorder)
.onSubmit { self.model.submitManualKey() }
Button {
self.model.submitManualKey()
} label: {
if self.model.manualTesting {
ProgressView()
.controlSize(.small)
.frame(minWidth: 74)
} else {
Text("Connect")
.frame(minWidth: 74)
}
}
.buttonStyle(.borderedProminent)
// isBusy, not just manualTesting: submitManualKey drops the tap
// while another test runs, so an enabled button would be a
// silent no-op.
.disabled(self.model.isBusy ||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(self.manualProviderHelp)
.font(.caption)
.foregroundStyle(.secondary)
if let manualError = model.manualError {
OnboardingErrorCard(
title: "That key didnt work",
message: manualError.summary,
details: manualError.detail,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(OnboardingSurface(cornerRadius: 12))
}
private var manualProviderHelp: String {
let hint = self.model.selectedManualProvider?.hint?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let hint, !hint.isEmpty else {
return "Paste the key or token here, and OpenClaw checks it with a real test question."
}
return "\(hint). Paste it here, and OpenClaw checks it with a real test question."
}
}
/// Friendly error presentation with a consistent docs escape hatch.
/// Every onboarding failure points at a docs.openclaw.ai page so people are
/// never stuck staring at a raw error string.
struct OnboardingErrorCard: View {
let title: String
let message: String
var details: String?
let docsSlug: String
var retryTitle: String?
var retry: (() -> Void)?
var secondaryTitle: String?
var secondary: (() -> Void)?
init(
title: String,
message: String,
details: String? = nil,
docsSlug: String,
retryTitle: String? = nil,
secondaryTitle: String? = nil,
secondary: (() -> Void)? = nil,
retry: (() -> Void)? = nil)
{
self.title = title
self.message = message
self.details = details
self.docsSlug = docsSlug
self.retryTitle = retryTitle
self.retry = retry
self.secondaryTitle = secondaryTitle
self.secondary = secondary
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.callout.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if let details {
OnboardingErrorDetails(text: details)
}
HStack(spacing: 14) {
if let retryTitle, let retry {
Button(retryTitle, action: retry)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
if let secondaryTitle, let secondary {
Button(secondaryTitle, action: secondary)
.buttonStyle(.bordered)
.controlSize(.small)
}
Button("Open help…") {
if let url = URL(string: "https://docs.openclaw.ai/\(docsSlug)") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.caption)
if details == nil {
Button("Copy error") {
OnboardingErrorDetails.copy(self.message)
}
.buttonStyle(.link)
.font(.caption)
}
}
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.orange.opacity(0.10)))
}
}
private struct OnboardingErrorDetails: View {
let text: String
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
self.expanded.toggle()
}
} label: {
Label(
self.expanded ? "Hide details" : "Show details",
systemImage: self.expanded ? "chevron.down" : "chevron.right")
}
.buttonStyle(.link)
.font(.caption)
if self.expanded {
ScrollView(.vertical) {
Text(self.text)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.05)))
Button {
Self.copy(self.text)
} label: {
Label("Copy error", systemImage: "doc.on.doc")
}
.buttonStyle(.link)
.font(.caption)
}
}
}
static func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}