diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5a9304050e0b..9b72511f992f 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -32243,7 +32243,7 @@ }, { "kind": "ui-localized-call", - "line": 10, + "line": 11, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Wake up, my friend!", "surface": "apple", @@ -32251,7 +32251,7 @@ }, { "kind": "conditional-branch", - "line": 129, + "line": 229, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "", "surface": "apple", @@ -32259,7 +32259,7 @@ }, { "kind": "conditional-branch", - "line": 228, + "line": 288, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "OpenClaw was interrupted. Restart to try again.", "surface": "apple", @@ -32267,7 +32267,7 @@ }, { "kind": "conditional-branch", - "line": 229, + "line": 289, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "The Gateway connection changed. Restart OpenClaw to reconnect.", "surface": "apple", @@ -32275,7 +32275,7 @@ }, { "kind": "ui-call", - "line": 253, + "line": 331, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "OpenClaw is working…", "surface": "apple", @@ -32283,7 +32283,7 @@ }, { "kind": "ui-call", - "line": 278, + "line": 356, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Restart", "surface": "apple", @@ -32291,7 +32291,7 @@ }, { "kind": "ui-call", - "line": 289, + "line": 367, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Enter secret…", "surface": "apple", @@ -32299,12 +32299,28 @@ }, { "kind": "ui-call", - "line": 291, + "line": 369, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Reply to OpenClaw… (yes sets everything up)", "surface": "apple", "id": "native.apple.35a542956f4f5497" }, + { + "kind": "ui-call", + "line": 409, + "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", + "source": "Skip for now", + "surface": "apple", + "id": "native.apple.134b9ae1d8a543df" + }, + { + "kind": "ui-call", + "line": 444, + "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", + "source": "Recommended", + "surface": "apple", + "id": "native.apple.5435391fa338f227" + }, { "kind": "ui-call", "line": 10, diff --git a/apps/.i18n/native/ar.json b/apps/.i18n/native/ar.json index 2698e247d00c..9c72e30e5461 100644 --- a/apps/.i18n/native/ar.json +++ b/apps/.i18n/native/ar.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "رد على OpenClaw… (تؤدي الإجابة بنعم إلى إعداد كل شيء)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "تخطي الآن" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "موصى به" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/de.json b/apps/.i18n/native/de.json index 5f93ae8c189c..6936c4979220 100644 --- a/apps/.i18n/native/de.json +++ b/apps/.i18n/native/de.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "OpenClaw antworten… („yes“ richtet alles ein)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Vorerst überspringen" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Empfohlen" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/es.json b/apps/.i18n/native/es.json index 3fc69f9e3980..6aa37f60f729 100644 --- a/apps/.i18n/native/es.json +++ b/apps/.i18n/native/es.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Responde a OpenClaw… («sí» lo configura todo)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Omitir por ahora" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Recomendado" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/fa.json b/apps/.i18n/native/fa.json index 0d73f0b9ab27..216e78307942 100644 --- a/apps/.i18n/native/fa.json +++ b/apps/.i18n/native/fa.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "به OpenClaw پاسخ دهید… (با «بله» همه‌چیز راه‌اندازی می‌شود)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "فعلاً رد شود" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "پیشنهادشده" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/fr.json b/apps/.i18n/native/fr.json index 578fd210a50f..9afa88f674fc 100644 --- a/apps/.i18n/native/fr.json +++ b/apps/.i18n/native/fr.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Répondre à OpenClaw… (« yes » configure tout)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Ignorer pour l’instant" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Recommandé" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/hi.json b/apps/.i18n/native/hi.json index 9d0e2e93564b..1756e77d771f 100644 --- a/apps/.i18n/native/hi.json +++ b/apps/.i18n/native/hi.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "OpenClaw को जवाब दें… (हाँ कहने पर सब कुछ सेट हो जाएगा)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "अभी के लिए छोड़ें" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "अनुशंसित" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/id.json b/apps/.i18n/native/id.json index 6d8c5fae09db..8bd4df8cd413 100644 --- a/apps/.i18n/native/id.json +++ b/apps/.i18n/native/id.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Balas OpenClaw… (yes menyiapkan semuanya)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Lewati untuk sekarang" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Direkomendasikan" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/it.json b/apps/.i18n/native/it.json index 395dff367e6e..e91655656957 100644 --- a/apps/.i18n/native/it.json +++ b/apps/.i18n/native/it.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Rispondi a OpenClaw… (sì configura tutto)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Salta per ora" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Consigliato" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/ja-JP.json b/apps/.i18n/native/ja-JP.json index 0f711b64b8c0..b9e31674c2c4 100644 --- a/apps/.i18n/native/ja-JP.json +++ b/apps/.i18n/native/ja-JP.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "OpenClawに返信…(yesですべてセットアップされます)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "今はスキップ" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "おすすめ" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/ko.json b/apps/.i18n/native/ko.json index e3aea0e9edbe..652b5c3e5504 100644 --- a/apps/.i18n/native/ko.json +++ b/apps/.i18n/native/ko.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "OpenClaw에 답장… (yes를 입력하면 모든 설정이 완료됩니다)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "지금은 건너뛰기" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "권장" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/nl.json b/apps/.i18n/native/nl.json index ebeacd69de11..ac55f7b12f35 100644 --- a/apps/.i18n/native/nl.json +++ b/apps/.i18n/native/nl.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Antwoord aan OpenClaw… (met ja wordt alles ingesteld)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Voor nu overslaan" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Aanbevolen" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/pl.json b/apps/.i18n/native/pl.json index f8c52040a94d..fc9772e9b762 100644 --- a/apps/.i18n/native/pl.json +++ b/apps/.i18n/native/pl.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Odpowiedz OpenClaw… („yes” skonfiguruje wszystko)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Pomiń na razie" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Zalecane" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/pt-BR.json b/apps/.i18n/native/pt-BR.json index 19157fc44a0a..4f46705ec541 100644 --- a/apps/.i18n/native/pt-BR.json +++ b/apps/.i18n/native/pt-BR.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Responda ao OpenClaw… (sim configura tudo)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Pular por enquanto" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Recomendado" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/ru.json b/apps/.i18n/native/ru.json index b85d5701a5cf..93b72c61f39b 100644 --- a/apps/.i18n/native/ru.json +++ b/apps/.i18n/native/ru.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Ответьте OpenClaw… (yes настроит всё)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Пропустить пока" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Рекомендуется" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/sv.json b/apps/.i18n/native/sv.json index 82875ddfbac2..055980b96506 100644 --- a/apps/.i18n/native/sv.json +++ b/apps/.i18n/native/sv.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Svara OpenClaw… (ja konfigurerar allt)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Hoppa över tills vidare" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Rekommenderas" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/th.json b/apps/.i18n/native/th.json index 5bf285935c34..f04d133b26b3 100644 --- a/apps/.i18n/native/th.json +++ b/apps/.i18n/native/th.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "ตอบกลับ OpenClaw… (พิมพ์ yes เพื่อตั้งค่าทุกอย่าง)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "ข้ามไปก่อน" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "แนะนำ" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/tr.json b/apps/.i18n/native/tr.json index a34847d48844..3acba5e8713f 100644 --- a/apps/.i18n/native/tr.json +++ b/apps/.i18n/native/tr.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "OpenClaw'a yanıt verin… (evet, her şeyi ayarlar)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Şimdilik atla" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Önerilen" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/uk.json b/apps/.i18n/native/uk.json index bbdbab70e4d0..afc99d27cdd1 100644 --- a/apps/.i18n/native/uk.json +++ b/apps/.i18n/native/uk.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Відповісти OpenClaw… (так налаштує все)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Пропустити зараз" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Рекомендовано" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/vi.json b/apps/.i18n/native/vi.json index 1e4200348a28..9652f177c891 100644 --- a/apps/.i18n/native/vi.json +++ b/apps/.i18n/native/vi.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "Trả lời OpenClaw… (chọn có để thiết lập mọi thứ)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "Bỏ qua lúc này" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "Được đề xuất" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/zh-CN.json b/apps/.i18n/native/zh-CN.json index 59c85e463260..02ae28184cca 100644 --- a/apps/.i18n/native/zh-CN.json +++ b/apps/.i18n/native/zh-CN.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "回复 OpenClaw…(回复 yes 即可完成所有设置)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "暂时跳过" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "推荐" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/.i18n/native/zh-TW.json b/apps/.i18n/native/zh-TW.json index 5456d8f418ed..7099064c50ca 100644 --- a/apps/.i18n/native/zh-TW.json +++ b/apps/.i18n/native/zh-TW.json @@ -20193,6 +20193,16 @@ "source": "Reply to OpenClaw… (yes sets everything up)", "translated": "回覆 OpenClaw…(輸入 yes 即可完成所有設定)" }, + { + "id": "native.apple.134b9ae1d8a543df", + "source": "Skip for now", + "translated": "暫時略過" + }, + { + "id": "native.apple.5435391fa338f227", + "source": "Recommended", + "translated": "建議" + }, { "id": "native.apple.78d1ad73762a7ce8", "source": "Connect your AI", diff --git a/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift b/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift index da3412de1703..66dff3025603 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import OpenClawKit import SwiftUI enum SystemAgentDraft: String, Decodable { @@ -60,12 +61,21 @@ final class SystemAgentOnboardingChatModel { let id = UUID() let role: Role let text: String + let question: SystemAgentChatQuestion? + + init(role: Role, text: String, question: SystemAgentChatQuestion? = nil) { + self.role = role + self.text = text + self.question = question + } } private(set) var messages: [Message] = [] private(set) var isSending = false private(set) var errorMessage: String? private(set) var expectsSensitiveReply = false + private(set) var dismissedQuestionMessageIDs: Set = [] + private(set) var retiredQuestionMessageIDs: Set = [] var input = "" /// Set when OpenClaw hands off to the normal agent ("talk to agent"). var onAgentHandoff: ((SystemAgentDraft?) -> Void)? @@ -100,6 +110,7 @@ final class SystemAgentOnboardingChatModel { let action: String let sensitive: Bool? let agentDraft: SystemAgentDraft? + let question: AnyCodable? } func startIfNeeded() async { @@ -118,23 +129,42 @@ final class SystemAgentOnboardingChatModel { @discardableResult func send() -> Task? { let text = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - guard let generation = self.requestGeneration, - !text.isEmpty, - !self.isSending, - self.errorMessage == nil + return self.send(message: text) + } + + @discardableResult + func answerQuestion(messageID: UUID, optionLabel: String) -> Task? { + guard let message = self.messages.first(where: { $0.id == messageID }), + let question = message.question, + let option = question.options.first(where: { $0.label == optionLabel }), + self.canAnswerQuestion(message) else { return nil } - self.input = "" - self.messages.append(Message( - role: .user, - text: self.expectsSensitiveReply ? "" : text)) - let task = Task { [weak self] in - guard let self else { return } - await self.requestReply(message: text, generation: generation) - } - self.requestTask = task + // The typed Gateway contract separates the visible label from its canonical reply. + // Keep the transcript human-readable while returning the machine-facing value. + return self.send(message: option.reply ?? option.label, displayText: option.label) + } + + @discardableResult + func skipQuestion(messageID: UUID) -> Task? { + guard let message = self.messages.first(where: { $0.id == messageID }), + self.canAnswerQuestion(message), + let task = self.send(message: "Skip for now", displayText: "Skip for now") + else { return nil } + self.dismissedQuestionMessageIDs.insert(messageID) return task } + func isQuestionVisible(_ message: Message) -> Bool { + message.question != nil && !self.dismissedQuestionMessageIDs.contains(message.id) + } + + func canAnswerQuestion(_ message: Message) -> Bool { + self.isQuestionVisible(message) && + !self.retiredQuestionMessageIDs.contains(message.id) && + !self.isSending && + self.errorMessage == nil + } + @discardableResult func restartAfterError() -> Task? { guard let previousGeneration = self.requestGeneration else { return nil } @@ -145,6 +175,8 @@ final class SystemAgentOnboardingChatModel { self.route = nil self.started = true self.messages.removeAll() + self.dismissedQuestionMessageIDs.removeAll() + self.retiredQuestionMessageIDs.removeAll() self.input = "" self.expectsSensitiveReply = false let task = Task { [weak self] in @@ -184,6 +216,31 @@ final class SystemAgentOnboardingChatModel { return route } + private func send(message: String, displayText: String? = nil) -> Task? { + guard let generation = self.requestGeneration, + !message.isEmpty, + !self.isSending, + self.errorMessage == nil + else { return nil } + self.retireQuestions() + self.input = "" + self.messages.append(Message( + role: .user, + text: displayText ?? (self.expectsSensitiveReply ? "" : message))) + let task = Task { [weak self] in + guard let self else { return } + await self.requestReply(message: message, generation: generation) + } + self.requestTask = task + return task + } + + private func retireQuestions() { + for message in self.messages where message.question != nil { + self.retiredQuestionMessageIDs.insert(message.id) + } + } + private func requestReply(message: String?, generation: UInt64) async { guard self.isCurrentRequest(generation) else { return } self.isSending = true @@ -215,7 +272,10 @@ final class SystemAgentOnboardingChatModel { let result = try JSONDecoder().decode(ChatResult.self, from: data) guard self.isCurrentRequest(generation) else { return } self.expectsSensitiveReply = result.sensitive == true - self.messages.append(Message(role: .assistant, text: result.reply)) + self.messages.append(Message( + role: .assistant, + text: result.reply, + question: SystemAgentChatQuestion.parse(result.question?.dictionaryValue))) self.onReplyReceived?() if result.action == "open-agent" { self.onAgentHandoff?(result.agentDraft) @@ -243,8 +303,26 @@ struct SystemAgentOnboardingChatView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(self.model.messages) { message in - SystemAgentChatBubble(message: message) - .id(message.id) + VStack(alignment: .leading, spacing: 8) { + SystemAgentChatBubble(message: message) + if let question = message.question, + self.model.isQuestionVisible(message) + { + SystemAgentChatQuestionCard( + question: question, + isEnabled: self.model.canAnswerQuestion(message), + onSelect: { option in + self.model.answerQuestion( + messageID: message.id, + optionLabel: option.label) + }, + onSkip: { + self.model.skipQuestion(messageID: message.id) + }) + .padding(.trailing, 40) + } + } + .id(message.id) } if self.model.isSending { HStack(spacing: 8) { @@ -312,6 +390,82 @@ struct SystemAgentOnboardingChatView: View { } } +private struct SystemAgentChatQuestionCard: View { + let question: SystemAgentChatQuestion + let isEnabled: Bool + let onSelect: (SystemAgentChatQuestion.Option) -> Void + let onSkip: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(self.question.header.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.accentColor) + Text(self.question.question) + .font(.callout.weight(.semibold)) + ForEach(self.question.options, id: \.label) { option in + self.optionButton(option) + } + Button("Skip for now", action: self.onSkip) + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + .disabled(!self.isEnabled) + } + .padding(12) + .frame(maxWidth: 460, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.secondary.opacity(0.2))) + .accessibilityElement(children: .contain) + .accessibilityLabel(self.question.question) + } + + private func optionButton(_ option: SystemAgentChatQuestion.Option) -> some View { + Button { + self.onSelect(option) + } label: { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + if let description = option.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + if option.recommended { + Text("Recommended") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(option.recommended + ? Color.accentColor.opacity(0.12) + : Color(NSColor.windowBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(option.recommended + ? Color.accentColor.opacity(0.55) + : Color.secondary.opacity(0.16))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!self.isEnabled) + } +} + private struct SystemAgentChatBubble: View { let message: SystemAgentOnboardingChatModel.Message diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingSystemAgentChatTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingSystemAgentChatTests.swift index bebffb435ea8..25dcc50a34ec 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingSystemAgentChatTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingSystemAgentChatTests.swift @@ -27,6 +27,18 @@ private actor SystemAgentSessionRecorder { } } +private actor SystemAgentMessageRecorder { + private var messages: [String] = [] + + func record(_ message: String) { + self.messages.append(message) + } + + func snapshot() -> [String] { + self.messages + } +} + private actor SystemAgentMethodRecorder { private var methods: [String] = [] @@ -88,6 +100,20 @@ private func systemAgentRequestMethod(from message: URLSessionWebSocketTask.Mess return object["method"] as? String } +private func systemAgentChatMessage(from message: URLSessionWebSocketTask.Message) -> String? { + let data: Data? = switch message { + case let .data(data): data + case let .string(string): string.data(using: .utf8) + @unknown default: nil + } + guard let data, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["method"] as? String == "openclaw.chat", + let params = object["params"] as? [String: Any] + else { return nil } + return params["message"] as? String +} + private func respondToSystemAgentHealth( task: GatewayTestWebSocketTask, id: String, @@ -101,9 +127,11 @@ private func respondToSystemAgentHealth( private func systemAgentResponse( id: String, action: String = "none", - agentDraft: String? = nil) -> Data + agentDraft: String? = nil, + questionJSON: String? = nil) -> Data { let agentDraftField = agentDraft.map { ",\n \"agentDraft\": \"\($0)\"" } ?? "" + let questionField = questionJSON.map { ",\n \"question\": \($0)" } ?? "" return Data( """ { @@ -114,7 +142,7 @@ private func systemAgentResponse( "sessionId": "test-session", "reply": "ready", "action": "\(action)", - "sensitive": false\(agentDraftField) + "sensitive": false\(agentDraftField)\(questionField) } } """.utf8) @@ -482,6 +510,114 @@ struct OnboardingSystemAgentChatTests { #expect(session.latestTask()?.snapshotSendCount() == 2) } + @Test func `typed question sends reply while transcript shows label`() async throws { + let recordedMessages = SystemAgentMessageRecorder() + let questionJSON = + """ + {"id":"next","header":"Next step","question":"What now?","options":[ + {"label":"Talk to my agent","reply":"talk to agent","recommended":true}, + {"label":"Connect WhatsApp","reply":"connect whatsapp","description":"Chat there."} + ],"isOther":true} + """ + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + if let message = systemAgentChatMessage(from: message) { + await recordedMessages.record(message) + task.emitReceiveSuccess(.data(systemAgentResponse(id: id))) + } else { + task.emitReceiveSuccess(.data(systemAgentResponse( + id: id, + questionJSON: questionJSON))) + } + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = SystemAgentOnboardingChatModel(gateway: gateway) + + await chat.startIfNeeded() + let assistant = try #require(chat.messages.first) + let question = try #require(assistant.question) + #expect(question.options.first?.recommended == true) + + let task = try #require(chat.answerQuestion( + messageID: assistant.id, + optionLabel: "Connect WhatsApp")) + await task.value + + #expect(await recordedMessages.snapshot() == ["connect whatsapp"]) + #expect(chat.messages.map(\.text) == ["ready", "Connect WhatsApp", "ready"]) + #expect(!chat.canAnswerQuestion(assistant)) + } + + @Test func `typed question skip sends fixed reply and dismisses cards`() async throws { + let recordedMessages = SystemAgentMessageRecorder() + let questionJSON = + #"{"id":"next","header":"Next step","question":"What now?","options":[{"label":"A"},{"label":"B"}]}"# + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + if let message = systemAgentChatMessage(from: message) { + await recordedMessages.record(message) + task.emitReceiveSuccess(.data(systemAgentResponse(id: id))) + } else { + task.emitReceiveSuccess(.data(systemAgentResponse( + id: id, + questionJSON: questionJSON))) + } + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = SystemAgentOnboardingChatModel(gateway: gateway) + + await chat.startIfNeeded() + let assistant = try #require(chat.messages.first) + let task = try #require(chat.skipQuestion(messageID: assistant.id)) + await task.value + + #expect(await recordedMessages.snapshot() == ["Skip for now"]) + #expect(chat.messages.map(\.text) == ["ready", "Skip for now", "ready"]) + #expect(!chat.isQuestionVisible(assistant)) + } + + @Test(arguments: [ + #"{"id":"dupes","header":"Next step","question":"What now?","options":[{"label":"Same"},{"label":"same"}]}"#, + #""invalid""#, + #"[]"#, + ]) + func `malformed typed question keeps prose reply only`(questionJSON: String) async throws { + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + task.emitReceiveSuccess(.data(systemAgentResponse( + id: id, + questionJSON: questionJSON))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = SystemAgentOnboardingChatModel(gateway: gateway) + + await chat.startIfNeeded() + + #expect(chat.messages.map(\.text) == ["ready"]) + #expect(chat.messages.first?.question == nil) + } + @Test func `agent handoff carries the hatch draft intent`() async throws { let session = GatewayTestWebSocketSession(taskFactory: { GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemAgentChatQuestion.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemAgentChatQuestion.swift new file mode 100644 index 000000000000..2039af3fa6da --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemAgentChatQuestion.swift @@ -0,0 +1,63 @@ +import Foundation + +public struct SystemAgentChatQuestion: Equatable, Sendable { + public struct Option: Equatable, Sendable { + public let label: String + public let description: String? + public let recommended: Bool + public let reply: String? + } + + public let id: String + public let header: String + public let question: String + public let options: [Option] + public let isOther: Bool + + /// Card-capable clients validate the open payload before turning values into actions. + /// Invalid questions stay prose-only instead of exposing partial or ambiguous choices. + public static func parse(_ value: [String: AnyCodable]?) -> Self? { + guard let value, + let id = nonEmptyString(value["id"]), + let header = nonEmptyString(value["header"]), + let question = nonEmptyString(value["question"]), + let rawOptions = value["options"]?.arrayValue, + (2...4).contains(rawOptions.count) + else { return nil } + + var labels = Set() + var recommendedCount = 0 + var options: [Option] = [] + options.reserveCapacity(rawOptions.count) + + for rawOption in rawOptions { + guard let option = rawOption.dictionaryValue, + let label = Self.nonEmptyString(option["label"]), + labels.insert(label.lowercased()).inserted + else { return nil } + + let recommended = option["recommended"]?.boolValue == true + recommendedCount += recommended ? 1 : 0 + guard recommendedCount <= 1 else { return nil } + options.append(Option( + label: label, + description: Self.nonEmptyString(option["description"]), + recommended: recommended, + reply: Self.nonEmptyString(option["reply"]))) + } + + return Self( + id: id, + header: header, + question: question, + options: options, + isOther: value["isOther"]?.boolValue == true) + } + + private static func nonEmptyString(_ value: AnyCodable?) -> String? { + guard let string = value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty + else { return nil } + return string + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift index c411c87e666f..57a3d152f8bd 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift @@ -297,7 +297,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { } @MainActor - func testPartialIdentitySyncPreservesTheOtherDeferredComponent() { + func testPartialIdentitySyncPreservesTheOtherDeferredComponent() async { let oldContract = "per-sender|main|main" let newContract = "per-sender|work-main|main" let contractViewModel = OpenClawChatViewModel( @@ -342,7 +342,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { } @MainActor - func testAttachmentStagingPinsSessionAndIdentityUntilItFinishes() { + func testAttachmentStagingPinsSessionAndIdentityUntilItFinishes() async { let viewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(), @@ -369,7 +369,7 @@ final class ChatViewModelAttachmentTests: XCTestCase { } @MainActor - func testRecordingPinsSessionAndIdentityUntilItEnds() { + func testRecordingPinsSessionAndIdentityUntilItEnds() async { let ownerActivity = AttachmentOwnerActivity() let viewModel = OpenClawChatViewModel( sessionKey: "main", diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SystemAgentChatQuestionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SystemAgentChatQuestionTests.swift new file mode 100644 index 000000000000..f1159d0d674e --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SystemAgentChatQuestionTests.swift @@ -0,0 +1,104 @@ +import Foundation +import OpenClawKit +import OpenClawProtocol +import Testing + +struct SystemAgentChatQuestionTests { + @Test + func `valid gateway question keeps display and reply fields`() throws { + let decoded = try Self.parse( + """ + { + "id": " onboarding-next-step ", + "header": " Next step ", + "question": " What would you like to do first? ", + "options": [ + { + "label": " Talk to my agent ", + "reply": " talk to agent ", + "recommended": true + }, + { + "label": "Connect WhatsApp", + "reply": "connect whatsapp", + "description": " Chat there. " + } + ], + "isOther": true + } + """) + let parsed = try #require(decoded) + + #expect(parsed.id == "onboarding-next-step") + #expect(parsed.header == "Next step") + #expect(parsed.question == "What would you like to do first?") + #expect(parsed.isOther) + #expect(parsed.options.count == 2) + #expect(parsed.options[0].label == "Talk to my agent") + #expect(parsed.options[0].reply == "talk to agent") + #expect(parsed.options[0].recommended) + #expect(parsed.options[1].description == "Chat there.") + #expect(!parsed.options[1].recommended) + } + + @Test + func `optional malformed metadata is ignored`() throws { + let decoded = try Self.parse( + """ + { + "id": "channel", + "header": "Channel", + "question": "Which channel?", + "options": [ + {"label": "WhatsApp", "description": 42, "reply": " ", "recommended": "yes"}, + {"label": "Telegram"} + ], + "isOther": "yes" + } + """) + let parsed = try #require(decoded) + + #expect(parsed.options[0].description == nil) + #expect(parsed.options[0].reply == nil) + #expect(!parsed.options[0].recommended) + #expect(!parsed.isOther) + } + + @Test + func `malformed gateway questions degrade to nil`() throws { + let malformedQuestions = [ + #"{"id":"one","header":"H","question":"Q","options":[{"label":"Only"}]}"#, + """ + {"id":"five","header":"H","question":"Q","options":\ + [{"label":"A"},{"label":"B"},{"label":"C"},{"label":"D"},{"label":"E"}]} + """, + #"{"id":"dupes","header":"H","question":"Q","options":[{"label":"Same"},{"label":"same"}]}"#, + """ + {"id":"recommended","header":"H","question":"Q","options":\ + [{"label":"A","recommended":true},{"label":"B","recommended":true}]} + """, + #"{"id":"blank","header":"H","question":"Q","options":[{"label":"A"},{"label":" "}]}"#, + #"{"id":"missing-header","question":"Q","options":[{"label":"A"},{"label":"B"}]}"#, + #"{"id":"bad-option","header":"H","question":"Q","options":[{"label":"A"},"B"]}"#, + ] + + for json in malformedQuestions { + #expect(try Self.parse(json) == nil) + } + } + + private static func parse(_ questionJSON: String) throws -> SystemAgentChatQuestion? { + let result = try JSONDecoder().decode( + SystemAgentChatResult.self, + from: Data( + """ + { + "sessionId": "test-session", + "reply": "The prose reply stands alone.", + "action": "none", + "question": \(questionJSON) + } + """.utf8)) + return SystemAgentChatQuestion.parse(result.question) + } +} diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 0fbfe5f26673..758e569bd637 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -103,7 +103,10 @@ the credential with the same live test before storing its auth profile. Next remains locked until one backend has passed, so the first agent chat cannot start without working inference. After that live check passes, OpenClaw becomes available to help configure the remaining workspace, Gateway, channels, and -other optional features; it is also available later under Settings → OpenClaw. +other optional features. When OpenClaw offers a short list of choices, the app +shows native option cards; choosing one sends the selection, and **Skip for +now** always leaves the choice optional. OpenClaw is also available later under +Settings → OpenClaw. For a local Gateway, onboarding checks the Mac for memories from supported AI