diff --git a/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html b/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
new file mode 100644
index 000000000..90ad3f246
--- /dev/null
+++ b/apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
@@ -0,0 +1,162 @@
+
+
+
+
+
+ Canvas
+
+
+
+
+
+
+
Ready
+
Waiting for agent
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
index d2e22a6c5..f711a4178 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt
@@ -109,6 +109,8 @@ class NodeRuntime(context: Context) {
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow = _isForeground.asStateFlow()
+ private var lastAutoA2uiUrl: String? = null
+
private val session =
BridgeSession(
scope = scope,
@@ -118,6 +120,7 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = remote
_isConnected.value = true
scope.launch { refreshWakeWordsFromGateway() }
+ maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message -> handleSessionDisconnected(message) },
onEvent = { event, payloadJson ->
@@ -136,6 +139,21 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
chat.onDisconnected(message)
+ showLocalCanvasOnDisconnect()
+ }
+
+ private fun maybeNavigateToA2uiOnConnect() {
+ val a2uiUrl = resolveA2uiHostUrl() ?: return
+ val current = canvas.currentUrl()?.trim().orEmpty()
+ if (current.isEmpty() || current == lastAutoA2uiUrl) {
+ lastAutoA2uiUrl = a2uiUrl
+ canvas.navigate(a2uiUrl)
+ }
+ }
+
+ private fun showLocalCanvasOnDisconnect() {
+ lastAutoA2uiUrl = null
+ canvas.navigate("")
}
val instanceId: StateFlow = prefs.instanceId
diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
index 5c3e90d69..d89dc83cb 100644
--- a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
+++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt
@@ -44,6 +44,10 @@ class CanvasController {
reload()
}
+ fun currentUrl(): String? = url
+
+ fun isDefaultCanvas(): Boolean = url == null
+
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
val wv = webView ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 5f1cf6d26..6a6b64450 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -28,6 +28,7 @@ final class NodeAppModel {
private var voiceWakeSyncTask: Task?
@ObservationIgnored private var cameraHUDDismissTask: Task?
let voiceWake = VoiceWakeManager()
+ private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge }
@@ -147,6 +148,20 @@ final class NodeAppModel {
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
+ private func showA2UIOnConnectIfNeeded() async {
+ guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
+ let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
+ if current.isEmpty || current == self.lastAutoA2uiURL {
+ self.screen.navigate(to: a2uiUrl)
+ self.lastAutoA2uiURL = a2uiUrl
+ }
+ }
+
+ private func showLocalCanvasOnDisconnect() {
+ self.lastAutoA2uiURL = nil
+ self.screen.showDefaultCanvas()
+ }
+
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -202,6 +217,7 @@ final class NodeAppModel {
}
}
await self.startVoiceWakeSync()
+ await self.showA2UIOnConnectIfNeeded()
},
onInvoke: { [weak self] req in
guard let self else {
@@ -214,6 +230,9 @@ final class NodeAppModel {
})
if Task.isCancelled { break }
+ await MainActor.run {
+ self.showLocalCanvasOnDisconnect()
+ }
attempt += 1
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -224,6 +243,7 @@ final class NodeAppModel {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
+ self.showLocalCanvasOnDisconnect()
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -235,6 +255,7 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
+ self.showLocalCanvasOnDisconnect()
}
}
}
@@ -249,6 +270,7 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
+ self.showLocalCanvasOnDisconnect()
}
func setGlobalWakeWords(_ words: [String]) async {
diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift
index 698df5d08..fc8826dd1 100644
--- a/apps/macos/Sources/Clawdis/CanvasManager.swift
+++ b/apps/macos/Sources/Clawdis/CanvasManager.swift
@@ -11,6 +11,12 @@ final class CanvasManager {
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
+ private var lastAutoA2UIUrl: String?
+ private var gatewayWatchTask: Task?
+
+ private init() {
+ self.startGatewayObserver()
+ }
var onPanelVisibilityChanged: ((Bool) -> Void)?
@@ -60,6 +66,7 @@ final class CanvasManager {
effectiveTarget: normalizedTarget)
}
+ self.maybeAutoNavigateToA2UIAsync(controller: controller)
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
@@ -93,6 +100,9 @@ final class CanvasManager {
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
+ if normalizedTarget == nil {
+ self.maybeAutoNavigateToA2UIAsync(controller: controller)
+ }
return self.makeShowResult(
directory: controller.directoryPath,
@@ -124,6 +134,55 @@ final class CanvasManager {
return try await controller.snapshot(to: outPath)
}
+ // MARK: - Gateway A2UI auto-nav
+
+ private func startGatewayObserver() {
+ self.gatewayWatchTask?.cancel()
+ self.gatewayWatchTask = Task { [weak self] in
+ guard let self else { return }
+ let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
+ for await push in stream {
+ await self.handleGatewayPush(push)
+ }
+ }
+ }
+
+ private func handleGatewayPush(_ push: GatewayPush) {
+ guard case let .snapshot(snapshot) = push else { return }
+ let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
+ guard let controller = self.panelController else { return }
+ self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
+ }
+
+ private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
+ Task { [weak self] in
+ guard let self else { return }
+ let a2uiUrl = await self.resolveA2UIHostUrl()
+ await MainActor.run {
+ guard self.panelController === controller else { return }
+ self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
+ }
+ }
+ }
+
+ private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
+ guard let a2uiUrl else { return }
+ guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
+ controller.load(target: a2uiUrl)
+ self.lastAutoA2UIUrl = a2uiUrl
+ }
+
+ private func resolveA2UIHostUrl() async -> String? {
+ let raw = await GatewayConnection.shared.canvasHostUrl()
+ return Self.resolveA2UIHostUrl(from: raw)
+ }
+
+ private static func resolveA2UIHostUrl(from raw: String?) -> String? {
+ let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
+ return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
+ }
+
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift
index d0fb3d344..d53a241c3 100644
--- a/apps/macos/Sources/Clawdis/CanvasWindow.swift
+++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift
@@ -43,6 +43,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
private var preferredPlacement: CanvasPlacement?
+ private(set) var currentTarget: String?
var onVisibilityChanged: ((Bool) -> Void)?
@@ -237,6 +238,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.currentTarget = trimmed
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
@@ -340,6 +342,18 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.sessionDir.path
}
+ func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
+ let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty || trimmed == "/" { return true }
+ if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !lastAuto.isEmpty,
+ trimmed == lastAuto
+ {
+ return true
+ }
+ return false
+ }
+
// MARK: - Window
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {