From 5adec0eae0c587cffc0fac8a8cd3057b253e3913 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 12:32:36 +0100 Subject: [PATCH] fix: align canvas defaults and A2UI auto-nav --- .../main/assets/CanvasScaffold/scaffold.html | 162 ++++++++++++++++++ .../com/steipete/clawdis/node/NodeRuntime.kt | 18 ++ .../clawdis/node/node/CanvasController.kt | 4 + apps/ios/Sources/Model/NodeAppModel.swift | 22 +++ .../macos/Sources/Clawdis/CanvasManager.swift | 59 +++++++ apps/macos/Sources/Clawdis/CanvasWindow.swift | 14 ++ 6 files changed, 279 insertions(+) create mode 100644 apps/android/app/src/main/assets/CanvasScaffold/scaffold.html 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 {