Canvas: click progress + context-rich actions

This commit is contained in:
Peter Steinberger
2025-12-17 20:19:37 +01:00
parent 56d94e6974
commit 9051c5891e
4 changed files with 398 additions and 20 deletions

View File

@@ -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 {
<div>Waiting for A2UI messages…</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
.surfaceId=${surfaceId}
.surface=${surface}

View File

@@ -115,6 +115,8 @@ const clawdisTheme = {
class ClawdisA2UIHost extends LitElement {
static properties = {
surfaces: { state: true },
pendingAction: { state: true },
toast: { state: true },
};
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
@@ -124,11 +126,15 @@ class ClawdisA2UIHost extends LitElement {
});
surfaces = [];
pendingAction = null;
toast = null;
#statusListener = null;
static styles = css`
:host {
display: block;
height: 100%;
position: relative;
box-sizing: border-box;
padding: 12px;
}
@@ -141,6 +147,71 @@ class ClawdisA2UIHost extends LitElement {
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() {
@@ -151,9 +222,50 @@ class ClawdisA2UIHost extends LitElement {
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, 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 {
</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(
this.surfaces,
([surfaceId]) => surfaceId,