diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5d12840ba..ac60f101edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai - Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle. - ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn. - Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc. +- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman. +- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman. ### Breaking diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 73e13fa0992..028983d1a5b 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -34,18 +34,11 @@ extension NodeAppModel { } func showA2UIOnConnectIfNeeded() async { - let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - if current.isEmpty || current == self.lastAutoA2uiURL { - if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(), - let url = URL(string: canvasUrl), - await Self.probeTCP(url: url, timeoutSeconds: 2.5) - { - self.screen.navigate(to: canvasUrl) - self.lastAutoA2uiURL = canvasUrl - } else { - self.lastAutoA2uiURL = nil - self.screen.showDefaultCanvas() - } + await MainActor.run { + // Keep the bundled home canvas as the default connected view. + // Agents can still explicitly present a remote or local canvas later. + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() } } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4b9483e7662..bbb79d98db0 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -88,6 +88,7 @@ final class NodeAppModel { var selectedAgentId: String? var gatewayDefaultAgentId: String? var gatewayAgents: [AgentSummary] = [] + var homeCanvasRevision: Int = 0 var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? @@ -548,6 +549,7 @@ final class NodeAppModel { self.seamColorHex = raw.isEmpty ? nil : raw self.mainSessionBaseKey = mainKey self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { if let gatewayError = error as? GatewayResponseError { @@ -574,12 +576,19 @@ final class NodeAppModel { self.selectedAgentId = nil } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { // Best-effort only. } } + func refreshGatewayOverviewIfConnected() async { + guard await self.isOperatorConnected() else { return } + await self.refreshBrandingFromGateway() + await self.refreshAgentsFromGateway() + } + func setSelectedAgentId(_ agentId: String?) { let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -590,6 +599,7 @@ final class NodeAppModel { GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( @@ -1749,6 +1759,7 @@ private extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 1eb8459a642..64e3d739b96 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import OpenClawProtocol struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @@ -137,16 +138,33 @@ struct RootCanvas: View { .environment(self.gatewayController) } .onAppear { self.updateIdleTimer() } + .onAppear { self.updateHomeCanvasState() } .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } - .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .onChange(of: self.scenePhase) { _, newValue in + self.updateIdleTimer() + self.updateHomeCanvasState() + guard newValue == .active else { return } + Task { + await self.appModel.refreshGatewayOverviewIfConnected() + await MainActor.run { + self.updateHomeCanvasState() + } + } + } .onAppear { self.maybeShowQuickSetup() } .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayStatusText) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.gatewayServerName) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.showOnboarding = false @@ -155,7 +173,13 @@ struct RootCanvas: View { .onChange(of: self.onboardingRequestID) { _, _ in self.evaluateOnboardingPresentation(force: true) } - .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.homeCanvasRevision) { _, _ in + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.onboardingComplete = true @@ -209,6 +233,134 @@ struct RootCanvas: View { self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } + private func updateHomeCanvasState() { + let payload = self.makeHomeCanvasPayload() + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + self.appModel.screen.updateHomeCanvasState(json: nil) + return + } + self.appModel.screen.updateHomeCanvasState(json: json) + } + + private func makeHomeCanvasPayload() -> HomeCanvasPayload { + let gatewayName = self.normalized(self.appModel.gatewayServerName) + let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) + let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" + let activeAgentID = self.resolveActiveAgentID() + let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) + + switch self.gatewayStatus { + case .connected: + return HomeCanvasPayload( + gatewayState: "connected", + eyebrow: "Connected to \(gatewayLabel)", + title: "Your agents are ready", + subtitle: + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", + activeAgentCaption: "Selected on this phone", + agentCount: agents.count, + agents: Array(agents.prefix(6)), + footer: "The overview refreshes on reconnect and when the app returns to foreground.") + case .connecting: + return HomeCanvasPayload( + gatewayState: "connecting", + eyebrow: "Reconnecting", + title: "OpenClaw is syncing back up", + subtitle: + "The gateway session is coming back online. " + + "Agent shortcuts should settle automatically in a moment.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: "OC", + activeAgentCaption: "Gateway session in progress", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: "If the gateway is reachable, reconnect should complete without intervention.") + case .error, .disconnected: + return HomeCanvasPayload( + gatewayState: self.gatewayStatus == .error ? "error" : "offline", + eyebrow: "Welcome to OpenClaw", + title: "Your phone stays quiet until it is needed", + subtitle: + "Pair this device to your gateway to wake it only for real work, " + + "keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel: gatewayLabel, + activeAgentName: "Main", + activeAgentBadge: "OC", + activeAgentCaption: "Connect to load your agents", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: + "When connected, the gateway can wake the phone with a silent push " + + "instead of holding an always-on session.") + } + } + + private func resolveActiveAgentID() -> String { + let selected = self.normalized(self.appModel.selectedAgentId) ?? "" + if !selected.isEmpty { + return selected + } + return self.resolveDefaultAgentID() + } + + private func resolveDefaultAgentID() -> String { + self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" + } + + private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] { + let defaultAgentID = self.resolveDefaultAgentID() + let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in + let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID + let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID + return HomeCanvasAgentCard( + id: agent.id, + name: self.homeCanvasName(for: agent), + badge: self.homeCanvasBadge(for: agent), + caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"), + isActive: isActive) + } + + return cards.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { + return lhs.isActive + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func homeCanvasName(for agent: AgentSummary) -> String { + self.normalized(agent.name) ?? agent.id + } + + private func homeCanvasBadge(for agent: AgentSummary) -> String { + if let identity = agent.identity, + let emoji = identity["emoji"]?.value as? String, + let normalizedEmoji = self.normalized(emoji) + { + return normalizedEmoji + } + let words = self.homeCanvasName(for: agent) + .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) + .prefix(2) + let initials = words.compactMap { $0.first }.map(String.init).joined() + if !initials.isEmpty { + return initials.uppercased() + } + return "OC" + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func evaluateOnboardingPresentation(force: Bool) { if force { self.onboardingAllowSkip = true @@ -274,6 +426,28 @@ struct RootCanvas: View { } } +private struct HomeCanvasPayload: Codable { + var gatewayState: String + var eyebrow: String + var title: String + var subtitle: String + var gatewayLabel: String + var activeAgentName: String + var activeAgentBadge: String + var activeAgentCaption: String + var agentCount: Int + var agents: [HomeCanvasAgentCard] + var footer: String +} + +private struct HomeCanvasAgentCard: Codable { + var id: String + var name: String + var badge: String + var caption: String + var isActive: Bool +} + private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel @AppStorage("talk.enabled") private var talkEnabled: Bool = false @@ -301,53 +475,33 @@ private struct CanvasContent: View { .transition(.opacity) } } - .overlay(alignment: .topLeading) { - HStack(alignment: .top, spacing: 8) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - brighten: self.brightenButtons, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { - self.openSettings() - } - }) - .layoutPriority(1) - - Spacer(minLength: 8) - - HStack(spacing: 8) { - OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { - self.openChat() - } - .accessibilityLabel("Chat") - - if self.talkButtonEnabled { - // Keep Talk mode near status controls while freeing right-side screen real estate. - OverlayButton( - systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", - brighten: self.brightenButtons, - tint: self.appModel.seamColor, - isActive: self.talkActive) - { - let next = !self.talkActive - self.talkEnabled = next - self.appModel.setTalkEnabled(next) - } - .accessibilityLabel("Talk Mode") - } - - OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { + .safeAreaInset(edge: .bottom, spacing: 0) { + HomeToolbar( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + brighten: self.brightenButtons, + talkButtonEnabled: self.talkButtonEnabled, + talkActive: self.talkActive, + talkTint: self.appModel.seamColor, + onStatusTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { self.openSettings() } - .accessibilityLabel("Settings") - } - } - .padding(.horizontal, 10) - .safeAreaPadding(.top, 10) + }, + onChatTap: { + self.openChat() + }, + onTalkTap: { + let next = !self.talkActive + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + }, + onSettingsTap: { + self.openSettings() + }) } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 5c945033551..4c9f3ff5085 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -20,6 +20,7 @@ final class ScreenController { private var debugStatusEnabled: Bool = false private var debugStatusTitle: String? private var debugStatusSubtitle: String? + private var homeCanvasStateJSON: String? init() { self.reload() @@ -94,6 +95,26 @@ final class ScreenController { subtitle: self.debugStatusSubtitle) } + func updateHomeCanvasState(json: String?) { + self.homeCanvasStateJSON = json + self.applyHomeCanvasStateIfNeeded() + } + + func applyHomeCanvasStateIfNeeded() { + guard let webView = self.activeWebView else { return } + let payload = self.homeCanvasStateJSON ?? "null" + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome(\(payload)); + } catch (_) {} + })() + """ + webView.evaluateJavaScript(js) { _, _ in } + } + func waitForA2UIReady(timeoutMs: Int) async -> Bool { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) @@ -191,6 +212,7 @@ final class ScreenController { self.activeWebView = webView self.reload() self.applyDebugStatusIfNeeded() + self.applyHomeCanvasStateIfNeeded() } func detachWebView(_ webView: WKWebView) { diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift index a30d78cbd00..61f9af6515c 100644 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ b/apps/ios/Sources/Screen/ScreenWebView.swift @@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_: WKWebView, didFinish _: WKNavigation?) { self.controller?.errorText = nil self.controller?.applyDebugStatusIfNeeded() + self.controller?.applyHomeCanvasStateIfNeeded() } func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index a723ce5eb39..d6f94185b40 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -38,6 +38,7 @@ struct StatusPill: View { var gateway: GatewayState var voiceWakeEnabled: Bool var activity: Activity? + var compact: Bool = false var brighten: Bool = false var onTap: () -> Void @@ -45,11 +46,11 @@ struct StatusPill: View { var body: some View { Button(action: self.onTap) { - HStack(spacing: 10) { - HStack(spacing: 8) { + HStack(spacing: self.compact ? 8 : 10) { + HStack(spacing: self.compact ? 6 : 8) { Circle() .fill(self.gateway.color) - .frame(width: 9, height: 9) + .frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9) .scaleEffect( self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) @@ -58,34 +59,38 @@ struct StatusPill: View { .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) Text(self.gateway.title) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(.primary) } - Divider() - .frame(height: 14) - .opacity(0.35) - if let activity { - HStack(spacing: 6) { + if !self.compact { + Divider() + .frame(height: 14) + .opacity(0.35) + } + + HStack(spacing: self.compact ? 4 : 6) { Image(systemName: activity.systemImage) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(activity.tint ?? .primary) - Text(activity.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) + if !self.compact { + Text(activity.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } } .transition(.opacity.combined(with: .move(edge: .top))) } else { Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") .transition(.opacity.combined(with: .move(edge: .top))) } } - .statusGlassCard(brighten: self.brighten, verticalPadding: 8) + .statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8) } .buttonStyle(.plain) .accessibilityLabel("Connection Status") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html index ceb7a975da4..52ec996e90a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -3,7 +3,7 @@ - Canvas + OpenClaw - + +
+
+
+
+ + Welcome to OpenClaw +
+

Your phone stays quiet until it is needed

+

+ Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops. +

+ +
+
+
Gateway
+
Gateway
+
Connect to load your agents
+
+ +
+
Active Agent
+
+
OC
+
+
Main
+
Connect to load your agents
+
+
+
+
+
+ +
+
+
Live agents
+
0 agents
+
+
+ +
+
+
+
Ready
Waiting for agent
+