Approval cards show again: the buttons no longer throw, the command is the item's detail
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s

An agent chat's bash approval left "Waiting for you to allow bash…" with no card: cardOf()
called append(actions, a, b, c) while append() took one array, so `children.flat` threw for
every approval and plan card (questions used el.append and worked). renderCards() had already
unhidden #cards, which stayed empty. append() now takes children as rest arguments too.

- The editable command is `detail` (services/device_link.py puts the fixed "Run a command" in
  the title); it is sent back as the item's text only when changed. Non-editable approvals
  show the title and the device's input. `purpose` ("It says: …"), `reason` and the asking
  subagent (`job`) are shown, as on the web page.
- renderCards() keeps a card already on the screen (by id) instead of drawing every card again
  on each `ask` frame, which the server re-sends whenever the countdown changes; a card that
  cannot be drawn shows the error instead of an empty box.
- tests/ui/: headless harness with a mocked Tauri bridge; five A1 cases (device bash card
  shows and is visible, Allow/edit/Deny post to /chats/{id}/interactions/{card}, redraw keeps
  edits, reopen gets the card from the stream's first pass, 390 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 08:13:02 +00:00
1 parent 10c82a3c7b
commit 5a87eec28d
7 files changed
+537 -17

No files matched your search

+21 -5
View File
@@ -11,6 +11,7 @@
const LEMBAS_I18N = {
"cs": {
"%(job)s asks:": "%(job)s se ptá:",
"%(model)s was busy, so it ran on this one.": "%(model)s byl zaneprázdněný, proto běžela na tomto.",
"%(n)s finished": "Dokončeno: %(n)s",
"%(n)s missed": "zmeškáno: %(n)s",
@@ -120,6 +121,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Nainstalovat LLeMbas %(version)s…",
"Instruction files": "Soubory s pokyny",
"It has not run yet.": "Ještě neběžel.",
"It says: %(purpose)s": "Model uvádí: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potřebuje server LLeMbas 2.0 nebo novější.",
"Last run": "Poslední běh",
@@ -340,6 +342,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ se zastaví a její historie zmizí. Konverzace, které napsala, zůstanou."
},
"de": {
"%(job)s asks:": "%(job)s fragt:",
"%(model)s was busy, so it ran on this one.": "%(model)s war belegt, daher lief sie auf diesem.",
"%(n)s finished": "%(n)s beendet",
"%(n)s missed": "%(n)s verpasst",
@@ -449,6 +452,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…",
"Instruction files": "Anweisungsdateien",
"It has not run yet.": "Er ist noch nicht gelaufen.",
"It says: %(purpose)s": "Es sagt: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop braucht einen LLeMbas-Server 2.0 oder neuer.",
"Last run": "Letzter Lauf",
@@ -669,6 +673,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ hört auf, und ihr Verlauf wird gelöscht. Die Chats, die sie geschrieben hat, bleiben."
},
"es": {
"%(job)s asks:": "%(job)s pregunta:",
"%(model)s was busy, so it ran on this one.": "%(model)s estaba ocupado, así que se ejecutó en este.",
"%(n)s finished": "%(n)s terminados",
"%(n)s missed": "%(n)s perdidas",
@@ -778,6 +783,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…",
"Instruction files": "Archivos de instrucciones",
"It has not run yet.": "Todavía no se ha ejecutado.",
"It says: %(purpose)s": "Dice: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop necesita un servidor LLeMbas 2.0 o posterior.",
"Last run": "Última ejecución",
@@ -790,13 +796,13 @@ const LEMBAS_I18N = {
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
"MCP tools": "Herramientas MCP",
"Manual — asks first": "Manual — pregunta primero",
"Max": "Máx.",
"Max": "Máx",
"Maximise": "Maximizar",
"Memory and personal": "Memoria y datos personales",
"Menu": "Menú",
"Message": "Mensaje",
"Messages": "Mensajes",
"Min": "Mín.",
"Min": "Mín",
"Minimise": "Minimizar",
"Missed": "Perdida",
"Mode": "Modo",
@@ -998,6 +1004,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» se detiene y su historial se borra. Los chats que escribió se conservan."
},
"fr": {
"%(job)s asks:": "%(job)s demande :",
"%(model)s was busy, so it ran on this one.": "%(model)s était occupé, elle a donc tourné sur celui-ci.",
"%(n)s finished": "%(n)s terminés",
"%(n)s missed": "%(n)s manquées",
@@ -1107,6 +1114,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…",
"Instruction files": "Fichiers d’instructions",
"It has not run yet.": "Elle ne s'est pas encore exécutée.",
"It says: %(purpose)s": "Il dit : %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop nécessite un serveur LLeMbas 2.0 ou plus récent.",
"Last run": "Dernière exécution",
@@ -1327,6 +1335,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "« %(title)s » s’arrête et son historique disparaît. Les conversations qu’elle a écrites restent."
},
"hu": {
"%(job)s asks:": "%(job)s kérdezi:",
"%(model)s was busy, so it ran on this one.": "%(model)s foglalt volt, ezért ezen futott.",
"%(n)s finished": "%(n)s befejezve",
"%(n)s missed": "%(n)s kimaradt",
@@ -1436,6 +1445,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…",
"Instruction files": "Utasításfájlok",
"It has not run yet.": "Még nem futott.",
"It says: %(purpose)s": "Ezt mondja: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "A LLeMbas Desktophoz LLeMbas 2.0 vagy újabb szerver kell.",
"Last run": "Utolsó futtatás",
@@ -1448,13 +1458,13 @@ const LEMBAS_I18N = {
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
"MCP tools": "MCP-eszközök",
"Manual — asks first": "Manual — előbb kérdez",
"Max": "Max.",
"Max": "Max",
"Maximise": "Teljes méret",
"Memory and personal": "Memória és személyes adatok",
"Menu": "Menü",
"Message": "Üzenet",
"Messages": "Üzenetek",
"Min": "Min.",
"Min": "Min",
"Minimise": "Kis méret",
"Missed": "Elmaradt",
"Mode": "Mód",
@@ -1656,6 +1666,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” leáll, és az előzményei törlődnek. Az általa írt beszélgetések megmaradnak."
},
"it": {
"%(job)s asks:": "%(job)s chiede:",
"%(model)s was busy, so it ran on this one.": "%(model)s era occupato, quindi è stata eseguita su questo.",
"%(n)s finished": "%(n)s terminati",
"%(n)s missed": "%(n)s perse",
@@ -1765,6 +1776,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…",
"Instruction files": "File di istruzioni",
"It has not run yet.": "Non è ancora stata eseguita.",
"It says: %(purpose)s": "Dice: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop richiede un server LLeMbas 2.0 o successivo.",
"Last run": "Ultima esecuzione",
@@ -1985,6 +1997,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» si ferma e la sua cronologia viene eliminata. Le chat che ha scritto restano."
},
"pl": {
"%(job)s asks:": "%(job)s pyta:",
"%(model)s was busy, so it ran on this one.": "%(model)s był zajęty, więc uruchomiono je na tym.",
"%(n)s finished": "Zakończone: %(n)s",
"%(n)s missed": "pominięte: %(n)s",
@@ -2094,6 +2107,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…",
"Instruction files": "Pliki z instrukcjami",
"It has not run yet.": "Jeszcze się nie uruchomił.",
"It says: %(purpose)s": "Mówi: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop wymaga serwera LLeMbas 2.0 lub nowszego.",
"Last run": "Ostatni przebieg",
@@ -2106,7 +2120,7 @@ const LEMBAS_I18N = {
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
"MCP tools": "Narzędzia MCP",
"Manual — asks first": "Manual — najpierw pyta",
"Max": "Maks",
"Max": "Max",
"Maximise": "Maksymalizuj",
"Memory and personal": "Pamięć i dane osobiste",
"Menu": "Menu",
@@ -2314,6 +2328,7 @@ const LEMBAS_I18N = {
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” zatrzyma się, a jego historia zniknie. Czaty, które napisało, zostaną."
},
"sk": {
"%(job)s asks:": "%(job)s sa pýta:",
"%(model)s was busy, so it ran on this one.": "%(model)s bol zaneprázdnený, preto bežala na tomto.",
"%(n)s finished": "Dokončené: %(n)s",
"%(n)s missed": "zmeškané: %(n)s",
@@ -2423,6 +2438,7 @@ const LEMBAS_I18N = {
"Install LLeMbas %(version)s…": "Nainštalovať LLeMbas %(version)s…",
"Instruction files": "Súbory s pokynmi",
"It has not run yet.": "Ešte nebežal.",
"It says: %(purpose)s": "Model uvádza: %(purpose)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potrebuje server LLeMbas 2.0 alebo novší.",
"Last run": "Posledný beh",
+47 -11
View File
@@ -417,10 +417,32 @@ function follower(chat, messageId) {
// --- Cards ----------------------------------------------------------------------------------------
/**
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
* countdown included, and drawing it again would take away a command being corrected or an option
* already picked. New ones are added in the server's order; the ones no longer listed go.
*/
function renderCards(chat, cards) {
const box = clear($("cards"))
box.hidden = !cards.length
for (const card of cards) box.append(cardOf(chat, card))
const box = $("cards")
const ids = cards.map((c) => String(c.id))
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
cards.forEach((card, i) => {
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
if (!el) {
try {
el = cardOf(chat, card)
} catch (err) {
// Never an empty box under a status that says the reply waits: say what went wrong.
console.error("card", card, err)
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
}
el.dataset.card = String(card.id)
el.dataset.chat = chat.id
}
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
})
box.hidden = !box.children.length
}
function cardOf(chat, card) {
@@ -430,24 +452,39 @@ function cardOf(chat, card) {
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
for (const item of card.items) {
const part = h("div", { class: "ask__item" })
// A background subagent asks, not the conversation's own turn: said first (`job`).
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
let detailShown = false
if (item.kind === "approval") {
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
if (item.editable) {
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
// this item's text, and only when it differs -- an untouched one is not an edit.
const command = item.detail || item.title
detailShown = true
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
edit.addEventListener("input", () => {
if (edit.value !== item.title) answers.texts[item.key] = edit.value
if (edit.value !== command) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else part.append(h("pre", { class: "call__pre", text: item.title }))
} else {
// What it wants to do (a tool's own title), then its input as the device sent it.
part.append(h("p", { class: "ask__what", text: item.title }))
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
detailShown = true
}
} else if (item.kind === "plan" && item.plan) {
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
} else {
part.append(h("p", { class: "ask__title", text: item.title }))
}
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
// The model's account of what it is for, attributed to it; then the server's reason for asking.
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
if (item.options?.length) {
const picked = new Set()
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
@@ -506,12 +543,11 @@ function cardOf(chat, card) {
const actions = h("div", { class: "ask__actions" })
if (decides) {
const isPlan = card.items.some((i) => i.kind === "plan")
append(
actions,
append(actions, [
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
)
])
} else {
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
}
+2 -1
View File
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
return el
}
export function append(el, children) {
/** append(el, child, [more], …): children one by one or in arrays, nested as deep as they come. */
export function append(el, ...children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))