A2UI: share web UI and action bridge

This commit is contained in:
Peter Steinberger
2025-12-18 11:38:32 +01:00
parent 8a343aedf2
commit c61bd6c84d
24 changed files with 809 additions and 18655 deletions

View File

@@ -9,6 +9,12 @@ android {
namespace = "com.steipete.clawdis.node" namespace = "com.steipete.clawdis.node"
compileSdk = 36 compileSdk = 36
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
}
}
defaultConfig { defaultConfig {
applicationId = "com.steipete.clawdis.node" applicationId = "com.steipete.clawdis.node"
minSdk = 31 minSdk = 31

File diff suppressed because one or more lines are too long

View File

@@ -1,23 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas</title>
<style>
:root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; }
body {
font: 13px -apple-system, system-ui;
background: #0b1020;
color: #e5e7eb;
overflow: hidden;
}
clawdis-a2ui-host { display: block; height: 100%; }
</style>
</head>
<body>
<clawdis-a2ui-host></clawdis-a2ui-host>
<script src="a2ui.bundle.js"></script>
</body>
</html>

View File

@@ -97,6 +97,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect() runtime.disconnect()
} }
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String = "main") { fun loadChat(sessionKey: String = "main") {
runtime.loadChat(sessionKey) runtime.loadChat(sessionKey)
} }

View File

