iOS: add welcome home canvas

This commit is contained in:
Mariano Belinky
2026-03-09 22:10:01 +01:00
committed by Nimrod Gutman
parent 8ba1b6eff1
commit 67746a12de
8 changed files with 769 additions and 192 deletions

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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")

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<title>OpenClaw</title>
<script>
(() => {
try {
@@ -15,99 +15,339 @@
}
if (/android/i.test(navigator.userAgent || '')) {
document.documentElement.dataset.platform = 'android';
} else {
document.documentElement.dataset.platform = 'ios';
}
} catch (_) {}
})();
</script>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
:root {
color-scheme: dark;
--bg: #06070b;
--panel: rgba(14, 17, 24, 0.74);
--panel-strong: rgba(18, 23, 32, 0.86);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
--text: rgba(255, 255, 255, 0.96);
--muted: rgba(222, 229, 239, 0.72);
--soft: rgba(222, 229, 239, 0.5);
--accent: #8ec5ff;
--accent-strong: #5b9dff;
--accent-warm: #ff9159;
--accent-rose: #ff5fa2;
--state: #7d8ca3;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
html,body { height:100%; margin:0; }
html, body {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
radial-gradient(900px 640px at 12% 10%, rgba(91, 157, 255, 0.36), rgba(0, 0, 0, 0) 58%),
radial-gradient(840px 600px at 88% 16%, rgba(255, 95, 162, 0.24), rgba(0, 0, 0, 0) 62%),
radial-gradient(960px 720px at 50% 100%, rgba(255, 145, 89, 0.18), rgba(0, 0, 0, 0) 60%),
linear-gradient(180deg, #090b11 0%, #05060a 100%);
color: var(--text);
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
#0b1328;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
transparent 1px, transparent 48px);
transform: translate3d(0,0,0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.45;
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before { opacity: 0.80; }
body::before,
body::after {
content:"";
content: "";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
filter: blur(28px);
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0,0,0);
inset: -10%;
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after { opacity: 0.85; }
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
body::before {
background:
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 52px
);
opacity: 0.42;
transform: rotate(-7deg);
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes openclaw-grid-drift {
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
}
@keyframes openclaw-glow-drift {
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
body::after {
background:
radial-gradient(700px 460px at 20% 18%, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0) 62%),
radial-gradient(720px 520px at 84% 20%, rgba(255, 95, 162, 0.14), rgba(0, 0, 0, 0) 66%),
radial-gradient(860px 620px at 52% 88%, rgba(255, 145, 89, 0.14), rgba(0, 0, 0, 0) 64%);
filter: blur(28px);
opacity: 0.95;
}
body[data-state="connected"] { --state: #61d58b; }
body[data-state="connecting"] { --state: #ffd05f; }
body[data-state="error"] { --state: #ff6d6d; }
body[data-state="offline"] { --state: #95a3b9; }
canvas {
position: fixed;
inset: 0;
display:block;
width:100vw;
height:100vh;
touch-action: none;
width: 100vw;
height: 100vh;
display: block;
z-index: 1;
}
:root[data-platform="android"] #openclaw-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
#141c33;
#openclaw-home {
position: fixed;
inset: 0;
z-index: 2;
display: flex;
align-items: stretch;
justify-content: center;
padding: calc(var(--safe-top) + 22px) 18px calc(var(--safe-bottom) + 18px);
box-sizing: border-box;
}
.shell {
width: min(100%, 760px);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
}
.hero {
position: relative;
overflow: hidden;
border-radius: 28px;
background: linear-gradient(180deg, rgba(18, 24, 34, 0.86), rgba(10, 13, 19, 0.94));
border: 1px solid var(--line);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
padding: 22px 22px 18px;
}
.hero::before {
content: "";
position: absolute;
inset: -30% auto auto -20%;
width: 240px;
height: 240px;
border-radius: 999px;
background: radial-gradient(circle, rgba(142, 197, 255, 0.18), rgba(0, 0, 0, 0));
pointer-events: none;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.eyebrow-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--state);
box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent);
}
.hero h1 {
margin: 18px 0 0;
font-size: clamp(32px, 7vw, 52px);
line-height: 0.98;
letter-spacing: -0.04em;
}
.hero p {
margin: 14px 0 0;
font-size: 16px;
line-height: 1.5;
color: var(--muted);
max-width: 32rem;
}
.hero-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
margin-top: 22px;
}
.meta-card,
.agent-card {
border-radius: 22px;
background: var(--panel);
border: 1px solid var(--line);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.meta-card {
padding: 16px 16px 15px;
}
.meta-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--soft);
}
.meta-value {
margin-top: 8px;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.03em;
}
.meta-subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.agent-focus {
display: flex;
align-items: center;
gap: 14px;
}
.agent-badge {
width: 56px;
height: 56px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background:
linear-gradient(135deg, rgba(142, 197, 255, 0.22), rgba(91, 157, 255, 0.1)),
rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 24px;
font-weight: 700;
}
.agent-focus .name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.agent-focus .caption {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
.section {
padding: 16px 16px 14px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--muted);
}
.section-count {
font-size: 12px;
font-weight: 700;
color: var(--soft);
}
.agent-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.agent-card {
padding: 13px 13px 12px;
}
.agent-card.active {
background: var(--panel-strong);
border-color: var(--line-strong);
box-shadow: inset 0 0 0 1px rgba(142, 197, 255, 0.12);
}
.agent-row {
display: flex;
align-items: center;
gap: 10px;
}
.agent-row .badge {
width: 38px;
height: 38px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 16px;
font-weight: 700;
}
.agent-row .name {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
}
.agent-row .caption {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.12);
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.footer-note {
margin-top: 12px;
color: var(--soft);
font-size: 12px;
line-height: 1.45;
}
#openclaw-status {
position: fixed;
inset: 0;
@@ -115,41 +355,116 @@
align-items: center;
justify-content: flex-start;
flex-direction: column;
padding-top: calc(20px + env(safe-area-inset-top, 0px));
padding-top: calc(var(--safe-top) + 18px);
pointer-events: none;
z-index: 3;
}
#openclaw-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
background: rgba(18, 18, 22, 0.46);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.55);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#openclaw-status .title {
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
color: rgba(255, 255, 255, 0.92);
}
#openclaw-status .subtitle {
margin-top: 6px;
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
color: rgba(255,255,255,0.58);
color: rgba(255, 255, 255, 0.58);
}
@media (max-width: 640px) {
#openclaw-home {
padding-left: 14px;
padding-right: 14px;
}
.hero {
border-radius: 24px;
padding: 18px 16px 16px;
}
.hero-grid,
.agent-grid {
grid-template-columns: 1fr;
}
.hero h1 {
font-size: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
}
</style>
</head>
<body>
<body data-state="offline">
<canvas id="openclaw-canvas"></canvas>
<div id="openclaw-home">
<div class="shell">
<div class="hero">
<div class="eyebrow">
<span class="eyebrow-dot"></span>
<span id="openclaw-home-eyebrow">Welcome to OpenClaw</span>
</div>
<h1 id="openclaw-home-title">Your phone stays quiet until it is needed</h1>
<p id="openclaw-home-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.
</p>
<div class="hero-grid">
<div class="meta-card">
<div class="meta-label">Gateway</div>
<div class="meta-value" id="openclaw-home-gateway">Gateway</div>
<div class="meta-subtitle" id="openclaw-home-gateway-caption">Connect to load your agents</div>
</div>
<div class="meta-card">
<div class="meta-label">Active Agent</div>
<div class="agent-focus">
<div class="agent-badge" id="openclaw-home-active-badge">OC</div>
<div>
<div class="name" id="openclaw-home-active-name">Main</div>
<div class="caption" id="openclaw-home-active-caption">Connect to load your agents</div>
</div>
</div>
</div>
</div>
</div>
<div class="meta-card section">
<div class="section-header">
<div class="section-title">Live agents</div>
<div class="section-count" id="openclaw-home-agent-count">0 agents</div>
</div>
<div class="agent-grid" id="openclaw-home-agent-grid"></div>
<div class="footer-note" id="openclaw-home-footer">
When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.
</div>
</div>
</div>
</div>
<div id="openclaw-status">
<div class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('openclaw-canvas');
@@ -157,6 +472,20 @@
const statusEl = document.getElementById('openclaw-status');
const titleEl = document.getElementById('openclaw-status-title');
const subtitleEl = document.getElementById('openclaw-status-subtitle');
const home = {
root: document.getElementById('openclaw-home'),
eyebrow: document.getElementById('openclaw-home-eyebrow'),
title: document.getElementById('openclaw-home-title'),
subtitle: document.getElementById('openclaw-home-subtitle'),
gateway: document.getElementById('openclaw-home-gateway'),
gatewayCaption: document.getElementById('openclaw-home-gateway-caption'),
activeBadge: document.getElementById('openclaw-home-active-badge'),
activeName: document.getElementById('openclaw-home-active-name'),
activeCaption: document.getElementById('openclaw-home-active-caption'),
agentCount: document.getElementById('openclaw-home-agent-count'),
agentGrid: document.getElementById('openclaw-home-agent-grid'),
footer: document.getElementById('openclaw-home-footer')
};
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
@@ -172,54 +501,114 @@
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
const width = Math.max(1, Math.floor(window.innerWidth * dpr));
const height = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = width;
canvas.height = height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function setDebugStatusEnabled(enabled) {
debugStatusEnabled = !!enabled;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
}
function setStatus(title, subtitle) {
if (!debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (typeof title === 'string') titleEl.textContent = title;
if (typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
function clearChildren(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function createAgentCard(agent) {
const card = document.createElement('div');
card.className = `agent-card${agent.isActive ? ' active' : ''}`;
const row = document.createElement('div');
row.className = 'agent-row';
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = agent.badge || 'OC';
const text = document.createElement('div');
const name = document.createElement('div');
name.className = 'name';
name.textContent = agent.name || agent.id || 'Agent';
const caption = document.createElement('div');
caption.className = 'caption';
caption.textContent = agent.caption || 'Ready';
text.appendChild(name);
text.appendChild(caption);
row.appendChild(badge);
row.appendChild(text);
card.appendChild(row);
return card;
}
function renderHome(state) {
if (!state || typeof state !== 'object') return;
document.body.dataset.state = state.gatewayState || 'offline';
home.root.style.display = 'flex';
home.eyebrow.textContent = state.eyebrow || 'Welcome to OpenClaw';
home.title.textContent = state.title || 'OpenClaw';
home.subtitle.textContent = state.subtitle || '';
home.gateway.textContent = state.gatewayLabel || 'Gateway';
home.gatewayCaption.textContent = state.gatewayState === 'connected'
? `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'} available`
: (state.activeAgentCaption || 'Connect to load your agents');
home.activeBadge.textContent = state.activeAgentBadge || 'OC';
home.activeName.textContent = state.activeAgentName || 'Main';
home.activeCaption.textContent = state.activeAgentCaption || '';
home.agentCount.textContent = `${state.agentCount || 0} agent${state.agentCount === 1 ? '' : 's'}`;
home.footer.textContent = state.footer || '';
clearChildren(home.agentGrid);
const agents = Array.isArray(state.agents) ? state.agents : [];
if (!agents.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = state.gatewayState === 'connected'
? 'Your gateway is online. Agents will appear here as soon as the current scope reports them.'
: 'Connect this phone to your gateway and the live agent overview will appear here.';
home.agentGrid.appendChild(empty);
return;
}
agents.forEach((agent) => {
home.agentGrid.appendChild(createAgentCard(agent));
});
}
window.addEventListener('resize', resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
};
if (statusEl && !debugStatusEnabled) {
if (!debugStatusEnabled) {
statusEl.style.display = 'none';
}
const api = {
window.__openclaw = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
}
setStatus,
renderHome
};
window.__openclaw = api;
})();
</script>
</body>
</html>