diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index 5ac059244..0e796c1fa 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -84,6 +84,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey)); const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); + const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); + const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); globalThis.addEventListener('a2uiaction', (evt) => { try { @@ -96,6 +98,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS const context = Array.isArray(action?.context) ? action.context : []; const userAction = { + id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())), name, surfaceId: payload.surfaceId ?? 'main', sourceComponentId: payload.sourceComponentId ?? '', @@ -117,7 +120,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS return; } - const message = 'A2UI action: ' + name + '\\n\\n```json\\n' + JSON.stringify(userAction, null, 2) + '\\n```'; + const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; + const message = + 'CANVAS_A2UI action=' + userAction.name + + ' session=' + sessionKey + + ' surface=' + userAction.surfaceId + + ' component=' + (userAction.sourceComponentId || '-') + + ' host=' + machineName.replace(/\\s+/g, '_') + + ' instance=' + instanceId + + ctx + + ' default=update_canvas'; const params = new URLSearchParams(); params.set('message', message); params.set('sessionKey', sessionKey); @@ -621,23 +633,24 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan guard !userAction.isEmpty else { return } guard let name = userAction["name"] as? String, !name.isEmpty else { return } - - let json: String = { - if let data = try? JSONSerialization.data(withJSONObject: userAction, options: [.prettyPrinted, .sortedKeys]), - let str = String(data: data, encoding: .utf8) - { - return str - } - return "" - }() + let actionId = + (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? UUID().uuidString canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") - let text = json.isEmpty - ? "A2UI action: \(name)" - : "A2UI action: \(name)\n\n```json\n\(json)\n```" + let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).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)" } ?? "" - Task { + // 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" + + Task { [weak webView] in if AppStateStore.shared.connectionMode == .local { GatewayProcessManager.shared.setActive(true) } @@ -648,13 +661,54 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan thinking: "low", deliver: false, to: nil, - channel: .last)) + channel: .last, + idempotencyKey: actionId)) + + await MainActor.run { + guard let webView else { return } + let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error) + webView.evaluateJavaScript(js) { _, _ in } + } if !result.ok { canvasWindowLogger.error( "A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)") } } } + + 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) }));" + } } // MARK: - Hover chrome container diff --git a/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js b/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js index 9e93fab96..43a94d58e 100644 --- a/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js +++ b/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js @@ -17726,17 +17726,25 @@ const clawdisTheme = { } }; var ClawdisA2UIHost = class extends i$1 { - static properties = { surfaces: { state: true } }; + static properties = { + surfaces: { state: true }, + pendingAction: { state: true }, + toast: { state: true } + }; #processor = Data.createSignalA2uiMessageProcessor(); #themeProvider = new i$2(this, { context: themeContext, initialValue: clawdisTheme }); surfaces = []; + pendingAction = null; + toast = null; + #statusListener = null; static styles = i` :host { display: block; height: 100%; + position: relative; box-sizing: border-box; padding: 12px; } @@ -17749,6 +17757,71 @@ var ClawdisA2UIHost = class extends i$1 { overflow: auto; padding-bottom: 24px; } + + .status { + position: absolute; + left: 50%; + transform: translateX(-50%); + top: 12px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + 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; + pointer-events: none; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); + z-index: 5; + } + + .toast { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 12px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + 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; + pointer-events: none; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25); + z-index: 5; + } + + .toast.error { + border-color: rgba(255, 109, 109, 0.35); + color: rgba(255, 223, 223, 0.98); + } + + .spinner { + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: rgba(255, 255, 255, 0.92); + animation: spin 0.75s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } `; connectedCallback() { super.connectedCallback(); @@ -17758,8 +17831,56 @@ var ClawdisA2UIHost = class extends i$1 { getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()) }; this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt)); + this.#statusListener = (evt) => this.#handleActionStatus(evt); + globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener); this.#syncSurfaces(); } + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#statusListener) { + globalThis.removeEventListener("clawdis:a2ui-action-status", this.#statusListener); + this.#statusListener = null; + } + } + #makeActionId() { + return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + #setToast(text$1, kind = "ok", timeoutMs = 1400) { + const toast = { + text: text$1, + kind, + expiresAt: Date.now() + timeoutMs + }; + this.toast = toast; + this.requestUpdate(); + setTimeout(() => { + if (this.toast === toast) { + this.toast = null; + this.requestUpdate(); + } + }, timeoutMs + 30); + } + #handleActionStatus(evt) { + const detail = evt?.detail ?? null; + if (!detail || typeof detail.id !== "string") return; + if (!this.pendingAction || this.pendingAction.id !== detail.id) return; + if (detail.ok) { + this.pendingAction = { + ...this.pendingAction, + phase: "sent", + sentAt: Date.now() + }; + } else { + const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed"; + this.pendingAction = { + ...this.pendingAction, + phase: "error", + error: msg + }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + this.requestUpdate(); + } #handleA2UIAction(evt) { const payload = evt?.detail ?? evt?.payload ?? null; if (!payload || payload.eventType !== "a2ui.action") { @@ -17806,7 +17927,16 @@ var ClawdisA2UIHost = class extends i$1 { continue; } } + const actionId = this.#makeActionId(); + this.pendingAction = { + id: actionId, + name, + phase: "sending", + startedAt: Date.now() + }; + this.requestUpdate(); const userAction = { + id: actionId, name, surfaceId: surfaceId ?? "main", sourceComponentId, @@ -17816,7 +17946,28 @@ var ClawdisA2UIHost = class extends i$1 { globalThis.__clawdisLastA2UIAction = userAction; const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction; if (handler?.postMessage) { - handler.postMessage({ userAction }); + try { + handler.postMessage({ userAction }); + } catch (e$14) { + const msg = String(e$14?.message ?? e$14); + this.pendingAction = { + id: actionId, + name, + phase: "error", + startedAt: Date.now(), + error: msg + }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + } else { + this.pendingAction = { + id: actionId, + name, + phase: "error", + startedAt: Date.now(), + error: "missing native bridge" + }; + this.#setToast("Failed: missing native bridge", "error", 4500); } } applyMessages(messages) { @@ -17825,6 +17976,10 @@ var ClawdisA2UIHost = class extends i$1 { } this.#processor.processMessages(messages); this.#syncSurfaces(); + if (this.pendingAction?.phase === "sent") { + this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100); + this.pendingAction = null; + } this.requestUpdate(); return { ok: true, @@ -17834,6 +17989,7 @@ var ClawdisA2UIHost = class extends i$1 { reset() { this.#processor.clearSurfaces(); this.#syncSurfaces(); + this.pendingAction = null; this.requestUpdate(); return { ok: true }; } @@ -17847,7 +18003,11 @@ var ClawdisA2UIHost = class extends i$1 {
Waiting for A2UI messages…
`; } - return x`
+ const statusText = this.pendingAction?.phase === "sent" ? `Working: ${this.pendingAction.name}` : this.pendingAction?.phase === "sending" ? `Sending: ${this.pendingAction.name}` : this.pendingAction?.phase === "error" ? `Failed: ${this.pendingAction.name}` : ""; + return x` + ${this.pendingAction && this.pendingAction.phase !== "error" ? x`
${statusText}
` : ""} + ${this.toast ? x`
${this.toast.text}
` : ""} +
${c(this.surfaces, ([surfaceId]) => surfaceId, ([surfaceId, surface]) => x` Array.from(this.#processor.getSurfaces().keys()), }; this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt)); + this.#statusListener = (evt) => this.#handleActionStatus(evt); + globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener); this.#syncSurfaces(); } + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#statusListener) { + globalThis.removeEventListener("clawdis:a2ui-action-status", this.#statusListener); + this.#statusListener = null; + } + } + + #makeActionId() { + return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + + #setToast(text, kind = "ok", timeoutMs = 1400) { + const toast = { text, kind, expiresAt: Date.now() + timeoutMs }; + this.toast = toast; + this.requestUpdate(); + setTimeout(() => { + if (this.toast === toast) { + this.toast = null; + this.requestUpdate(); + } + }, timeoutMs + 30); + } + + #handleActionStatus(evt) { + const detail = evt?.detail ?? null; + if (!detail || typeof detail.id !== "string") return; + if (!this.pendingAction || this.pendingAction.id !== detail.id) return; + + if (detail.ok) { + this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() }; + } else { + const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed"; + this.pendingAction = { ...this.pendingAction, phase: "error", error: msg }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + this.requestUpdate(); + } + #handleA2UIAction(evt) { const payload = evt?.detail ?? evt?.payload ?? null; if (!payload || payload.eventType !== "a2ui.action") { @@ -208,7 +320,12 @@ class ClawdisA2UIHost extends LitElement { } } + const actionId = this.#makeActionId(); + this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() }; + this.requestUpdate(); + const userAction = { + id: actionId, name, surfaceId: surfaceId ?? "main", sourceComponentId, @@ -220,7 +337,16 @@ class ClawdisA2UIHost extends LitElement { const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction; if (handler?.postMessage) { - handler.postMessage({ userAction }); + try { + handler.postMessage({ userAction }); + } catch (e) { + const msg = String(e?.message ?? e); + this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + } else { + this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" }; + this.#setToast("Failed: missing native bridge", "error", 4500); } } @@ -230,6 +356,10 @@ class ClawdisA2UIHost extends LitElement { } this.#processor.processMessages(messages); this.#syncSurfaces(); + if (this.pendingAction?.phase === "sent") { + this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100); + this.pendingAction = null; + } this.requestUpdate(); return { ok: true, surfaces: this.surfaces.map(([id]) => id) }; } @@ -237,6 +367,7 @@ class ClawdisA2UIHost extends LitElement { reset() { this.#processor.clearSurfaces(); this.#syncSurfaces(); + this.pendingAction = null; this.requestUpdate(); return { ok: true }; } @@ -253,7 +384,23 @@ class ClawdisA2UIHost extends LitElement { `; } - return html`
+ const statusText = + this.pendingAction?.phase === "sent" + ? `Working: ${this.pendingAction.name}` + : this.pendingAction?.phase === "sending" + ? `Sending: ${this.pendingAction.name}` + : this.pendingAction?.phase === "error" + ? `Failed: ${this.pendingAction.name}` + : ""; + + return html` + ${this.pendingAction && this.pendingAction.phase !== "error" + ? html`
${statusText}
` + : ""} + ${this.toast + ? html`
${this.toast.text}
` + : ""} +
${repeat( this.surfaces, ([surfaceId]) => surfaceId, diff --git a/docs/refactor/canvas-a2ui.md b/docs/refactor/canvas-a2ui.md index 30135f871..3682400f9 100644 --- a/docs/refactor/canvas-a2ui.md +++ b/docs/refactor/canvas-a2ui.md @@ -25,6 +25,23 @@ - Now it hops back to the main actor before mutating state. - Preserve in-page state when closing Canvas (hide the window instead of closing the `WKWebView`). - Fix another “Canvas looks hung” source: node pairing approval used `NSAlert.runModal()` on the main actor, which stalls Canvas/IPC while the alert is open. + - Add UX feedback + better agent prompting: + - Show a small “Sending/Working” spinner when a button is clicked. + - Show “Updated/Failed” toasts (failures include the gateway error string). + - Send a compact, unambiguous agent message that includes machine identity + Canvas context (instead of a big JSON markdown block). + - Native acks the click back into the page via `clawdis:a2ui-action-status` so the UI can switch from “Sending…” to “Working…” immediately. + +## Suggested message format (token-efficient) +We want the model to immediately understand: +- This is a **Canvas UI event** (not user chat). +- It happened on **this specific Mac**. +- Default behavior is to **update the Canvas UI** (unless the button context says otherwise). + +Proposed message line (single-line, parseable): + +``` +CANVAS_A2UI action= session= surface= component= host= instance= ctx= default=update_canvas +``` ## Follow-ups - Add a small “action sent / failed” debug overlay in the A2UI shell (dev-only) to make failures obvious.