mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
feat(ios): add OpenClaw settings chat (#112420)
* feat(ios): add OpenClaw settings chat * chore(ios): sanitize screenshot fixture label * fix(ios): gate OpenClaw settings chat * fix(ios): stabilize OpenClaw settings support checks * chore(i18n): refresh native source inventory * fix(ios): bind OpenClaw chat to gateway route
This commit is contained in:
committed by
GitHub
parent
9f501c77a6
commit
74de1a2f0c
+467
-275
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ enum AppleReviewDemoMode {
|
||||
|
||||
enum ScreenshotFixtureMode {
|
||||
static let gatewayName = "OpenClaw Gateway"
|
||||
static let gatewayAddress = "Mac Studio on local network"
|
||||
static let gatewayAddress = "Gateway on local network"
|
||||
static let gatewayID = "screenshot-fixture-gateway"
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
|
||||
@@ -85,6 +85,7 @@ struct SettingsProTab: View {
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@State var systemAgentChatStore = IOSSystemAgentChatStore()
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
|
||||
@@ -933,6 +933,7 @@ extension SettingsProTab {
|
||||
func title(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: String(localized: "Gateway")
|
||||
case .systemAgent: String(localized: "OpenClaw")
|
||||
case .appleWatch: String(localized: "Apple Watch")
|
||||
case .approvals: String(localized: "Approvals")
|
||||
case .permissions: String(localized: "Permissions")
|
||||
|
||||
@@ -158,6 +158,12 @@ extension SettingsProTab {
|
||||
|
||||
@ViewBuilder var settingsListSection: some View {
|
||||
Section {
|
||||
self.settingsListRow(
|
||||
icon: "sparkles.square.filled.on.square",
|
||||
iconColor: OpenClawBrand.accent,
|
||||
title: "OpenClaw",
|
||||
route: .systemAgent)
|
||||
.accessibilityIdentifier("settings-system-agent-row")
|
||||
self.settingsListRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
iconColor: self.pendingApproval == nil ? .green : .orange,
|
||||
@@ -245,6 +251,8 @@ extension SettingsProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: SettingsRoute) -> some View {
|
||||
switch route {
|
||||
case .systemAgent:
|
||||
SettingsSystemAgentChatScreen(model: self.systemAgentChatStore.model(for: self.appModel))
|
||||
case .channels:
|
||||
SettingsChannelsDestination()
|
||||
.navigationTitle(title(for: route))
|
||||
@@ -258,6 +266,8 @@ extension SettingsProTab {
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
case .systemAgent:
|
||||
EmptyView()
|
||||
case .appleWatch:
|
||||
self.appleWatchDestination
|
||||
case .approvals:
|
||||
|
||||
@@ -6,6 +6,7 @@ import UserNotifications
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case systemAgent
|
||||
case appleWatch
|
||||
case approvals
|
||||
case permissions
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct IOSSystemAgentChatRouteLease: Sendable {
|
||||
let route: GatewayNodeSessionRoute?
|
||||
let request: @Sendable (_ method: String, _ params: [String: AnyCodable], _ timeoutMs: Double) async throws -> Data
|
||||
let isCurrent: @Sendable () async -> Bool
|
||||
|
||||
static func live(session: GatewayNodeSession, gatewayID: String?) async -> Self? {
|
||||
guard let route = await session.currentRoute(ifGatewayID: gatewayID) else { return nil }
|
||||
return Self(
|
||||
route: route,
|
||||
request: { method, params, timeoutMs in
|
||||
try await session.request(
|
||||
method: method,
|
||||
params: params,
|
||||
timeoutMs: timeoutMs,
|
||||
ifCurrentRoute: route,
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
},
|
||||
isCurrent: {
|
||||
await session.currentRoute(ifGatewayID: gatewayID) == route
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IOSSystemAgentChatModel {
|
||||
enum AccessState: Equatable {
|
||||
case disconnected
|
||||
case missingAdminScope
|
||||
case checkingSystemAgentMethod
|
||||
case missingSystemAgentMethod
|
||||
case ready
|
||||
}
|
||||
|
||||
struct Message: Identifiable, Equatable {
|
||||
enum Role: Equatable {
|
||||
case assistant
|
||||
case user
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
let role: Role
|
||||
let text: String
|
||||
let question: SystemAgentChatQuestion?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
role: Role,
|
||||
text: String,
|
||||
question: SystemAgentChatQuestion? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.question = question
|
||||
}
|
||||
}
|
||||
|
||||
struct Handoff: Equatable {
|
||||
let agentID: String?
|
||||
}
|
||||
|
||||
typealias CaptureRoute = @Sendable (_ gatewayID: String?) async -> IOSSystemAgentChatRouteLease?
|
||||
|
||||
private struct ChatResult: Decodable {
|
||||
let reply: String
|
||||
let action: String
|
||||
let sensitive: Bool?
|
||||
let agentId: String?
|
||||
let question: AnyCodable?
|
||||
}
|
||||
|
||||
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<UUID> = []
|
||||
private(set) var retiredQuestionMessageIDs: Set<UUID> = []
|
||||
private(set) var accessState: AccessState
|
||||
private(set) var pendingHandoff: Handoff?
|
||||
private(set) var sessionID: String
|
||||
var input = ""
|
||||
var onOpenAgent: ((String?) -> Void)?
|
||||
|
||||
private let sessionPrefix: String
|
||||
private let captureRoute: CaptureRoute
|
||||
private var routeLease: IOSSystemAgentChatRouteLease?
|
||||
private var routeIdentity: String?
|
||||
private var started = false
|
||||
private var requestGeneration: UInt64? = 0
|
||||
private var requestTask: Task<Void, Never>?
|
||||
private var systemAgentMethodSupport: (gatewayID: String?, route: GatewayNodeSessionRoute, value: Bool)?
|
||||
|
||||
init(
|
||||
accessState: AccessState,
|
||||
routeIdentity: String?,
|
||||
sessionPrefix: String = "ios-settings-openclaw",
|
||||
captureRoute: @escaping CaptureRoute)
|
||||
{
|
||||
self.accessState = accessState
|
||||
self.routeIdentity = routeIdentity
|
||||
self.sessionPrefix = sessionPrefix
|
||||
self.sessionID = "\(sessionPrefix)-\(UUID().uuidString)"
|
||||
self.captureRoute = captureRoute
|
||||
}
|
||||
|
||||
convenience init(appModel: NodeAppModel) {
|
||||
let session = appModel.operatorSession
|
||||
let captureRoute: CaptureRoute = if appModel.isScreenshotFixtureModeEnabled {
|
||||
{ _ in
|
||||
IOSSystemAgentChatRouteLease(
|
||||
route: nil,
|
||||
request: { _, params, _ in try Self.screenshotFixtureReply(params: params) },
|
||||
isCurrent: { true })
|
||||
}
|
||||
} else {
|
||||
{ gatewayID in
|
||||
await IOSSystemAgentChatRouteLease.live(session: session, gatewayID: gatewayID)
|
||||
}
|
||||
}
|
||||
self.init(
|
||||
accessState: Self.accessState(
|
||||
connected: appModel.isOperatorGatewayConnected,
|
||||
hasAdminScope: appModel.hasOperatorAdminScope,
|
||||
supportsSystemAgent: appModel.isScreenshotFixtureModeEnabled ? true : nil),
|
||||
routeIdentity: appModel.connectedGatewayID,
|
||||
captureRoute: captureRoute)
|
||||
self.onOpenAgent = { [weak appModel] agentID in
|
||||
guard let appModel else { return }
|
||||
let trimmedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmedAgentID, !trimmedAgentID.isEmpty {
|
||||
appModel.setSelectedAgentId(trimmedAgentID)
|
||||
}
|
||||
appModel.openChat(sessionKey: nil)
|
||||
}
|
||||
}
|
||||
|
||||
static func accessState(
|
||||
connected: Bool,
|
||||
hasAdminScope: Bool,
|
||||
supportsSystemAgent: Bool?) -> AccessState
|
||||
{
|
||||
guard connected else { return .disconnected }
|
||||
guard hasAdminScope else { return .missingAdminScope }
|
||||
guard let supportsSystemAgent else { return .checkingSystemAgentMethod }
|
||||
return supportsSystemAgent ? .ready : .missingSystemAgentMethod
|
||||
}
|
||||
|
||||
private nonisolated static func screenshotFixtureReply(params: [String: AnyCodable]) throws -> Data {
|
||||
let hasMessage = params["message"]?.value is String
|
||||
var result: [String: Any] = [
|
||||
"sessionId": "ios-screenshot-openclaw",
|
||||
"reply": hasMessage
|
||||
? "I’ll keep this conversation separate from ordinary agent chat."
|
||||
: "I can check Gateway status, repair configuration, change models, or connect channels.",
|
||||
"action": "none",
|
||||
]
|
||||
if !hasMessage {
|
||||
result["question"] = [
|
||||
"id": "help",
|
||||
"header": "OpenClaw",
|
||||
"question": "What should we look at first?",
|
||||
"options": [
|
||||
[
|
||||
"label": "Check status",
|
||||
"description": "Review the Gateway and active services.",
|
||||
"recommended": true,
|
||||
"reply": "Check Gateway status",
|
||||
],
|
||||
[
|
||||
"label": "Review setup",
|
||||
"description": "Inspect models, channels, and configuration.",
|
||||
"reply": "Review setup",
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: result)
|
||||
}
|
||||
|
||||
func updateAccess(
|
||||
connected: Bool,
|
||||
hasAdminScope: Bool,
|
||||
supportsSystemAgent: Bool? = true,
|
||||
routeIdentity: String?)
|
||||
{
|
||||
let nextAccess = Self.accessState(
|
||||
connected: connected,
|
||||
hasAdminScope: hasAdminScope,
|
||||
supportsSystemAgent: supportsSystemAgent)
|
||||
let routeChanged = self.routeIdentity != routeIdentity
|
||||
self.routeIdentity = routeIdentity
|
||||
self.accessState = nextAccess
|
||||
|
||||
if routeChanged {
|
||||
self.invalidateCurrentRequest()
|
||||
self.clearSystemAgentMethodSupport()
|
||||
self.rotateConversation()
|
||||
return
|
||||
}
|
||||
if nextAccess == .checkingSystemAgentMethod {
|
||||
self.invalidateCurrentRequest()
|
||||
self.routeLease = nil
|
||||
self.input = ""
|
||||
return
|
||||
}
|
||||
guard nextAccess != .ready else { return }
|
||||
guard self.started || self.isSending || !self.messages.isEmpty else { return }
|
||||
self.invalidateCurrentRequest()
|
||||
self.routeLease = nil
|
||||
self.input = ""
|
||||
self.expectsSensitiveReply = false
|
||||
self.pendingHandoff = nil
|
||||
self.errorMessage = String(localized: "The Gateway connection changed. Restart OpenClaw to reconnect.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func matchesGatewayIdentity(_ gatewayID: String?) -> Bool {
|
||||
self.routeIdentity == gatewayID
|
||||
}
|
||||
|
||||
func cachedSystemAgentMethodSupport(
|
||||
gatewayID: String?,
|
||||
route: GatewayNodeSessionRoute) -> Bool?
|
||||
{
|
||||
guard let systemAgentMethodSupport,
|
||||
systemAgentMethodSupport.gatewayID == gatewayID,
|
||||
systemAgentMethodSupport.route == route
|
||||
else { return nil }
|
||||
return systemAgentMethodSupport.value
|
||||
}
|
||||
|
||||
func cacheSystemAgentMethodSupport(
|
||||
gatewayID: String?,
|
||||
route: GatewayNodeSessionRoute,
|
||||
value: Bool)
|
||||
{
|
||||
self.systemAgentMethodSupport = (gatewayID, route, value)
|
||||
}
|
||||
|
||||
func clearSystemAgentMethodSupport() {
|
||||
self.systemAgentMethodSupport = nil
|
||||
}
|
||||
|
||||
private func rotateConversation() {
|
||||
self.started = false
|
||||
self.routeLease = nil
|
||||
self.sessionID = "\(self.sessionPrefix)-\(UUID().uuidString)"
|
||||
self.messages.removeAll()
|
||||
self.dismissedQuestionMessageIDs.removeAll()
|
||||
self.retiredQuestionMessageIDs.removeAll()
|
||||
self.input = ""
|
||||
self.errorMessage = nil
|
||||
self.expectsSensitiveReply = false
|
||||
self.pendingHandoff = nil
|
||||
}
|
||||
|
||||
func startIfNeeded() -> Task<Void, Never>? {
|
||||
guard self.accessState == .ready,
|
||||
!self.started,
|
||||
self.errorMessage == nil,
|
||||
let generation = self.requestGeneration
|
||||
else { return nil }
|
||||
self.started = true
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.requestReply(message: nil, generation: generation)
|
||||
}
|
||||
self.requestTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send() -> Task<Void, Never>? {
|
||||
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return self.send(message: self.expectsSensitiveReply ? self.input : trimmed)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func answerQuestion(messageID: UUID, optionLabel: String) -> Task<Void, Never>? {
|
||||
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 }
|
||||
return self.send(message: option.reply ?? option.label, displayText: option.label)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func skipQuestion(messageID: UUID) -> Task<Void, Never>? {
|
||||
guard let message = self.messages.first(where: { $0.id == messageID }),
|
||||
self.canAnswerQuestion(message),
|
||||
let task = self.send(
|
||||
message: "Skip for now",
|
||||
displayText: String(localized: "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.accessState == .ready &&
|
||||
self.isQuestionVisible(message) &&
|
||||
!self.retiredQuestionMessageIDs.contains(message.id) &&
|
||||
!self.isSending &&
|
||||
self.errorMessage == nil
|
||||
}
|
||||
|
||||
func openAgent() {
|
||||
guard let handoff = self.pendingHandoff else { return }
|
||||
self.pendingHandoff = nil
|
||||
self.onOpenAgent?(handoff.agentID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restartAfterError() -> Task<Void, Never>? {
|
||||
guard self.accessState == .ready,
|
||||
let previousGeneration = self.requestGeneration
|
||||
else { return nil }
|
||||
let generation = previousGeneration &+ 1
|
||||
self.requestGeneration = generation
|
||||
self.requestTask?.cancel()
|
||||
self.routeLease = nil
|
||||
self.sessionID = "\(self.sessionPrefix)-\(UUID().uuidString)"
|
||||
self.started = true
|
||||
self.messages.removeAll()
|
||||
self.dismissedQuestionMessageIDs.removeAll()
|
||||
self.retiredQuestionMessageIDs.removeAll()
|
||||
self.input = ""
|
||||
self.errorMessage = nil
|
||||
self.expectsSensitiveReply = false
|
||||
self.pendingHandoff = nil
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.requestReply(message: nil, generation: generation)
|
||||
}
|
||||
self.requestTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
/// A secret-bearing draft must not survive while another surface is active.
|
||||
/// In-flight work stays bound to its captured route and can finish in the retained model.
|
||||
func clearInputForBackground() {
|
||||
self.input = ""
|
||||
}
|
||||
|
||||
private func invalidateCurrentRequest() {
|
||||
guard let generation = self.requestGeneration else { return }
|
||||
self.requestGeneration = generation &+ 1
|
||||
self.requestTask?.cancel()
|
||||
self.requestTask = nil
|
||||
self.isSending = false
|
||||
}
|
||||
|
||||
private func isCurrentRequest(_ generation: UInt64) -> Bool {
|
||||
self.requestGeneration == generation && !Task.isCancelled
|
||||
}
|
||||
|
||||
private func sessionRoute(for generation: UInt64) async throws -> IOSSystemAgentChatRouteLease {
|
||||
if let routeLease = self.routeLease {
|
||||
return routeLease
|
||||
}
|
||||
guard let routeLease = await self.captureRoute(self.routeIdentity) else {
|
||||
guard self.isCurrentRequest(generation) else { throw CancellationError() }
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway is not connected"])
|
||||
}
|
||||
guard self.isCurrentRequest(generation) else { throw CancellationError() }
|
||||
self.routeLease = routeLease
|
||||
return routeLease
|
||||
}
|
||||
|
||||
private func send(message: String, displayText: String? = nil) -> Task<Void, Never>? {
|
||||
guard self.accessState == .ready,
|
||||
let generation = self.requestGeneration,
|
||||
!message.isEmpty,
|
||||
!self.isSending,
|
||||
self.errorMessage == nil,
|
||||
self.pendingHandoff == nil
|
||||
else { return nil }
|
||||
self.retireQuestions()
|
||||
self.input = ""
|
||||
self.messages.append(Message(
|
||||
role: .user,
|
||||
text: displayText ?? (self.expectsSensitiveReply ? String(localized: "<redacted secret>") : 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.accessState == .ready, self.isCurrentRequest(generation) else { return }
|
||||
self.isSending = true
|
||||
self.errorMessage = nil
|
||||
defer {
|
||||
if self.requestGeneration == generation {
|
||||
self.isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(self.sessionID),
|
||||
]
|
||||
if let message {
|
||||
params["message"] = AnyCodable(message)
|
||||
}
|
||||
let routeLease = try await self.sessionRoute(for: generation)
|
||||
guard self.isCurrentRequest(generation) else { return }
|
||||
let data = try await routeLease.request("openclaw.chat", params, 190_000)
|
||||
guard self.isCurrentRequest(generation) else { return }
|
||||
guard await routeLease.isCurrent() else { throw CancellationError() }
|
||||
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,
|
||||
question: SystemAgentChatQuestion.parse(result.question?.dictionaryValue)))
|
||||
if result.action == "open-agent" {
|
||||
self.pendingHandoff = Handoff(agentID: result.agentId)
|
||||
}
|
||||
} catch {
|
||||
guard self.requestGeneration == generation else { return }
|
||||
let routeChangedBeforeDispatch = if let requestError = error as? GatewayNodeSessionRequestError {
|
||||
switch requestError {
|
||||
case .routeChangedBeforeDispatch: true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if error is CancellationError || routeChangedBeforeDispatch {
|
||||
self.started = false
|
||||
self.routeLease = nil
|
||||
self.errorMessage = String(localized: "The Gateway connection changed. Restart OpenClaw to reconnect.")
|
||||
return
|
||||
}
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IOSSystemAgentChatStore {
|
||||
private var model: IOSSystemAgentChatModel?
|
||||
|
||||
func model(for appModel: NodeAppModel) -> IOSSystemAgentChatModel {
|
||||
if let model {
|
||||
return model
|
||||
}
|
||||
let model = IOSSystemAgentChatModel(appModel: appModel)
|
||||
self.model = model
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsSystemAgentChatScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var model: IOSSystemAgentChatModel
|
||||
@State private var systemAgentSupportCheckID = UUID()
|
||||
@State private var systemAgentSupportRetryTask: Task<Void, Never>?
|
||||
@State private var isScreenActive = false
|
||||
|
||||
init(model: IOSSystemAgentChatModel) {
|
||||
_model = State(initialValue: model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if self.model.accessState == .ready {
|
||||
self.chatContent
|
||||
} else {
|
||||
self.accessGate
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.navigationTitle("OpenClaw")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
self.isScreenActive = true
|
||||
await self.refreshSystemAgentSupportAndStart()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, _ in
|
||||
Task { await self.refreshSystemAgentSupportAndStart(forceRefresh: true) }
|
||||
}
|
||||
.onChange(of: self.appModel.hasOperatorAdminScope) { _, _ in
|
||||
Task { await self.refreshSystemAgentSupportAndStart(forceRefresh: true) }
|
||||
}
|
||||
.onChange(of: self.appModel.connectedGatewayID) { _, _ in
|
||||
Task { await self.refreshSystemAgentSupportAndStart(forceRefresh: true) }
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
guard phase == .active else {
|
||||
self.cancelSystemAgentSupportRetry()
|
||||
self.model.clearInputForBackground()
|
||||
return
|
||||
}
|
||||
Task { await self.refreshSystemAgentSupportAndStart() }
|
||||
}
|
||||
.onDisappear {
|
||||
self.isScreenActive = false
|
||||
self.cancelSystemAgentSupportRetry()
|
||||
self.model.clearInputForBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private var chatContent: some View {
|
||||
VStack(spacing: 10) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(self.model.messages) { message in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
IOSSystemAgentChatBubble(message: message)
|
||||
if let question = message.question,
|
||||
self.model.isQuestionVisible(message)
|
||||
{
|
||||
IOSSystemAgentQuestionCard(
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
if self.model.isSending {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("OpenClaw is working…")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onChange(of: self.model.messages) { _, messages in
|
||||
guard let last = messages.last else { return }
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.model.errorMessage {
|
||||
self.errorRow(error)
|
||||
}
|
||||
if self.model.pendingHandoff != nil {
|
||||
self.handoffRow
|
||||
} else {
|
||||
self.composer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var accessGate: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: self.accessGateIcon)
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
Text(self.accessGateTitle)
|
||||
.font(OpenClawType.title3SemiBold)
|
||||
Text(self.accessGateDetail)
|
||||
.font(OpenClawType.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(28)
|
||||
.accessibilityIdentifier("settings-system-agent-access-gate")
|
||||
}
|
||||
|
||||
private var accessGateIcon: String {
|
||||
switch self.model.accessState {
|
||||
case .disconnected: "wifi.slash"
|
||||
case .missingAdminScope: "lock.shield"
|
||||
case .checkingSystemAgentMethod: "arrow.triangle.2.circlepath"
|
||||
case .missingSystemAgentMethod: "arrow.down.circle"
|
||||
case .ready: ""
|
||||
}
|
||||
}
|
||||
|
||||
private var accessGateTitle: String {
|
||||
switch self.model.accessState {
|
||||
case .disconnected: String(localized: "Gateway Required")
|
||||
case .missingAdminScope: String(localized: "Full Access Required")
|
||||
case .checkingSystemAgentMethod: String(localized: "Checking Gateway")
|
||||
case .missingSystemAgentMethod: String(localized: "Gateway Update Required")
|
||||
case .ready: ""
|
||||
}
|
||||
}
|
||||
|
||||
private var accessGateDetail: String {
|
||||
switch self.model.accessState {
|
||||
case .disconnected:
|
||||
String(localized: "Connect this iPhone to a Gateway before opening the OpenClaw settings assistant.")
|
||||
case .missingAdminScope:
|
||||
String(localized: "Reconnect with operator.admin access to review and change Gateway settings.")
|
||||
case .checkingSystemAgentMethod:
|
||||
String(localized: "Checking whether this Gateway supports the OpenClaw settings assistant.")
|
||||
case .missingSystemAgentMethod:
|
||||
String(localized: "Update this Gateway to use the OpenClaw settings assistant.")
|
||||
case .ready:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private func errorRow(_ error: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
Text(error)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
Button {
|
||||
self.model.restartAfterError()
|
||||
} label: {
|
||||
Text("Restart")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private var handoffRow: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("OpenClaw is ready to continue in your ordinary chat.")
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
self.model.openAgent()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Open Chat")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} icon: {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.accessibilityIdentifier("settings-system-agent-open-chat")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
Group {
|
||||
if self.model.expectsSensitiveReply {
|
||||
ZStack(alignment: .leading) {
|
||||
SecureField("", text: self.$model.input)
|
||||
.font(OpenClawType.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityLabel("Enter secret")
|
||||
if self.model.input.isEmpty {
|
||||
Text("Enter secret…")
|
||||
.font(OpenClawType.body)
|
||||
.foregroundStyle(.tertiary)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextField(text: self.$model.input, axis: .vertical) {
|
||||
Text("Reply to OpenClaw…")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
.lineLimit(1...5)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { self.model.send() }
|
||||
.disabled(self.model.errorMessage != nil || self.model.isSending)
|
||||
|
||||
Button {
|
||||
self.model.send()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(
|
||||
self.model.isSending ||
|
||||
self.model.errorMessage != nil ||
|
||||
self.model.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.accessibilityLabel("Send reply")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private func isCurrentSystemAgentSupportCheck(_ checkID: UUID, gatewayID: String?) -> Bool {
|
||||
self.isScreenActive &&
|
||||
self.scenePhase == .active &&
|
||||
self.systemAgentSupportCheckID == checkID &&
|
||||
self.appModel.connectedGatewayID == gatewayID
|
||||
}
|
||||
|
||||
private func cancelSystemAgentSupportRetry() {
|
||||
self.systemAgentSupportRetryTask?.cancel()
|
||||
self.systemAgentSupportRetryTask = nil
|
||||
}
|
||||
|
||||
private func enterCheckingSystemAgentSupport(gatewayID: String?) {
|
||||
self.model.clearSystemAgentMethodSupport()
|
||||
self.model.updateAccess(
|
||||
connected: self.appModel.isOperatorGatewayConnected,
|
||||
hasAdminScope: self.appModel.hasOperatorAdminScope,
|
||||
supportsSystemAgent: nil,
|
||||
routeIdentity: gatewayID)
|
||||
}
|
||||
|
||||
private func retrySystemAgentSupportCheck(_ checkID: UUID, gatewayID: String?) {
|
||||
self.cancelSystemAgentSupportRetry()
|
||||
self.systemAgentSupportRetryTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
guard !Task.isCancelled,
|
||||
self.isScreenActive,
|
||||
self.scenePhase == .active,
|
||||
self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID)
|
||||
else { return }
|
||||
self.systemAgentSupportRetryTask = nil
|
||||
await self.refreshSystemAgentSupportAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSystemAgentSupportAndStart(forceRefresh: Bool = false) async {
|
||||
guard self.isScreenActive, self.scenePhase == .active else { return }
|
||||
let checkID = UUID()
|
||||
self.systemAgentSupportCheckID = checkID
|
||||
let gatewayID = self.appModel.connectedGatewayID
|
||||
let connected = self.appModel.isOperatorGatewayConnected
|
||||
let hasAdminScope = self.appModel.hasOperatorAdminScope
|
||||
let isFixture = self.appModel.isScreenshotFixtureModeEnabled
|
||||
|
||||
if forceRefresh || !connected || !hasAdminScope || !self.model.matchesGatewayIdentity(gatewayID) {
|
||||
self.enterCheckingSystemAgentSupport(gatewayID: gatewayID)
|
||||
}
|
||||
|
||||
guard connected, hasAdminScope else { return }
|
||||
if isFixture {
|
||||
self.model.updateAccess(
|
||||
connected: connected,
|
||||
hasAdminScope: hasAdminScope,
|
||||
supportsSystemAgent: true,
|
||||
routeIdentity: gatewayID)
|
||||
self.model.startIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard let route = await self.appModel.operatorSession.currentRoute(ifGatewayID: gatewayID) else {
|
||||
guard self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID) else { return }
|
||||
self.enterCheckingSystemAgentSupport(gatewayID: gatewayID)
|
||||
self.retrySystemAgentSupportCheck(checkID, gatewayID: gatewayID)
|
||||
return
|
||||
}
|
||||
guard self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID) else { return }
|
||||
|
||||
if !forceRefresh,
|
||||
let support = self.model.cachedSystemAgentMethodSupport(gatewayID: gatewayID, route: route)
|
||||
{
|
||||
self.model.updateAccess(
|
||||
connected: connected,
|
||||
hasAdminScope: hasAdminScope,
|
||||
supportsSystemAgent: support,
|
||||
routeIdentity: gatewayID)
|
||||
self.model.startIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
self.enterCheckingSystemAgentSupport(gatewayID: gatewayID)
|
||||
let support = await self.appModel.operatorSession.supportsServerMethod(
|
||||
"openclaw.chat",
|
||||
ifCurrentRoute: route)
|
||||
guard self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID) else { return }
|
||||
guard let currentRoute = await self.appModel.operatorSession.currentRoute(ifGatewayID: gatewayID) else {
|
||||
guard self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID) else { return }
|
||||
self.enterCheckingSystemAgentSupport(gatewayID: gatewayID)
|
||||
self.retrySystemAgentSupportCheck(checkID, gatewayID: gatewayID)
|
||||
return
|
||||
}
|
||||
guard self.isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID) else { return }
|
||||
guard currentRoute == route else {
|
||||
self.enterCheckingSystemAgentSupport(gatewayID: gatewayID)
|
||||
self.retrySystemAgentSupportCheck(checkID, gatewayID: gatewayID)
|
||||
return
|
||||
}
|
||||
guard let support else {
|
||||
self.retrySystemAgentSupportCheck(checkID, gatewayID: gatewayID)
|
||||
return
|
||||
}
|
||||
self.model.cacheSystemAgentMethodSupport(gatewayID: gatewayID, route: route, value: support)
|
||||
self.model.updateAccess(
|
||||
connected: self.appModel.isOperatorGatewayConnected,
|
||||
hasAdminScope: self.appModel.hasOperatorAdminScope,
|
||||
supportsSystemAgent: support,
|
||||
routeIdentity: gatewayID)
|
||||
self.model.startIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private struct IOSSystemAgentQuestionCard: 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(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
Text(self.question.question)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
ForEach(self.question.options, id: \.label) { option in
|
||||
self.optionButton(option)
|
||||
}
|
||||
Button(action: self.onSkip) {
|
||||
Text("Skip for now")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(!self.isEnabled)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemGroupedBackground)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.16)))
|
||||
.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: 3) {
|
||||
Text(option.label)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.primary)
|
||||
if let description = option.description {
|
||||
Text(description)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if option.recommended {
|
||||
Text("Recommended")
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(option.recommended
|
||||
? OpenClawBrand.accent.opacity(0.12)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(option.recommended
|
||||
? OpenClawBrand.accent.opacity(0.55)
|
||||
: Color.secondary.opacity(0.12)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!self.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IOSSystemAgentChatBubble: View {
|
||||
let message: IOSSystemAgentChatModel.Message
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if self.message.role == .user {
|
||||
Spacer(minLength: 44)
|
||||
}
|
||||
Text(self.message.text)
|
||||
.font(OpenClawType.body)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.fill(self.message.role == .user
|
||||
? OpenClawBrand.accent.opacity(0.18)
|
||||
: Color(uiColor: .secondarySystemGroupedBackground)))
|
||||
if self.message.role == .assistant {
|
||||
Spacer(minLength: 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,10 @@ struct RootTabs: View {
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
||||
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
|
||||
@State private var activeSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
|
||||
@State private var selectedSettingsRoute: SettingsRoute? =
|
||||
Self.initialSettingsRoute ?? Self.initialSidebarDestination.settingsRoute
|
||||
@State private var activeSettingsRoute: SettingsRoute? =
|
||||
Self.initialSettingsRoute ?? Self.initialSidebarDestination.settingsRoute
|
||||
@State private var selectedSettingsRouteRequestID: Int = 0
|
||||
@State private var sidebarModel = RootSidebarModel()
|
||||
// Embedded Settings rows push onto the sidebar stack; clear it before
|
||||
@@ -57,7 +59,14 @@ struct RootTabs: View {
|
||||
initialDestination(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
private static var initialSettingsRoute: SettingsRoute? {
|
||||
requestedInitialSettingsRoute(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
static func initialDestination(arguments: [String]) -> SidebarDestination {
|
||||
if self.requestedInitialSettingsRoute(arguments: arguments) != nil {
|
||||
return .settings
|
||||
}
|
||||
if let requested = self.requestedInitialSidebarDestination(arguments: arguments) {
|
||||
return requested
|
||||
}
|
||||
@@ -73,6 +82,18 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func requestedInitialSettingsRoute(arguments: [String]) -> SettingsRoute? {
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-settings-route") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
return switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "openclaw", "system-agent": .systemAgent
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
static func requestedInitialSidebarDestination(arguments: [String]) -> SidebarDestination? {
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct IOSSystemAgentChatTests {
|
||||
private struct RecordedRequest: @unchecked Sendable {
|
||||
let method: String
|
||||
let params: [String: AnyCodable]
|
||||
let timeoutMs: Double
|
||||
}
|
||||
|
||||
private enum HarnessError: Error {
|
||||
case failed
|
||||
}
|
||||
|
||||
private actor RequestRecorder {
|
||||
private var requests: [RecordedRequest] = []
|
||||
private var responses: [Result<Data, HarnessError>]
|
||||
|
||||
init(responses: [Result<Data, HarnessError>]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
func perform(
|
||||
method: String,
|
||||
params: [String: AnyCodable],
|
||||
timeoutMs: Double) throws -> Data
|
||||
{
|
||||
self.requests.append(RecordedRequest(method: method, params: params, timeoutMs: timeoutMs))
|
||||
guard !self.responses.isEmpty else { throw HarnessError.failed }
|
||||
return try self.responses.removeFirst().get()
|
||||
}
|
||||
|
||||
func allRequests() -> [RecordedRequest] {
|
||||
self.requests
|
||||
}
|
||||
}
|
||||
|
||||
private actor RouteState {
|
||||
private var current = true
|
||||
|
||||
func setCurrent(_ current: Bool) {
|
||||
self.current = current
|
||||
}
|
||||
|
||||
func isCurrent() -> Bool {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
private actor SuspendedRequest {
|
||||
private var continuation: CheckedContinuation<Data, Never>?
|
||||
private var request: RecordedRequest?
|
||||
|
||||
func perform(method: String, params: [String: AnyCodable], timeoutMs: Double) async -> Data {
|
||||
self.request = RecordedRequest(method: method, params: params, timeoutMs: timeoutMs)
|
||||
return await withCheckedContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func hasRequest() -> Bool {
|
||||
self.request != nil
|
||||
}
|
||||
|
||||
func resolve(_ data: Data) {
|
||||
self.continuation?.resume(returning: data)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `RPC is gated by connected operator admin and omits onboarding params`() async throws {
|
||||
let recorder = RequestRecorder(responses: [.success(Self.reply("Ready"))])
|
||||
let model = self.makeModel(
|
||||
accessState: .disconnected,
|
||||
recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
#expect(await recorder.allRequests().isEmpty)
|
||||
|
||||
model.updateAccess(connected: true, hasAdminScope: false, routeIdentity: "gateway-a")
|
||||
await Self.start(model)
|
||||
#expect(await recorder.allRequests().isEmpty)
|
||||
|
||||
model.updateAccess(connected: true, hasAdminScope: true, routeIdentity: "gateway-a")
|
||||
await Self.start(model)
|
||||
|
||||
let request = try #require(await recorder.allRequests().first)
|
||||
#expect(request.method == "openclaw.chat")
|
||||
#expect(request.timeoutMs == 190_000)
|
||||
#expect((request.params["sessionId"]?.value as? String)?.hasPrefix("ios-settings-openclaw-") == true)
|
||||
#expect(request.params["sessionId"]?.value as? String != "main")
|
||||
#expect(request.params["message"] == nil)
|
||||
#expect(request.params["welcomeVariant"] == nil)
|
||||
#expect(request.params["delegation"] == nil)
|
||||
}
|
||||
|
||||
@Test func `missing advertised system-agent method blocks the chat`() {
|
||||
#expect(
|
||||
IOSSystemAgentChatModel.accessState(
|
||||
connected: true,
|
||||
hasAdminScope: true,
|
||||
supportsSystemAgent: false) == .missingSystemAgentMethod)
|
||||
}
|
||||
|
||||
@Test func `pending method support check blocks the chat without losing secure state`() async {
|
||||
let recorder = RequestRecorder(responses: [.success(Self.reply("Enter a secret", sensitive: true))])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
#expect(model.expectsSensitiveReply)
|
||||
model.updateAccess(
|
||||
connected: true,
|
||||
hasAdminScope: true,
|
||||
supportsSystemAgent: nil,
|
||||
routeIdentity: "gateway-a")
|
||||
await Self.start(model)
|
||||
|
||||
#expect(model.accessState == .checkingSystemAgentMethod)
|
||||
#expect(model.expectsSensitiveReply)
|
||||
#expect(await recorder.allRequests().count == 1)
|
||||
}
|
||||
|
||||
@Test func `route change invalidates suspended generation and ignores its reply`() async {
|
||||
let suspended = SuspendedRequest()
|
||||
let model = IOSSystemAgentChatModel(
|
||||
accessState: .ready,
|
||||
routeIdentity: "gateway-a",
|
||||
captureRoute: { _ in
|
||||
IOSSystemAgentChatRouteLease(
|
||||
route: nil,
|
||||
request: { method, params, timeoutMs in
|
||||
await suspended.perform(method: method, params: params, timeoutMs: timeoutMs)
|
||||
},
|
||||
isCurrent: { true })
|
||||
})
|
||||
|
||||
let start = model.startIfNeeded()
|
||||
await Self.waitUntil { await suspended.hasRequest() }
|
||||
let originalSessionID = model.sessionID
|
||||
model.input = "unsent-secret"
|
||||
model.updateAccess(connected: true, hasAdminScope: true, routeIdentity: "gateway-b")
|
||||
await suspended.resolve(Self.reply("stale reply"))
|
||||
await start?.value
|
||||
|
||||
#expect(model.messages.isEmpty)
|
||||
#expect(model.sessionID != originalSessionID)
|
||||
#expect(model.input.isEmpty)
|
||||
#expect(model.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func `gateway identity changes rotate the retained conversation`() async {
|
||||
let recorder = RequestRecorder(responses: [.success(Self.questionReply())])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
let originalSessionID = model.sessionID
|
||||
#expect(!model.messages.isEmpty)
|
||||
|
||||
model.updateAccess(connected: true, hasAdminScope: true, routeIdentity: "gateway-b")
|
||||
|
||||
#expect(model.sessionID != originalSessionID)
|
||||
#expect(model.messages.isEmpty)
|
||||
#expect(model.dismissedQuestionMessageIDs.isEmpty)
|
||||
#expect(model.retiredQuestionMessageIDs.isEmpty)
|
||||
#expect(model.pendingHandoff == nil)
|
||||
#expect(model.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func `stale route after RPC is rejected`() async {
|
||||
let recorder = RequestRecorder(responses: [.success(Self.reply("stale reply"))])
|
||||
let routeState = RouteState()
|
||||
await routeState.setCurrent(false)
|
||||
let model = self.makeModel(recorder: recorder, routeState: routeState)
|
||||
|
||||
await Self.start(model)
|
||||
|
||||
#expect(model.messages.isEmpty)
|
||||
#expect(model.errorMessage == "The Gateway connection changed. Restart OpenClaw to reconnect.")
|
||||
}
|
||||
|
||||
@Test func `sensitive answer stays redacted locally and is sent verbatim`() async throws {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.success(Self.reply("Enter the token", sensitive: true)),
|
||||
.success(Self.reply("Saved", sensitive: false)),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
#expect(model.expectsSensitiveReply)
|
||||
model.input = " super-secret-value "
|
||||
let send = try #require(model.send())
|
||||
await send.value
|
||||
|
||||
let requests = await recorder.allRequests()
|
||||
#expect(requests.count == 2)
|
||||
#expect(requests[1].params["message"]?.value as? String == " super-secret-value ")
|
||||
#expect(model.messages.contains { $0.role == .user && $0.text == "<redacted secret>" })
|
||||
#expect(!model.messages.contains { $0.text.contains("super-secret-value") })
|
||||
}
|
||||
|
||||
@Test func `option reply uses canonical value while transcript keeps label`() async throws {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.success(Self.questionReply()),
|
||||
.success(Self.reply("Applied")),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
let questionMessage = try #require(model.messages.first)
|
||||
let answer = try #require(model.answerQuestion(messageID: questionMessage.id, optionLabel: "Use Tailscale"))
|
||||
await answer.value
|
||||
|
||||
let requests = await recorder.allRequests()
|
||||
#expect(requests[1].params["message"]?.value as? String == "tailscale")
|
||||
#expect(model.messages.contains { $0.role == .user && $0.text == "Use Tailscale" })
|
||||
#expect(model.retiredQuestionMessageIDs.contains(questionMessage.id))
|
||||
}
|
||||
|
||||
@Test func `skip for now sends explicit reply and dismisses card`() async throws {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.success(Self.questionReply()),
|
||||
.success(Self.reply("Skipped")),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
let questionMessage = try #require(model.messages.first)
|
||||
let skip = try #require(model.skipQuestion(messageID: questionMessage.id))
|
||||
await skip.value
|
||||
|
||||
let requests = await recorder.allRequests()
|
||||
#expect(requests[1].params["message"]?.value as? String == "Skip for now")
|
||||
#expect(model.dismissedQuestionMessageIDs.contains(questionMessage.id))
|
||||
#expect(!model.isQuestionVisible(questionMessage))
|
||||
}
|
||||
|
||||
@Test func `ordinary gateway errors remain visible instead of becoming route change`() async {
|
||||
let recorder = RequestRecorder(responses: [.failure(.failed)])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
|
||||
#expect(model.errorMessage != nil)
|
||||
#expect(model.errorMessage != "The Gateway connection changed. Restart OpenClaw to reconnect.")
|
||||
}
|
||||
|
||||
@Test func `pre-dispatch route change asks for restart`() async {
|
||||
let model = IOSSystemAgentChatModel(
|
||||
accessState: .ready,
|
||||
routeIdentity: "gateway-a",
|
||||
captureRoute: { _ in
|
||||
IOSSystemAgentChatRouteLease(
|
||||
route: nil,
|
||||
request: { _, _, _ in
|
||||
throw GatewayNodeSessionRequestError.routeChangedBeforeDispatch
|
||||
},
|
||||
isCurrent: { true })
|
||||
})
|
||||
|
||||
await Self.start(model)
|
||||
|
||||
#expect(model.errorMessage == "The Gateway connection changed. Restart OpenClaw to reconnect.")
|
||||
}
|
||||
|
||||
@Test func `leaving settings clears input without canceling an in-flight turn`() async {
|
||||
let suspended = SuspendedRequest()
|
||||
let model = IOSSystemAgentChatModel(
|
||||
accessState: .ready,
|
||||
routeIdentity: "gateway-a",
|
||||
captureRoute: { _ in
|
||||
IOSSystemAgentChatRouteLease(
|
||||
route: nil,
|
||||
request: { method, params, timeoutMs in
|
||||
await suspended.perform(method: method, params: params, timeoutMs: timeoutMs)
|
||||
},
|
||||
isCurrent: { true })
|
||||
})
|
||||
|
||||
let start = model.startIfNeeded()
|
||||
await Self.waitUntil { await suspended.hasRequest() }
|
||||
model.input = "discard-me"
|
||||
model.clearInputForBackground()
|
||||
await suspended.resolve(Self.reply("Welcome"))
|
||||
await start?.value
|
||||
|
||||
#expect(model.input.isEmpty)
|
||||
#expect(model.messages.map(\.text) == ["Welcome"])
|
||||
#expect(model.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func `returning to settings continues the existing conversation`() async throws {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.success(Self.reply("Welcome")),
|
||||
.success(Self.reply("Still here")),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
|
||||
await Self.start(model)
|
||||
let sessionID = model.sessionID
|
||||
model.clearInputForBackground()
|
||||
model.input = "Continue"
|
||||
let send = try #require(model.send())
|
||||
await send.value
|
||||
|
||||
let requests = await recorder.allRequests()
|
||||
#expect(requests.count == 2)
|
||||
#expect(requests[1].params["sessionId"]?.value as? String == sessionID)
|
||||
#expect(requests[1].params["message"]?.value as? String == "Continue")
|
||||
#expect(model.messages.map(\.text) == ["Welcome", "Continue", "Still here"])
|
||||
}
|
||||
|
||||
@Test func `restart creates a fresh system session`() async throws {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.failure(.failed),
|
||||
.success(Self.reply("Recovered")),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
let originalSessionID = model.sessionID
|
||||
|
||||
await Self.start(model)
|
||||
#expect(model.errorMessage != nil)
|
||||
let restart = try #require(model.restartAfterError())
|
||||
await restart.value
|
||||
|
||||
#expect(model.sessionID != originalSessionID)
|
||||
#expect(model.messages.map(\.text) == ["Recovered"])
|
||||
let requests = await recorder.allRequests()
|
||||
#expect(requests.count == 2)
|
||||
#expect(requests[0].params["sessionId"]?.value as? String == originalSessionID)
|
||||
#expect(requests[1].params["sessionId"]?.value as? String == model.sessionID)
|
||||
}
|
||||
|
||||
@Test func `open agent handoff waits for explicit action and carries agent`() async {
|
||||
let recorder = RequestRecorder(responses: [
|
||||
.success(Self.reply("Continue in chat", action: "open-agent", agentID: " reviewer ")),
|
||||
])
|
||||
let model = self.makeModel(recorder: recorder)
|
||||
var openedAgentID: String?
|
||||
var openCount = 0
|
||||
model.onOpenAgent = { agentID in
|
||||
openCount += 1
|
||||
openedAgentID = agentID
|
||||
}
|
||||
|
||||
await Self.start(model)
|
||||
#expect(model.pendingHandoff?.agentID == " reviewer ")
|
||||
#expect(openCount == 0)
|
||||
|
||||
model.openAgent()
|
||||
#expect(openCount == 1)
|
||||
#expect(openedAgentID == " reviewer ")
|
||||
#expect(model.pendingHandoff == nil)
|
||||
}
|
||||
|
||||
@Test func `live handoff selects returned agent and opens ordinary chat`() {
|
||||
let appModel = NodeAppModel()
|
||||
let model = IOSSystemAgentChatModel(appModel: appModel)
|
||||
let previousOpenRequest = appModel.openChatRequestID
|
||||
|
||||
model.onOpenAgent?(" reviewer ")
|
||||
|
||||
#expect(appModel.selectedAgentId == "reviewer")
|
||||
#expect(appModel.openChatRequestID == previousOpenRequest + 1)
|
||||
}
|
||||
|
||||
@Test func `settings chat model is retained by the settings store`() {
|
||||
let appModel = NodeAppModel()
|
||||
let store = IOSSystemAgentChatStore()
|
||||
|
||||
#expect(store.model(for: appModel) === store.model(for: appModel))
|
||||
}
|
||||
|
||||
@Test func `settings chat uses branded and accessible secure input typography`() throws {
|
||||
let iosRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let source = try String(
|
||||
contentsOf: iosRoot.appendingPathComponent("Sources/Design/SettingsSystemAgentChat.swift"),
|
||||
encoding: .utf8)
|
||||
|
||||
#expect(source.contains(".font(OpenClawType.title3SemiBold)"))
|
||||
#expect(source.contains(".font(OpenClawType.body)"))
|
||||
#expect(source.contains(".font(OpenClawType.subheadSemiBold)"))
|
||||
#expect(source.contains(".font(OpenClawType.caption)"))
|
||||
#expect(source.contains("SecureField(\"\", text: self.$model.input)"))
|
||||
#expect(source.contains(".onChange(of: self.scenePhase)"))
|
||||
#expect(source.contains("guard phase == .active else"))
|
||||
#expect(source.contains("self.cancelSystemAgentSupportRetry()"))
|
||||
#expect(source.contains(".accessibilityLabel(\"Enter secret\")"))
|
||||
#expect(source.contains("currentRoute(ifGatewayID: gatewayID)"))
|
||||
#expect(source.contains("supportsServerMethod(\n \"openclaw.chat\""))
|
||||
#expect(source.contains("matchesGatewayIdentity(gatewayID)"))
|
||||
#expect(source.contains("cachedSystemAgentMethodSupport(gatewayID: gatewayID, route: route)"))
|
||||
#expect(source.contains("isCurrentSystemAgentSupportCheck(checkID, gatewayID: gatewayID)"))
|
||||
#expect(source.contains("currentRoute == route"))
|
||||
#expect(source.contains("retrySystemAgentSupportCheck(checkID, gatewayID: gatewayID)"))
|
||||
#expect(source.contains("cancelSystemAgentSupportRetry()"))
|
||||
#expect(source.contains("enterCheckingSystemAgentSupport(gatewayID: gatewayID)"))
|
||||
#expect(source.contains("self.isScreenActive"))
|
||||
#expect(source.contains("guard let support else"))
|
||||
#expect(source.contains("String(localized: \"Gateway Update Required\")"))
|
||||
#expect(source.contains("String(localized: \"Skip for now\")"))
|
||||
#expect(source.contains("String(localized: \"<redacted secret>\")"))
|
||||
#expect(!source.contains("SecureField(\"Enter secret"))
|
||||
}
|
||||
|
||||
@Test func `settings route launch argument opens OpenClaw directly`() {
|
||||
let arguments = ["OpenClaw", "--openclaw-settings-route", "openclaw"]
|
||||
|
||||
#expect(RootTabs.requestedInitialSettingsRoute(arguments: arguments) == .systemAgent)
|
||||
#expect(RootTabs.initialDestination(arguments: arguments) == .settings)
|
||||
}
|
||||
|
||||
@Test func `settings route is visible and handoff uses root chat navigation`() throws {
|
||||
#expect(SettingsProTab().title(for: .systemAgent) == "OpenClaw")
|
||||
|
||||
let iosRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let settings = try String(
|
||||
contentsOf: iosRoot.appendingPathComponent("Sources/Design/SettingsProTabSections.swift"),
|
||||
encoding: .utf8)
|
||||
let rootTabs = try String(
|
||||
contentsOf: iosRoot.appendingPathComponent("Sources/RootTabs.swift"),
|
||||
encoding: .utf8)
|
||||
#expect(settings.contains("route: .systemAgent"))
|
||||
#expect(settings
|
||||
.contains("SettingsSystemAgentChatScreen(model: self.systemAgentChatStore.model(for: self.appModel))"))
|
||||
#expect(rootTabs.contains(".onChange(of: self.appModel.openChatRequestID)"))
|
||||
}
|
||||
|
||||
private func makeModel(
|
||||
accessState: IOSSystemAgentChatModel.AccessState = .ready,
|
||||
recorder: RequestRecorder,
|
||||
routeState: RouteState = RouteState()) -> IOSSystemAgentChatModel
|
||||
{
|
||||
IOSSystemAgentChatModel(
|
||||
accessState: accessState,
|
||||
routeIdentity: "gateway-a",
|
||||
captureRoute: { _ in
|
||||
IOSSystemAgentChatRouteLease(
|
||||
route: nil,
|
||||
request: { method, params, timeoutMs in
|
||||
try await recorder.perform(method: method, params: params, timeoutMs: timeoutMs)
|
||||
},
|
||||
isCurrent: { await routeState.isCurrent() })
|
||||
})
|
||||
}
|
||||
|
||||
private static func start(_ model: IOSSystemAgentChatModel) async {
|
||||
await model.startIfNeeded()?.value
|
||||
}
|
||||
|
||||
private static func waitUntil(_ condition: @escaping () async -> Bool) async {
|
||||
for _ in 0..<100 {
|
||||
if await condition() { return }
|
||||
await Task.yield()
|
||||
}
|
||||
Issue.record("Timed out waiting for asynchronous condition")
|
||||
}
|
||||
|
||||
private static func reply(
|
||||
_ reply: String,
|
||||
action: String = "reply",
|
||||
sensitive: Bool? = nil,
|
||||
agentID: String? = nil) -> Data
|
||||
{
|
||||
var result: [String: Any] = [
|
||||
"sessionId": "system-session",
|
||||
"reply": reply,
|
||||
"action": action,
|
||||
]
|
||||
if let sensitive {
|
||||
result["sensitive"] = sensitive
|
||||
}
|
||||
if let agentID {
|
||||
result["agentId"] = agentID
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: result) else {
|
||||
preconditionFailure("System-agent reply fixture must encode")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static func questionReply() -> Data {
|
||||
let value: [String: Any] = [
|
||||
"sessionId": "system-session",
|
||||
"reply": "Choose a connection",
|
||||
"action": "reply",
|
||||
"question": [
|
||||
"id": "connection",
|
||||
"header": "Connection",
|
||||
"question": "How should OpenClaw connect?",
|
||||
"options": [
|
||||
[
|
||||
"label": "Use Tailscale",
|
||||
"description": "Private network",
|
||||
"recommended": true,
|
||||
"reply": "tailscale",
|
||||
],
|
||||
[
|
||||
"label": "Use LAN",
|
||||
"description": "Local network",
|
||||
"recommended": false,
|
||||
"reply": "lan",
|
||||
],
|
||||
],
|
||||
"isOther": false,
|
||||
],
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: value) else {
|
||||
preconditionFailure("System-agent question fixture must encode")
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,27 @@ struct SwiftUIRenderSmokeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `settings OpenClaw destination builds access gate across appearance and type size`() {
|
||||
var windows: [UIWindow] = []
|
||||
defer { windows.forEach { $0.isHidden = true } }
|
||||
|
||||
for scheme in [ColorScheme.light, ColorScheme.dark] {
|
||||
for typeSize in [DynamicTypeSize.large, .accessibility2] {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let root = SettingsProTab(directRoute: .systemAgent)
|
||||
.environment(AppAppearanceModel())
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.dynamicTypeSize, typeSize)
|
||||
.preferredColorScheme(scheme)
|
||||
|
||||
windows.append(Self.host(root, size: CGSize(width: 393, height: 852)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `settings pro tab appearance row builds for all preferences`() throws {
|
||||
for preference in AppAppearancePreference.allCases {
|
||||
let suiteName = "OpenClawTests.appearance.\(preference.rawValue).\(UUID().uuidString)"
|
||||
|
||||
@@ -19,6 +19,7 @@ Availability: iPhone app builds are distributed through Apple channels when enab
|
||||
- Keeps a small read-only offline cache of recent chat sessions and transcripts per paired gateway: cold opens paint the last known transcript immediately and refresh once the gateway responds, recent chats stay browsable while disconnected, and reset/forget purges the protected local cache.
|
||||
- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotent retries, remain durable until canonical history confirms the send, retry with backoff before surfacing a retry/delete action, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache.
|
||||
- Chat is the single text-and-voice surface. Chat actions can open the full Sessions screen without leaving Chat and can show or hide assistant reasoning and tool activity. Tap the microphone for draft dictation, open its menu to record a voice note, or use the inline Talk control for realtime voice; the Talk control animates from live microphone or playback level while listening or speaking.
|
||||
- **Settings -> OpenClaw** opens a dedicated Gateway settings assistant when the operator connection has `operator.admin` and the Gateway supports `openclaw.chat`. Its setup conversation stays separate from ordinary Chat, redacts secret replies locally, and moves to Chat only after you tap **Open Chat**.
|
||||
- Speaks assistant messages on demand: long-press a message in Chat and choose **Listen**. The app plays supported gateway `tts.speak` clips with the configured TTS provider and falls back to on-device speech when gateway audio is unavailable or unplayable. Playback stops on session switch or backgrounding.
|
||||
|
||||
## Requirements
|
||||
|
||||
Reference in New Issue
Block a user