@@ -18,6 +18,7 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.protocol.ClawdisCapability import com.steipete.clawdis.node.protocol.ClawdisCapability
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
import com.steipete.clawdis.node.voice.VoiceWakeManager import com.steipete.clawdis.node.voice.VoiceWakeManager
@@ -364,6 +365,79 @@ class NodeRuntime(context: Context) {
session.disconnect() session.disconnect()
} }
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch {
val trimmed = payloadJson.trim()
if (trimmed.isEmpty()) return@launch
val root =
try {
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
} catch (_: Throwable) {
return@launch
}
val userActionObj = (root["userAction"] as? JsonObject) ?: root
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
if (name.isEmpty()) return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = "main"
val message =
ClawdisCanvasA2UIAction.formatAgentMessage(
actionName = name,
sessionKey = sessionKey,
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
val connected = isConnected.value
var error: String? = null
if (connected) {
try {
session.sendEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(message))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
put("key", JsonPrimitive(actionId))
}.toString(),
)
} catch (e: Throwable) {
error = e.message ?: "send failed"
}
} else {
error = "bridge not connected"
}
try {
canvas.eval(
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId,
ok = connected && error == null,
error = error,
),
)
} catch (_: Throwable) {
// ignore
}
}
}
fun loadChat(sessionKey: String = "main") { fun loadChat(sessionKey: String = "main") {
chat.load(sessionKey) chat.load(sessionKey)
} }
@@ -581,11 +655,15 @@ class NodeRuntime(context: Context) {
val raw = paramsJson?.trim().orEmpty() val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required") if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue) { val obj =
val obj = json.parseToJsonElement(raw) as? JsonObject
json.parseToJsonElement(raw) as? JsonObject ?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
val jsonl = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val hasMessagesArray = obj["messages"] is JsonArray
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
val messages = val messages =
jsonl jsonl
@@ -604,9 +682,6 @@ class NodeRuntime(context: Context) {
return JsonArray(messages).toString() return JsonArray(messages).toString()
} }
val obj =
json.parseToJsonElement(raw) as? JsonObject
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required") val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
val out = val out =
arr.mapIndexed { idx, el -> arr.mapIndexed { idx, el ->
@@ -638,7 +713,7 @@ class NodeRuntime(context: Context) {
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D) private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
private const val a2uiIndexUrl: String = "file:///android_asset/canvas_a2ui/index.html" private const val a2uiIndexUrl: String = "file:///android_asset/CanvasA2UI/index.html"
private const val a2uiReadyCheckJS: String = private const val a2uiReadyCheckJS: String =
""" """

View File

@@ -20,6 +20,8 @@ class CanvasController {
@Volatile private var mode: Mode = Mode.CANVAS @Volatile private var mode: Mode = Mode.CANVAS
@Volatile private var url: String = "" @Volatile private var url: String = ""
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
fun attach(webView: WebView) { fun attach(webView: WebView) {
this.webView = webView this.webView = webView
reload() reload()
@@ -59,7 +61,7 @@ class CanvasController {
if (trimmed.isBlank()) return@withWebViewOnMain if (trimmed.isBlank()) return@withWebViewOnMain
wv.loadUrl(trimmed) wv.loadUrl(trimmed)
} }
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null) Mode.CANVAS -> wv.loadUrl(scaffoldAssetUrl)
} }
} }
} }
@@ -143,158 +145,3 @@ class CanvasController {
} }
} }
} }
private val canvasHtml =
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
}
html,body { height:100%; margin:0; }
body {
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;
overflow: hidden;
}
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: clawdis-grid-drift 140s ease-in-out infinite alternate;
}
body::after {
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);
pointer-events: none;
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
}
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes clawdis-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 clawdis-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; }
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-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);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#clawdis-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);
}
#clawdis-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);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
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;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
};
})();
</script>
</body>
</html>
""".trimIndent()

View File

@@ -0,0 +1,49 @@
package com.steipete.clawdis.node.protocol
object ClawdisCanvasA2UIAction {
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
val out = StringBuilder(normalized.length)
for (c in normalized) {
val ok =
c.isLetterOrDigit() ||
c == '_' ||
c == '-' ||
c == '.' ||
c == ':'
out.append(if (ok) c else '_')
}
return out.toString()
}
fun formatAgentMessage(
actionName: String,
sessionKey: String,
surfaceId: String,
sourceComponentId: String,
host: String,
instanceId: String,
contextJson: String?,
): String {
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
return listOf(
"CANVAS_A2UI",
"action=${sanitizeTagValue(actionName)}",
"session=${sanitizeTagValue(sessionKey)}",
"surface=${sanitizeTagValue(surfaceId)}",
"component=${sanitizeTagValue(sourceComponentId)}",
"host=${sanitizeTagValue(host)}",
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
"default=update_canvas",
).joinToString(separator = " ")
}
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
val okLiteral = if (ok) "true" else "false"
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
}
}

View File

@@ -6,13 +6,13 @@ import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -24,12 +24,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.background
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -42,10 +40,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -80,7 +75,6 @@ fun RootScreen(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CanvasBackdrop(modifier = Modifier.fillMaxSize())
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
} }
@@ -187,73 +181,30 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
) )
} }
} }
setBackgroundColor(Color.TRANSPARENT) setBackgroundColor(Color.BLACK)
setBackgroundResource(0) setLayerType(View.LAYER_TYPE_HARDWARE, null)
// WebView transparency + HW acceleration can render as solid black on some Android/WebView builds.
// Prefer correct alpha blending since we render the idle backdrop in Compose underneath. addJavascriptInterface(
setLayerType(View.LAYER_TYPE_SOFTWARE, null) CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
},
CanvasA2UIActionBridge.interfaceName,
)
viewModel.canvas.attach(this) viewModel.canvas.attach(this)
} }
}, },
) )
} }
@Composable private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
private fun CanvasBackdrop(modifier: Modifier = Modifier) { @JavascriptInterface
val base = MaterialTheme.colorScheme.background fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
Canvas(modifier = modifier.background(base)) { companion object {
// Subtle idle backdrop; also acts as fallback when WebView content is transparent or fails to load. const val interfaceName: String = "clawdisCanvasA2UIAction"
drawRect(
brush =
Brush.linearGradient(
colors =
listOf(
ComposeColor(0xFF0A2034),
ComposeColor(0xFF070A10),
ComposeColor(0xFF250726),
),
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
),
)
val step = 48f * density
val lineColor = ComposeColor.White.copy(alpha = 0.028f)
var x = -step
while (x < size.width + step) {
drawLine(color = lineColor, start = Offset(x, 0f), end = Offset(x, size.height), strokeWidth = 1f)
x += step
}
var y = -step
while (y < size.height + step) {
drawLine(color = lineColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f)
y += step
}
drawRect(
brush =
Brush.radialGradient(
colors = listOf(ComposeColor(0xFF2A71FF).copy(alpha = 0.22f), ComposeColor.Transparent),
center = Offset(size.width * 0.15f, size.height * 0.20f),
radius = size.minDimension * 0.9f,
),
)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(ComposeColor(0xFFFF008A).copy(alpha = 0.18f), ComposeColor.Transparent),
center = Offset(size.width * 0.85f, size.height * 0.30f),
radius = size.minDimension * 0.75f,
),
)
drawRect(
brush =
Brush.radialGradient(
colors = listOf(ComposeColor(0xFF00D1FF).copy(alpha = 0.14f), ComposeColor.Transparent),
center = Offset(size.width * 0.60f, size.height * 0.90f),
radius = size.minDimension * 0.85f,
),
)
} }
} }

View File

@@ -0,0 +1,35 @@
package com.steipete.clawdis.node.protocol
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdisCanvasA2UIActionTest {
@Test
fun formatAgentMessageMatchesSharedSpec() {
val msg =
ClawdisCanvasA2UIAction.formatAgentMessage(
actionName = "Get Weather",
sessionKey = "main",
surfaceId = "main",
sourceComponentId = "btnWeather",
host = "Peters iPad",
instanceId = "ipad16,6",
contextJson = "{\"city\":\"Vienna\"}",
)
assertEquals(
"CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas",
msg,
)
}
@Test
fun jsDispatchA2uiStatusIsStable() {
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
assertEquals(
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
js,
)
}
}

View File

@@ -2,6 +2,7 @@ import ClawdisKit
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
@MainActor @MainActor
@Observable @Observable
@@ -43,6 +44,90 @@ final class NodeAppModel {
await self.handleDeepLink(url: url) await self.handleDeepLink(url: url)
} }
} }
// Wire up A2UI action clicks (buttons, etc.)
self.screen.onA2UIAction = { [weak self] body in
guard let self else { return }
Task { @MainActor in
await self.handleCanvasA2UIAction(body: body)
}
}
}
private func handleCanvasA2UIAction(body: [String: Any]) async {
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id
}()
let surfaceId: String = {
let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? "main" : raw
}()
let sourceComponentId: String = {
let raw = (userAction[
"sourceComponentId",
] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? "-" : raw
}()
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main"
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: name,
sessionKey: sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: host,
instanceId: instanceId,
contextJSON: contextJSON)
let ok: Bool
var errorText: String? = nil
if await !self.isBridgeConnected() {
ok = false
errorText = "bridge not connected"
} else {
do {
try await self.sendAgentRequest(link: AgentDeepLink(
message: message,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: actionId))
ok = true
} catch {
ok = false
errorText = error.localizedDescription
}
}
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
do {
_ = try await self.screen.eval(javaScript: js)
} catch {
// ignore
}
} }
func setScenePhase(_ phase: ScenePhase) { func setScenePhase(_ phase: ScenePhase) {
@@ -339,8 +424,14 @@ final class NodeAppModel {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else { } else {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON) do {
messages = params.messages let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
} catch {
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
}
} }
try self.screen.showA2UI() try self.screen.showA2UI()

View File

@@ -8,6 +8,7 @@ import WebKit
final class ScreenController { final class ScreenController {
let webView: WKWebView let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
var mode: ClawdisCanvasMode = .canvas var mode: ClawdisCanvasMode = .canvas
var urlString: String = "" var urlString: String = ""
@@ -16,14 +17,23 @@ final class ScreenController {
/// Callback invoked when a clawdis:// deep link is tapped in the canvas /// Callback invoked when a clawdis:// deep link is tapped in the canvas
var onDeepLink: ((URL) -> Void)? var onDeepLink: ((URL) -> Void)?
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
var onA2UIAction: (([String: Any]) -> Void)?
init() { init() {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent() config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate() self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
self.webView = WKWebView(frame: .zero, configuration: config) self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.isOpaque = false // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.backgroundColor = .clear self.webView.isOpaque = true
self.webView.scrollView.backgroundColor = .clear self.webView.backgroundColor = .black
self.webView.scrollView.backgroundColor = .black
self.webView.scrollView.contentInsetAdjustmentBehavior = .never self.webView.scrollView.contentInsetAdjustmentBehavior = .never
self.webView.scrollView.contentInset = .zero self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero self.webView.scrollView.scrollIndicatorInsets = .zero
@@ -33,6 +43,7 @@ final class ScreenController {
self.webView.scrollView.bounces = false self.webView.scrollView.bounces = false
self.webView.navigationDelegate = self.navigationDelegate self.webView.navigationDelegate = self.navigationDelegate
self.navigationDelegate.controller = self self.navigationDelegate.controller = self
a2uiActionHandler.controller = self
self.reload() self.reload()
} }
@@ -61,18 +72,16 @@ final class ScreenController {
self.webView.load(URLRequest(url: url)) self.webView.load(URLRequest(url: url))
} }
case .canvas: case .canvas:
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil) guard let url = Self.canvasScaffoldURL else { return }
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} }
} }
func showA2UI() throws { func showA2UI() throws {
guard let url = ClawdisKitResources.bundle.url( guard let url = Self.a2uiIndexURL
forResource: "index",
withExtension: "html",
subdirectory: "CanvasA2UI")
else { else {
throw NSError(domain: "Canvas", code: 10, userInfo: [ throw NSError(domain: "Canvas", code: 10, userInfo: [
NSLocalizedDescriptionKey: "A2UI resources missing (CanvasA2UI/index.html)", NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
]) ])
} }
self.mode = .web self.mode = .web
@@ -145,164 +154,17 @@ final class ScreenController {
return data.base64EncodedString() return data.base64EncodedString()
} }
private static let canvasScaffoldHTML = """ // SwiftPM flattens resource directories; ensure resource filenames are unique.
<!doctype html> private static let canvasScaffoldURL: URL? = ClawdisKitResources.bundle.url(
<html> forResource: "scaffold",
<head> withExtension: "html")
<meta charset="utf-8" /> private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html")
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
}
html,body { height:100%; margin:0; }
body {
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;
overflow: hidden;
}
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: clawdis-grid-drift 140s ease-in-out infinite alternate;
}
body::after {
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);
pointer-events: none;
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
}
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes clawdis-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 clawdis-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; }
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-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);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#clawdis-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);
}
#clawdis-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);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
function resize() { fileprivate func isBundledA2UIURL(_ url: URL) -> Bool {
const dpr = window.devicePixelRatio || 1; guard url.isFileURL else { return false }
const w = Math.max(1, Math.floor(window.innerWidth * dpr)); guard let expected = Self.a2uiIndexURL else { return false }
const h = Math.max(1, Math.floor(window.innerHeight * dpr)); return url.standardizedFileURL == expected.standardizedFileURL
canvas.width = w; }
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
// Auto-hide after 3 seconds
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
};
})();
</script>
</body>
</html>
"""
} }
// MARK: - Navigation Delegate // MARK: - Navigation Delegate
@@ -333,3 +195,31 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
decisionHandler(.allow) decisionHandler(.allow)
} }
} }
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
weak var controller: ScreenController?
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == Self.messageName else { return }
guard let controller else { return }
// Only accept actions from local bundled CanvasA2UI content (not arbitrary web pages).
guard let url = message.webView?.url, controller.isBundledA2UIURL(url) else { return }
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }
if let dict = message.body as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !body.isEmpty else { return }
controller.onA2UIAction?(body)
}
}

View File

@@ -7,11 +7,11 @@ import WebKit
let screen = ScreenController() let screen = ScreenController()
#expect(screen.mode == .canvas) #expect(screen.mode == .canvas)
#expect(screen.webView.isOpaque == false) #expect(screen.webView.isOpaque == true)
#expect(screen.webView.backgroundColor == .clear) #expect(screen.webView.backgroundColor == .black)
let scrollView = screen.webView.scrollView let scrollView = screen.webView.scrollView
#expect(scrollView.backgroundColor == .clear) #expect(scrollView.backgroundColor == .black)
#expect(scrollView.contentInsetAdjustmentBehavior == .never) #expect(scrollView.contentInsetAdjustmentBehavior == .never)
#expect(scrollView.isScrollEnabled == false) #expect(scrollView.isScrollEnabled == false)
#expect(scrollView.bounces == false) #expect(scrollView.bounces == false)

View File

@@ -205,8 +205,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
} }
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse { private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in A2UI shell. // Default Canvas UX: when no index exists, show the built-in scaffold page.
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") { if let data = self.loadBundledResourceData(relativePath: "scaffold.html") {
return CanvasResponse(mime: "text/html", data: data) return CanvasResponse(mime: "text/html", data: data)
} }
@@ -234,7 +234,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html("Forbidden", title: "Canvas: 403") return self.html("Forbidden", title: "Canvas: 403")
} }
guard let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: relative) else { guard let data = self.loadBundledResourceData(relativePath: relative) else {
return self.html("Not Found", title: "Canvas: 404") return self.html("Not Found", title: "Canvas: 404")
} }
@@ -243,12 +243,15 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return CanvasResponse(mime: mime, data: data) return CanvasResponse(mime: mime, data: data)
} }
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? { private func loadBundledResourceData(relativePath: String) -> Data? {
guard let base = ClawdisKitResources.bundle.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else { // SwiftPM flattens resource directories; treat bundled canvas resources as uniquely-named files.
return nil if relativePath.contains("/") { return nil }
} let url = URL(fileURLWithPath: relativePath)
let url = base.appendingPathComponent(relativePath, isDirectory: false) let ext = url.pathExtension
return try? Data(contentsOf: url) let name = url.deletingPathExtension().lastPathComponent
guard !name.isEmpty, !ext.isEmpty else { return nil }
guard let resourceURL = ClawdisKitResources.bundle.url(forResource: name, withExtension: ext) else { return nil }
return try? Data(contentsOf: resourceURL)
} }
private func textEncodingName(forMimeType mimeType: String) -> String? { private func textEncodingName(forMimeType mimeType: String) -> String? {

View File

@@ -1,5 +1,6 @@
import AppKit import AppKit
import ClawdisIPC import ClawdisIPC
import ClawdisKit
import Foundation import Foundation
import OSLog import OSLog
import QuartzCore import QuartzCore
@@ -149,7 +150,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView") canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config) self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.setValue(false, forKey: "drawsBackground") // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.setValue(true, forKey: "drawsBackground")
let sessionDir = self.sessionDir let sessionDir = self.sessionDir
let webView = self.webView let webView = self.webView
@@ -646,23 +648,18 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
.nonEmpty ?? "main" .nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)? let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
let instanceId = InstanceIdentity.instanceId.lowercased() let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = Self.compactJSON(userAction["context"]) let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas. // Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let text = let text = ClawdisCanvasA2UIAction.formatAgentMessage(
[ actionName: name,
"CANVAS_A2UI", sessionKey: self.sessionKey,
"action=\(Self.sanitizeTagValue(name))", surfaceId: surfaceId,
"session=\(Self.sanitizeTagValue(self.sessionKey))", sourceComponentId: sourceComponentId,
"surface=\(Self.sanitizeTagValue(surfaceId))", host: InstanceIdentity.displayName,
"component=\(Self.sanitizeTagValue(sourceComponentId))", instanceId: instanceId,
"host=\(host)", contextJSON: contextJSON)
"instance=\(instanceId)\(contextSuffix)",
"default=update_canvas",
].joined(separator: " ")
Task { [weak webView] in Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local { if AppStateStore.shared.connectionMode == .local {
@@ -680,7 +677,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
await MainActor.run { await MainActor.run {
guard let webView else { return } guard let webView else { return }
let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error) let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
webView.evaluateJavaScript(js) { _, _ in } webView.evaluateJavaScript(js) { _, _ in }
} }
if !result.ok { if !result.ok {
@@ -690,39 +687,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
} }
} }
private static func sanitizeTagValue(_ value: String) -> String { // Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func compactJSON(_ obj: Any?) -> String? {
guard let obj else { return nil }
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
private static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
let payload: [String: Any] = [
"id": actionId,
"ok": ok,
"error": error ?? "",
]
let json: String = {
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
let str = String(data: data, encoding: .utf8)
{
return str
}
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
}()
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
}
} }
// MARK: - Hover chrome container // MARK: - Hover chrome container

View File

@@ -0,0 +1,61 @@
import Foundation
public enum ClawdisCanvasA2UIAction: Sendable {
public static func sanitizeTagValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
let normalized = nonEmpty.replacingOccurrences(of: " ", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
public static func compactJSON(_ obj: Any?) -> String? {
guard let obj else { return nil }
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
public static func formatAgentMessage(
actionName: String,
sessionKey: String,
surfaceId: String,
sourceComponentId: String,
host: String,
instanceId: String,
contextJSON: String?)
-> String
{
let ctxSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
return [
"CANVAS_A2UI",
"action=\(self.sanitizeTagValue(actionName))",
"session=\(self.sanitizeTagValue(sessionKey))",
"surface=\(self.sanitizeTagValue(surfaceId))",
"component=\(self.sanitizeTagValue(sourceComponentId))",
"host=\(self.sanitizeTagValue(host))",
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
"default=update_canvas",
].joined(separator: " ")
}
public static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
let payload: [String: Any] = [
"id": actionId,
"ok": ok,
"error": error ?? "",
]
let json: String = {
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
let str = String(data: data, encoding: .utf8)
{
return str
}
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
}()
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
}
}

View File

@@ -24,4 +24,3 @@ public struct ClawdisCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
self.jsonl = jsonl self.jsonl = jsonl
} }
} }

View File

@@ -73,4 +73,3 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
return json return json
} }
} }

View File

@@ -3,4 +3,3 @@ import Foundation
public enum ClawdisKitResources { public enum ClawdisKitResources {
public static let bundle: Bundle = .module public static let bundle: Bundle = .module
} }

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@
:root { color-scheme: light dark; } :root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; } html, body { height: 100%; margin: 0; }
body { body {
font: 13px -apple-system, system-ui; font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
background: #0b1020; background: #0b1020;
color: #e5e7eb; color: #e5e7eb;
overflow: hidden; overflow: hidden;

View File

@@ -0,0 +1,158 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
}
html,body { height:100%; margin:0; }
body {
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;
overflow: hidden;
}
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: clawdis-grid-drift 140s ease-in-out infinite alternate;
}
body::after {
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);
pointer-events: none;
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
}
@supports (mix-blend-mode: screen) {
body::after { mix-blend-mode: screen; }
}
@supports not (mix-blend-mode: screen) {
body::after { opacity: 0.70; }
}
@keyframes clawdis-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 clawdis-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; }
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-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);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
#clawdis-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);
}
#clawdis-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);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
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;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
// Auto-hide after 3 seconds.
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
};
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
import ClawdisKit
import Foundation
import Testing
@Suite struct CanvasA2UIActionTests {
@Test func sanitizeTagValueIsStable() {
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue(" ") == "-")
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
}
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: "Get Weather",
sessionKey: "main",
surfaceId: "main",
sourceComponentId: "btnWeather",
host: "Peters iPad",
instanceId: "ipad16,6",
contextJSON: "{\"city\":\"Vienna\"}")
#expect(msg.contains("CANVAS_A2UI "))
#expect(msg.contains("action=Get_Weather"))
#expect(msg.contains("session=main"))
#expect(msg.contains("surface=main"))
#expect(msg.contains("component=btnWeather"))
#expect(msg.contains("host=Peter_s_iPad"))
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
#expect(msg.hasSuffix(" default=update_canvas"))
}
@Test func a2uiBundleSupportsAndroidBridgeFallback() throws {
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
else {
throw NSError(domain: "Tests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
])
}
let js = try String(contentsOf: url, encoding: .utf8)
#expect(js.contains("clawdisCanvasA2UIAction"))
#expect(js.contains("globalThis.clawdisCanvasA2UIAction"))
}
}

View File

@@ -10,6 +10,12 @@ const empty = Object.freeze({});
const emptyClasses = () => ({}); const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";
const clawdisTheme = { const clawdisTheme = {
components: { components: {
AudioPlayer: emptyClasses(), AudioPlayer: emptyClasses(),
@@ -85,7 +91,7 @@ const clawdisTheme = {
border: "1px solid rgba(255,255,255,.09)", border: "1px solid rgba(255,255,255,.09)",
borderRadius: "14px", borderRadius: "14px",
padding: "14px", padding: "14px",
boxShadow: "0 10px 30px rgba(0,0,0,.35)", boxShadow: cardShadow,
}, },
Column: { gap: "10px" }, Column: { gap: "10px" },
Row: { gap: "10px", alignItems: "center" }, Row: { gap: "10px", alignItems: "center" },
@@ -98,7 +104,7 @@ const clawdisTheme = {
color: "#071016", color: "#071016",
fontWeight: "650", fontWeight: "650",
cursor: "pointer", cursor: "pointer",
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)", boxShadow: buttonShadow,
}, },
Text: { Text: {
...textHintStyles(), ...textHintStyles(),
@@ -161,11 +167,11 @@ class ClawdisA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
pointer-events: none; pointer-events: none;
backdrop-filter: blur(14px); backdrop-filter: blur(${statusBlur});
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(${statusBlur});
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); box-shadow: ${statusShadow};
z-index: 5; z-index: 5;
} }
@@ -182,11 +188,11 @@ class ClawdisA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
pointer-events: none; pointer-events: none;
backdrop-filter: blur(14px); backdrop-filter: blur(${statusBlur});
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(${statusBlur});
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); box-shadow: ${statusShadow};
z-index: 5; z-index: 5;
} }
@@ -335,10 +341,17 @@ class ClawdisA2UIHost extends LitElement {
globalThis.__clawdisLastA2UIAction = userAction; globalThis.__clawdisLastA2UIAction = userAction;
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction; const handler =
globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction ??
globalThis.clawdisCanvasA2UIAction;
if (handler?.postMessage) { if (handler?.postMessage) {
try { try {
handler.postMessage({ userAction }); // WebKit message handlers support structured objects; Android's JS interface expects strings.
if (handler === globalThis.clawdisCanvasA2UIAction) {
handler.postMessage(JSON.stringify({ userAction }));
} else {
handler.postMessage({ userAction });
}
} catch (e) { } catch (e) {
const msg = String(e?.message ?? e); const msg = String(e?.message ?? e);
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg }; this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };

View File

@@ -1,8 +1,9 @@
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "rolldown"; import { defineConfig } from "rolldown";
const here = path.dirname(new URL(import.meta.url).pathname); const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../../../.."); const repoRoot = path.resolve(here, "../../../../..");
const fromHere = (p) => path.resolve(here, p); const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js"); const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js");