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"
compileSdk = 36
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
}
}
defaultConfig {
applicationId = "com.steipete.clawdis.node"
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()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String = "main") {
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.protocol.ClawdisCapability
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.ClawdisCanvasCommand
import com.steipete.clawdis.node.voice.VoiceWakeManager
@@ -364,6 +365,79 @@ class NodeRuntime(context: Context) {
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") {
chat.load(sessionKey)
}
@@ -581,11 +655,15 @@ class NodeRuntime(context: Context) {
val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue) {
val obj =
json.parseToJsonElement(raw) as? JsonObject
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
val jsonl = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val obj =
json.parseToJsonElement(raw) as? JsonObject
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
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")
val messages =
jsonl
@@ -604,9 +682,6 @@ class NodeRuntime(context: Context) {
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 out =
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 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 =
"""

View File

@@ -20,6 +20,8 @@ class CanvasController {
@Volatile private var mode: Mode = Mode.CANVAS
@Volatile private var url: String = ""
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
fun attach(webView: WebView) {
this.webView = webView
reload()
@@ -59,7 +61,7 @@ class CanvasController {
if (trimmed.isBlank()) return@withWebViewOnMain
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.util.Log
import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebViewClient
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.background
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.icons.Icons
@@ -42,10 +40,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@@ -80,7 +75,6 @@ fun RootScreen(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
Box(modifier = Modifier.fillMaxSize()) {
CanvasBackdrop(modifier = Modifier.fillMaxSize())
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}
@@ -187,73 +181,30 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
)
}
}
setBackgroundColor(Color.TRANSPARENT)
setBackgroundResource(0)
// 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.
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
addJavascriptInterface(
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
},
CanvasA2UIActionBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
},
)
}
@Composable
private fun CanvasBackdrop(modifier: Modifier = Modifier) {
val base = MaterialTheme.colorScheme.background
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
@JavascriptInterface
fun postMessage(payload: String?) {
val msg = payload?.trim().orEmpty()
if (msg.isEmpty()) return
onMessage(msg)
}
Canvas(modifier = modifier.background(base)) {
// Subtle idle backdrop; also acts as fallback when WebView content is transparent or fails to load.
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,
),
)
companion object {
const val interfaceName: String = "clawdisCanvasA2UIAction"
}
}

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 Observation
import SwiftUI
import UIKit
@MainActor
@Observable
@@ -43,6 +44,90 @@ final class NodeAppModel {
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) {
@@ -339,8 +424,14 @@ final class NodeAppModel {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else {
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
do {
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()

View File

@@ -8,6 +8,7 @@ import WebKit
final class ScreenController {
let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
var mode: ClawdisCanvasMode = .canvas
var urlString: String = ""
@@ -16,14 +17,23 @@ final class ScreenController {
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
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() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.isOpaque = false
self.webView.backgroundColor = .clear
self.webView.scrollView.backgroundColor = .clear
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.isOpaque = true
self.webView.backgroundColor = .black
self.webView.scrollView.backgroundColor = .black
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero
@@ -33,6 +43,7 @@ final class ScreenController {
self.webView.scrollView.bounces = false
self.webView.navigationDelegate = self.navigationDelegate
self.navigationDelegate.controller = self
a2uiActionHandler.controller = self
self.reload()
}
@@ -61,18 +72,16 @@ final class ScreenController {
self.webView.load(URLRequest(url: url))
}
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 {
guard let url = ClawdisKitResources.bundle.url(
forResource: "index",
withExtension: "html",
subdirectory: "CanvasA2UI")
guard let url = Self.a2uiIndexURL
else {
throw NSError(domain: "Canvas", code: 10, userInfo: [
NSLocalizedDescriptionKey: "A2UI resources missing (CanvasA2UI/index.html)",
NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
])
}
self.mode = .web
@@ -145,164 +154,17 @@ final class ScreenController {
return data.base64EncodedString()
}
private static let canvasScaffoldHTML = """
<!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');
// SwiftPM flattens resource directories; ensure resource filenames are unique.
private static let canvasScaffoldURL: URL? = ClawdisKitResources.bundle.url(
forResource: "scaffold",
withExtension: "html")
private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html")
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>
"""
fileprivate func isBundledA2UIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
guard let expected = Self.a2uiIndexURL else { return false }
return url.standardizedFileURL == expected.standardizedFileURL
}
}
// MARK: - Navigation Delegate
@@ -333,3 +195,31 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
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()
#expect(screen.mode == .canvas)
#expect(screen.webView.isOpaque == false)
#expect(screen.webView.backgroundColor == .clear)
#expect(screen.webView.isOpaque == true)
#expect(screen.webView.backgroundColor == .black)
let scrollView = screen.webView.scrollView
#expect(scrollView.backgroundColor == .clear)
#expect(scrollView.backgroundColor == .black)
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
#expect(scrollView.isScrollEnabled == false)
#expect(scrollView.bounces == false)

View File

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

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import Foundation
import OSLog
import QuartzCore
@@ -149,7 +150,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
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 webView = self.webView
@@ -646,23 +648,18 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
.nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = Self.compactJSON(userAction["context"])
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let text =
[
"CANVAS_A2UI",
"action=\(Self.sanitizeTagValue(name))",
"session=\(Self.sanitizeTagValue(self.sessionKey))",
"surface=\(Self.sanitizeTagValue(surfaceId))",
"component=\(Self.sanitizeTagValue(sourceComponentId))",
"host=\(host)",
"instance=\(instanceId)\(contextSuffix)",
"default=update_canvas",
].joined(separator: " ")
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: name,
sessionKey: self.sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: InstanceIdentity.displayName,
instanceId: instanceId,
contextJSON: contextJSON)
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
@@ -680,7 +677,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
await MainActor.run {
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 }
}
if !result.ok {
@@ -690,39 +687,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
}
}
private static func sanitizeTagValue(_ value: String) -> String {
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) }));"
}
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
}
// 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
}
}

View File

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

View File

@@ -3,4 +3,3 @@ import Foundation
public enum ClawdisKitResources {
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; }
html, body { height: 100%; margin: 0; }
body {
font: 13px -apple-system, system-ui;
font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
background: #0b1020;
color: #e5e7eb;
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 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 = {
components: {
AudioPlayer: emptyClasses(),
@@ -85,7 +91,7 @@ const clawdisTheme = {
border: "1px solid rgba(255,255,255,.09)",
borderRadius: "14px",
padding: "14px",
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
boxShadow: cardShadow,
},
Column: { gap: "10px" },
Row: { gap: "10px", alignItems: "center" },
@@ -98,7 +104,7 @@ const clawdisTheme = {
color: "#071016",
fontWeight: "650",
cursor: "pointer",
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
boxShadow: buttonShadow,
},
Text: {
...textHintStyles(),
@@ -161,11 +167,11 @@ class ClawdisA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
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;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(${statusBlur});
-webkit-backdrop-filter: blur(${statusBlur});
box-shadow: ${statusShadow};
z-index: 5;
}
@@ -182,11 +188,11 @@ class ClawdisA2UIHost extends LitElement {
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.18);
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;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(${statusBlur});
-webkit-backdrop-filter: blur(${statusBlur});
box-shadow: ${statusShadow};
z-index: 5;
}
@@ -335,10 +341,17 @@ class ClawdisA2UIHost extends LitElement {
globalThis.__clawdisLastA2UIAction = userAction;
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
const handler =
globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction ??
globalThis.clawdisCanvasA2UIAction;
if (handler?.postMessage) {
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) {
const msg = String(e?.message ?? e);
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };

View File

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