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:
Peter Steinberger
2026-07-22 05:14:47 -07:00
committed by GitHub
parent 9f501c77a6
commit 74de1a2f0c
11 changed files with 1990 additions and 278 deletions
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
? "Ill 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)
}
}
}
}
+23 -2
View File
@@ -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)"
+1
View File
@@ -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