fix: align canvas defaults and A2UI auto-nav
This commit is contained in:
162
apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
Normal file
162
apps/android/app/src/main/assets/CanvasScaffold/scaffold.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<!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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
z-index: 1;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
#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>
|
||||
@@ -109,6 +109,8 @@ class NodeRuntime(context: Context) {
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
@@ -118,6 +120,7 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onEvent = { event, payloadJson ->
|
||||
@@ -136,6 +139,21 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
|
||||
@@ -44,6 +44,10 @@ class CanvasController {
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
val wv = webView ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
|
||||
@@ -28,6 +28,7 @@ final class NodeAppModel {
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
|
||||
@@ -147,6 +148,20 @@ final class NodeAppModel {
|
||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
||||
}
|
||||
|
||||
private func showA2UIOnConnectIfNeeded() async {
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
|
||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||
self.screen.navigate(to: a2uiUrl)
|
||||
self.lastAutoA2uiURL = a2uiUrl
|
||||
}
|
||||
}
|
||||
|
||||
private func showLocalCanvasOnDisconnect() {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .background:
|
||||
@@ -202,6 +217,7 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
await self.startVoiceWakeSync()
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
@@ -214,6 +230,9 @@ final class NodeAppModel {
|
||||
})
|
||||
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
attempt += 1
|
||||
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
@@ -224,6 +243,7 @@ final class NodeAppModel {
|
||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
@@ -235,6 +255,7 @@ final class NodeAppModel {
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +270,7 @@ final class NodeAppModel {
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func setGlobalWakeWords(_ words: [String]) async {
|
||||
|
||||
@@ -11,6 +11,12 @@ final class CanvasManager {
|
||||
|
||||
private var panelController: CanvasWindowController?
|
||||
private var panelSessionKey: String?
|
||||
private var lastAutoA2UIUrl: String?
|
||||
private var gatewayWatchTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
self.startGatewayObserver()
|
||||
}
|
||||
|
||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
@@ -60,6 +66,7 @@ final class CanvasManager {
|
||||
effectiveTarget: normalizedTarget)
|
||||
}
|
||||
|
||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
||||
return CanvasShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
@@ -93,6 +100,9 @@ final class CanvasManager {
|
||||
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
|
||||
controller.showCanvas(path: effectiveTarget)
|
||||
Self.logger.debug("showDetailed showCanvas done")
|
||||
if normalizedTarget == nil {
|
||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
||||
}
|
||||
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
@@ -124,6 +134,55 @@ final class CanvasManager {
|
||||
return try await controller.snapshot(to: outPath)
|
||||
}
|
||||
|
||||
// MARK: - Gateway A2UI auto-nav
|
||||
|
||||
private func startGatewayObserver() {
|
||||
self.gatewayWatchTask?.cancel()
|
||||
self.gatewayWatchTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
|
||||
for await push in stream {
|
||||
await self.handleGatewayPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGatewayPush(_ push: GatewayPush) {
|
||||
guard case let .snapshot(snapshot) = push else { return }
|
||||
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
|
||||
guard let controller = self.panelController else { return }
|
||||
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
|
||||
}
|
||||
|
||||
private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let a2uiUrl = await self.resolveA2UIHostUrl()
|
||||
await MainActor.run {
|
||||
guard self.panelController === controller else { return }
|
||||
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
|
||||
guard let a2uiUrl else { return }
|
||||
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
|
||||
controller.load(target: a2uiUrl)
|
||||
self.lastAutoA2UIUrl = a2uiUrl
|
||||
}
|
||||
|
||||
private func resolveA2UIHostUrl() async -> String? {
|
||||
let raw = await GatewayConnection.shared.canvasHostUrl()
|
||||
return Self.resolveA2UIHostUrl(from: raw)
|
||||
}
|
||||
|
||||
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
private static func mouseAnchorProvider() -> NSRect? {
|
||||
|
||||
@@ -43,6 +43,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
private let container: HoverChromeContainerView
|
||||
let presentation: CanvasPresentation
|
||||
private var preferredPlacement: CanvasPlacement?
|
||||
private(set) var currentTarget: String?
|
||||
|
||||
var onVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
@@ -237,6 +238,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
|
||||
func load(target: String) {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.currentTarget = trimmed
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "https" || scheme == "http" {
|
||||
@@ -340,6 +342,18 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.sessionDir.path
|
||||
}
|
||||
|
||||
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
|
||||
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "/" { return true }
|
||||
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!lastAuto.isEmpty,
|
||||
trimmed == lastAuto
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
|
||||
Reference in New Issue
Block a user