Canvas: click progress + context-rich actions
This commit is contained in:
@@ -84,6 +84,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
|
|
||||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||||
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||||
|
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||||
|
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||||
|
|
||||||
globalThis.addEventListener('a2uiaction', (evt) => {
|
globalThis.addEventListener('a2uiaction', (evt) => {
|
||||||
try {
|
try {
|
||||||
@@ -96,6 +98,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
|
|
||||||
const context = Array.isArray(action?.context) ? action.context : [];
|
const context = Array.isArray(action?.context) ? action.context : [];
|
||||||
const userAction = {
|
const userAction = {
|
||||||
|
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
|
||||||
name,
|
name,
|
||||||
surfaceId: payload.surfaceId ?? 'main',
|
surfaceId: payload.surfaceId ?? 'main',
|
||||||
sourceComponentId: payload.sourceComponentId ?? '',
|
sourceComponentId: payload.sourceComponentId ?? '',
|
||||||
@@ -117,7 +120,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
return;
|
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();
|
const params = new URLSearchParams();
|
||||||
params.set('message', message);
|
params.set('message', message);
|
||||||
params.set('sessionKey', sessionKey);
|
params.set('sessionKey', sessionKey);
|
||||||
@@ -621,23 +633,24 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
guard !userAction.isEmpty else { return }
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||||
|
let actionId =
|
||||||
let json: String = {
|
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: userAction, options: [.prettyPrinted, .sortedKeys]),
|
?? UUID().uuidString
|
||||||
let str = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}()
|
|
||||||
|
|
||||||
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||||
|
|
||||||
let text = json.isEmpty
|
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||||
? "A2UI action: \(name)"
|
let sourceComponentId = (userAction["sourceComponentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||||
: "A2UI action: \(name)\n\n```json\n\(json)\n```"
|
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 {
|
if AppStateStore.shared.connectionMode == .local {
|
||||||
GatewayProcessManager.shared.setActive(true)
|
GatewayProcessManager.shared.setActive(true)
|
||||||
}
|
}
|
||||||
@@ -648,13 +661,54 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
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 {
|
if !result.ok {
|
||||||
canvasWindowLogger.error(
|
canvasWindowLogger.error(
|
||||||
"A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)")
|
"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
|
// MARK: - Hover chrome container
|
||||||
|
|||||||
@@ -17726,17 +17726,25 @@ const clawdisTheme = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
var ClawdisA2UIHost = class extends i$1 {
|
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();
|
#processor = Data.createSignalA2uiMessageProcessor();
|
||||||
#themeProvider = new i$2(this, {
|
#themeProvider = new i$2(this, {
|
||||||
context: themeContext,
|
context: themeContext,
|
||||||
initialValue: clawdisTheme
|
initialValue: clawdisTheme
|
||||||
});
|
});
|
||||||
surfaces = [];
|
surfaces = [];
|
||||||
|
pendingAction = null;
|
||||||
|
toast = null;
|
||||||
|
#statusListener = null;
|
||||||
static styles = i`
|
static styles = i`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
@@ -17749,6 +17757,71 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-bottom: 24px;
|
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() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -17758,8 +17831,56 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys())
|
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys())
|
||||||
};
|
};
|
||||||
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||||
|
this.#statusListener = (evt) => this.#handleActionStatus(evt);
|
||||||
|
globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener);
|
||||||
this.#syncSurfaces();
|
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) {
|
#handleA2UIAction(evt) {
|
||||||
const payload = evt?.detail ?? evt?.payload ?? null;
|
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||||
if (!payload || payload.eventType !== "a2ui.action") {
|
if (!payload || payload.eventType !== "a2ui.action") {
|
||||||
@@ -17806,7 +17927,16 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const actionId = this.#makeActionId();
|
||||||
|
this.pendingAction = {
|
||||||
|
id: actionId,
|
||||||
|
name,
|
||||||
|
phase: "sending",
|
||||||
|
startedAt: Date.now()
|
||||||
|
};
|
||||||
|
this.requestUpdate();
|
||||||
const userAction = {
|
const userAction = {
|
||||||
|
id: actionId,
|
||||||
name,
|
name,
|
||||||
surfaceId: surfaceId ?? "main",
|
surfaceId: surfaceId ?? "main",
|
||||||
sourceComponentId,
|
sourceComponentId,
|
||||||
@@ -17816,7 +17946,28 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
globalThis.__clawdisLastA2UIAction = userAction;
|
globalThis.__clawdisLastA2UIAction = userAction;
|
||||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||||
if (handler?.postMessage) {
|
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) {
|
applyMessages(messages) {
|
||||||
@@ -17825,6 +17976,10 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
}
|
}
|
||||||
this.#processor.processMessages(messages);
|
this.#processor.processMessages(messages);
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
|
if (this.pendingAction?.phase === "sent") {
|
||||||
|
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
|
||||||
|
this.pendingAction = null;
|
||||||
|
}
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -17834,6 +17989,7 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
reset() {
|
reset() {
|
||||||
this.#processor.clearSurfaces();
|
this.#processor.clearSurfaces();
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
|
this.pendingAction = null;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -17847,7 +18003,11 @@ var ClawdisA2UIHost = class extends i$1 {
|
|||||||
<div>Waiting for A2UI messages…</div>
|
<div>Waiting for A2UI messages…</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
return x`<section id="surfaces">
|
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`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>` : ""}
|
||||||
|
${this.toast ? x`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>` : ""}
|
||||||
|
<section id="surfaces">
|
||||||
${c(this.surfaces, ([surfaceId]) => surfaceId, ([surfaceId, surface]) => x`<a2ui-surface
|
${c(this.surfaces, ([surfaceId]) => surfaceId, ([surfaceId, surface]) => x`<a2ui-surface
|
||||||
.surfaceId=${surfaceId}
|
.surfaceId=${surfaceId}
|
||||||
.surface=${surface}
|
.surface=${surface}
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ const clawdisTheme = {
|
|||||||
class ClawdisA2UIHost extends LitElement {
|
class ClawdisA2UIHost extends LitElement {
|
||||||
static properties = {
|
static properties = {
|
||||||
surfaces: { state: true },
|
surfaces: { state: true },
|
||||||
|
pendingAction: { state: true },
|
||||||
|
toast: { state: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
|
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
|
||||||
@@ -124,11 +126,15 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
surfaces = [];
|
surfaces = [];
|
||||||
|
pendingAction = null;
|
||||||
|
toast = null;
|
||||||
|
#statusListener = null;
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,71 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-bottom: 24px;
|
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() {
|
connectedCallback() {
|
||||||
@@ -151,9 +222,50 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
|
||||||
};
|
};
|
||||||
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
|
||||||
|
this.#statusListener = (evt) => this.#handleActionStatus(evt);
|
||||||
|
globalThis.addEventListener("clawdis:a2ui-action-status", this.#statusListener);
|
||||||
this.#syncSurfaces();
|
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) {
|
#handleA2UIAction(evt) {
|
||||||
const payload = evt?.detail ?? evt?.payload ?? null;
|
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||||
if (!payload || payload.eventType !== "a2ui.action") {
|
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 = {
|
const userAction = {
|
||||||
|
id: actionId,
|
||||||
name,
|
name,
|
||||||
surfaceId: surfaceId ?? "main",
|
surfaceId: surfaceId ?? "main",
|
||||||
sourceComponentId,
|
sourceComponentId,
|
||||||
@@ -220,7 +337,16 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
|
|
||||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||||
if (handler?.postMessage) {
|
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.#processor.processMessages(messages);
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
|
if (this.pendingAction?.phase === "sent") {
|
||||||
|
this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
|
||||||
|
this.pendingAction = null;
|
||||||
|
}
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
|
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
|
||||||
}
|
}
|
||||||
@@ -237,6 +367,7 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
reset() {
|
reset() {
|
||||||
this.#processor.clearSurfaces();
|
this.#processor.clearSurfaces();
|
||||||
this.#syncSurfaces();
|
this.#syncSurfaces();
|
||||||
|
this.pendingAction = null;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -253,7 +384,23 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<section id="surfaces">
|
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`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
|
||||||
|
: ""}
|
||||||
|
${this.toast
|
||||||
|
? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
|
||||||
|
: ""}
|
||||||
|
<section id="surfaces">
|
||||||
${repeat(
|
${repeat(
|
||||||
this.surfaces,
|
this.surfaces,
|
||||||
([surfaceId]) => surfaceId,
|
([surfaceId]) => surfaceId,
|
||||||
|
|||||||
@@ -25,6 +25,23 @@
|
|||||||
- Now it hops back to the main actor before mutating state.
|
- 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`).
|
- 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.
|
- 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=<name> session=<sessionKey> surface=<surfaceId> component=<componentId> host=<machine> instance=<instanceId> ctx=<json?> default=update_canvas
|
||||||
|
```
|
||||||
|
|
||||||
## Follow-ups
|
## Follow-ups
|
||||||
- Add a small “action sent / failed” debug overlay in the A2UI shell (dev-only) to make failures obvious.
|
- Add a small “action sent / failed” debug overlay in the A2UI shell (dev-only) to make failures obvious.
|
||||||
|
|||||||
Reference in New Issue
Block a user