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

+16
View File
@@ -6,6 +6,22 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased]
### Fixed
- **Approvals show again.** An agent asking to run a command, edit a file or carry out a plan
stopped with "Waiting for you to allow bash…" and no card to answer it: drawing the card's
buttons failed, and the space for it stayed empty. Stopping and asking again gave the same.
Now the card shows, with the command in the box to correct before it runs (it used to show the
words "Run a command" there instead), what the model says it is for, why it is asking, and which
subagent asks when one does. A card already on the screen is kept as it is while the reply goes
on, so a command being corrected or an option picked is no longer lost when the server sends
the list again; a card that cannot be drawn says why instead of leaving an empty space.
### Added
- `tests/ui/`: the page under a headless browser against a stand-in for the app's bridge, with
the server's answers as a table (`node tests/ui/run.mjs`, needs Playwright and a Chromium).
## [1.0.0-beta.5] — 2026-11-09
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
+2
View File
@@ -61,6 +61,8 @@ KEYS = (
"The agent asks to run %(tool)s:",
"The agent asks:",
"Command",
"%(job)s asks:",
"It says: %(purpose)s",
"Recommended",
"Or type an answer",
"Your answer",
+100
View File
@@ -0,0 +1,100 @@
// A stand-in for Tauri's bridge, for the page under a headless browser (tests/ui/run.mjs). Runs
// before the page's own scripts: `window.__TAURI__` answers the commands the page invokes from a
// table the test hands in, and records every call, so a test can say what the page asked for.
//
// The table (`config`):
// status what `get_status` answers (the app, the agent, the server)
// routes { "GET /me": value, "POST /chats/c1/interactions/k1": value, ... } -- the server's
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
// not in the table is a 404, as an older server would answer.
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
//
// Nothing here talks to a network: the page's policy allows it none, and this keeps it so.
;(function () {
const config = window.__mockConfig || {}
const listeners = new Map() // event name -> [callback]
const streams = new Map() // stream id -> path
let nextStream = 0
const calls = []
function dispatch(name, payload) {
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
}
function route(method, path) {
const routes = config.routes || {}
const bare = path.split("?")[0]
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
if (Object.prototype.hasOwnProperty.call(routes, key)) {
const value = routes[key]
if (value && value.__error) return Promise.reject(value.__error)
return Promise.resolve(JSON.parse(JSON.stringify(value)))
}
}
return Promise.reject({ status: 404, detail: `Not in the test's table: ${method} ${path}` })
}
function frame(path, event, data) {
for (const [id, p] of streams) if (p === path) dispatch("stream", { id, event, data })
}
async function invoke(cmd, args) {
calls.push({ cmd, args: args === undefined ? null : JSON.parse(JSON.stringify(args)) })
switch (cmd) {
case "get_status":
return JSON.parse(JSON.stringify(config.status))
case "api":
return route(args.method, args.path)
case "api_text":
return route("GET", args.path)
case "stream_open": {
const id = ++nextStream
streams.set(id, args.path)
const frames = (config.streams || {})[args.path] || []
setTimeout(() => {
for (const [event, data] of frames) dispatch("stream", { id, event, data })
}, 0)
return id
}
case "stream_close":
streams.delete(args.id)
return null
case "model_image":
return ""
case "terminal_word":
return "lembas"
default:
return null
}
}
const win = {
minimize: async () => {},
toggleMaximize: async () => {},
close: async () => {},
isMaximized: async () => false,
}
window.__TAURI__ = {
core: { invoke },
event: {
listen: async (name, cb) => {
if (!listeners.has(name)) listeners.set(name, [])
listeners.get(name).push(cb)
return () => listeners.set(name, (listeners.get(name) || []).filter((x) => x !== cb))
},
},
window: { getCurrentWindow: () => win },
}
window.__mock = {
calls,
frame,
emit: dispatch,
/** The calls of one command, or of `api` with one method and path prefix. */
api: (method, prefix) => calls.filter((c) => c.cmd === "api" && c.args.method === method && c.args.path.startsWith(prefix)),
streams: () => [...streams.values()],
}
})()
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env node
// The page under a headless browser, against a mocked bridge (tests/ui/mock-tauri.js): what the
// person sees for a given answer of the server's /api/v2, and what the page sends back.
//
// node tests/ui/run.mjs [name-filter]
//
// Needs Playwright (`playwright` or `playwright-core`, from NODE_PATH or a node_modules beside
// the repository) and a Chromium: Playwright's own, or the one in $CHROMIUM (/usr/bin/chromium
// is tried when Playwright has none). Nothing is built and no server is started: the page's
// files are handed to the browser from ui/ as they are in the repository. With $SHOTS set to a
// directory, some tests leave a screenshot there.
//
// The shapes are the server's (`api/v2/serialise.py`, its fixtures in tests/fixtures/api_v2),
// with made-up names. Where the server's fixture differs from what its code sends, the test
// says so and follows the code.
import { createRequire } from "node:module"
import { readFileSync, existsSync } from "node:fs"
import { dirname, join, extname } from "node:path"
import { fileURLToPath } from "node:url"
import assert from "node:assert/strict"
const HERE = dirname(fileURLToPath(import.meta.url))
const UI = join(HERE, "..", "..", "ui")
const ORIGIN = "http://app.test"
const require = createRequire(join(process.cwd(), "noop.js"))
function playwright() {
for (const name of ["playwright", "playwright-core"]) {
try {
return require(name)
} catch {}
try {
return createRequire(import.meta.url)(name)
} catch {}
}
console.error("tests/ui: Playwright is not installed (npm install playwright-core, and put it on NODE_PATH)")
process.exit(2)
}
async function launch() {
const { chromium } = playwright()
const paths = [process.env.CHROMIUM, "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean)
if (!process.env.CHROMIUM) {
try {
return await chromium.launch()
} catch {}
}
for (const executablePath of paths) if (existsSync(executablePath)) return chromium.launch({ executablePath })
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
}
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
// --- The server, as the tests' table -----------------------------------------------------------
const STAMP = "2026-01-02T03:04:05+00:00"
function status(over = {}) {
return {
version: "1.0.0-test",
server: "https://ai.example",
discovery: { name: "Example", app: "https://ai.example/api/v2", version: "2.0.0" },
refused: null,
checked: true,
account: { name: "Ada Example", email: "ada@example.org" },
signing_in: false,
linked: true,
agent: { state: "running", detail: null },
agent_wanted: true,
update: null,
remote: { enabled: true, roots: ["/home/dev/project"] },
theme: "moria",
host: "workbench",
notify: false,
...over,
}
}
const SUB = (offered, named_only = false) => ({ offered, named_only })
/** The models as `/api/v2/models` lists them; `subagents` per model as the server works it out. */
function models({ qwen = SUB(true), solo = SUB(true, true), plain = SUB(false) } = {}) {
const base = { vision: false, tools: true, efforts: ["low", "medium", "high"], thinking_switch: false, levels: ["low", "medium", "high"], effort: "medium", control: "slider", context: 131072, image: null }
return {
models: [
{ ...base, id: "qwen", name: "Qwen", parallel: 2, default: true, subagents: qwen },
{ ...base, id: "solo", name: "Solo", parallel: 1, default: false, subagents: solo },
{ ...base, id: "plain", name: "Plain", parallel: 1, default: false, subagents: plain },
],
subagents_cap: 8,
}
}
function chatRow(over = {}) {
return {
id: "chat1",
kind: "chat",
title: "Waybread",
model: "qwen",
folder_id: null,
pinned: false,
archived: false,
temporary: false,
unread: false,
running: false,
mode: "",
effort: "medium",
device: null,
device_session_id: null,
parent_chat_id: null,
agents: { min: 1, max: 4 },
subagents: { offered: true, named_only: false, cap: 8 },
created_at: STAMP,
updated_at: STAMP,
...over,
}
}
function agentChat(over = {}) {
return chatRow({
id: "chat2",
kind: "agent",
title: "Tidy the build",
running: true,
mode: "edit",
device: { token_id: "token1", directory: "/home/dev/project" },
device_session_id: "ses_01",
// An agent chat's team runs on its device: the server offers no setting (team_run.may_have_team).
subagents: { offered: false, named_only: false, cap: 8 },
...over,
})
}
const userMessage = (id, text) => ({ id, role: "user", created_at: STAMP, complete: true, stopped: false, error: null, queued: false, machine: false, origin: "", model: null, attachments: [], text })
/**
* A device's bash approval as `services/device_link.py:_permission` builds it and
* `api/v2/serialise.py:interruption` sends it: the title is the fixed "Run a command", the command
* is in `detail`, and it is `editable`. (The server's fixture `event_ask.json` puts the command in
* the title; its code does not.)
*/
function deviceBashCard(over = {}) {
return {
id: "card7",
kind: "approval",
why: "",
dismissable: false,
expires_in: 1800,
items: [
{
index: 0,
key: "0",
kind: "approval",
tool: "bash",
title: "Run a command",
header: "",
detail: "ls -la build",
reason: "Edit mode asks before anything that runs a command.",
purpose: "See what is in the build directory",
options: [],
multiple: false,
free_text: true,
editable: true,
plan: null,
mode_choice: false,
job: "",
},
],
...over,
}
}
function baseRoutes(over = {}) {
return {
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {} },
"GET /models": models(),
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", linked: true }] },
"GET /chats": { chats: [chatRow(), agentChat()], more: false },
"GET /schedules": { schedules: [] },
...over,
}
}
// --- The browser --------------------------------------------------------------------------------
let browser
async function page(config, { width = 1280, height = 860 } = {}) {
const context = await browser.newContext({ viewport: { width, height } })
const p = await context.newPage()
const errors = []
p.on("pageerror", (e) => errors.push(String(e)))
await context.route(`${ORIGIN}/**`, (route) => {
const path = new URL(route.request().url()).pathname
const file = join(UI, path === "/" ? "index.html" : decodeURIComponent(path))
if (!file.startsWith(UI) || !existsSync(file)) return route.fulfill({ status: 404, body: "" })
return route.fulfill({ status: 200, contentType: TYPES[extname(file)] || "application/octet-stream", body: readFileSync(file) })
})
await p.addInitScript((c) => {
window.__mockConfig = c
}, config)
await p.addInitScript({ path: join(HERE, "mock-tauri.js") })
await p.goto(`${ORIGIN}/index.html`)
await p.waitForSelector("#shell:not([hidden])")
p.errors = errors
return p
}
async function openChatRow(p, id) {
await p.click(`.chatrow[data-id="${id}"]`)
await p.waitForSelector(`#messages .msg`)
}
// --- The tests ----------------------------------------------------------------------------------
const tests = []
const test = (name, fn) => tests.push({ name, fn })
const STREAM2 = "/chats/chat2/messages/m2/stream"
function agentChatOpen(frames, over = {}) {
return {
status: status(),
routes: baseRoutes({
"GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "What is in build?")], more: false, live: "m2" },
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
"POST /chats/chat2/interactions/card8": { answered: true, remembered: false, not_remembered: [] },
"GET /models?chat=chat2": models(),
"POST /chats/chat2/interactions/card7": { answered: true, remembered: false, not_remembered: [] },
...over,
}),
streams: { [STREAM2]: frames },
}
}
test("A1: an agent chat's device bash approval shows as a card, the command editable, and Allow answers it", async () => {
const card = deviceBashCard()
const p = await page(
agentChatOpen([
["steps", { steps: [], tail: { thinking: false, text: "", label: "" } }],
["status", { text: "Waiting for you to allow bash…" }],
["ask", { cards: [card] }],
]),
)
await openChatRow(p, "chat2")
await p.waitForSelector("#cards:not([hidden]) .ask")
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Waiting for you to allow bash…")
const cardBox = await p.locator("#cards .ask").boundingBox()
const view = p.viewportSize()
assert.ok(cardBox && cardBox.height > 40, "the card has a size")
assert.ok(cardBox.y >= 0 && cardBox.y + 20 <= view.height, `the card is on the screen (y=${cardBox.y})`)
assert.ok(await p.locator("#cards .ask").isVisible(), "the card is visible")
// The command is the item's `detail`, in the box to correct it; the title is not the command.
assert.equal(await p.inputValue("#cards .ask__edit"), "ls -la build")
const text = await p.textContent("#cards .ask")
assert.match(text, /bash/)
assert.match(text, /See what is in the build directory/, "the model's purpose is said")
assert.match(text, /Edit mode asks before anything that runs a command\./, "the server's reason is said")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "a1-card.png") })
await p.click("#cards .ask__actions .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
assert.equal(post.args.path, "/chats/chat2/interactions/card7")
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: {} }, "an untouched command is not sent as an edit")
await p.waitForSelector("#cards", { state: "hidden" })
assert.deepEqual(p.errors, [])
})
test("A1: a corrected command is sent as the item's text; Deny sends deny", async () => {
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask__edit")
await p.fill("#cards .ask__edit", "ls -la build/out")
await p.click("#cards .ask__actions .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: { 0: "ls -la build/out" } })
// The next one, refused.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c] }), [STREAM2, deviceBashCard({ id: "card8" })])
await p.waitForSelector("#cards .ask__edit")
await p.click("#cards .ask__actions .btn >> text=Deny")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/card8").length === 1)
const [deny] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/card8"))
assert.deepEqual(deny.args.body, { verdict: "deny", choices: {}, texts: {} })
assert.deepEqual(p.errors, [])
})
test("A1: a card that arrives while the person is typing keeps what they typed and is not drawn again by an unchanged frame", async () => {
const card = deviceBashCard()
const p = await page(agentChatOpen([["ask", { cards: [card] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask__edit")
await p.fill("#cards .ask__edit", "ls build")
// The server sends the frame again when anything in it changes -- here only the countdown.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [{ ...c, expires_in: 1790 }] }), [STREAM2, card])
await p.evaluate(([path]) => window.__mock.frame(path, "status", { text: "Waiting for you to allow bash…" }), [STREAM2])
assert.equal(await p.inputValue("#cards .ask__edit"), "ls build")
assert.equal(await p.locator("#cards .ask").count(), 1)
// A second card joins the first without wiping it; the answered one goes.
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c, { ...c, id: "card8", items: [{ ...c.items[0], detail: "make clean" }] }] }), [STREAM2, card])
assert.equal(await p.locator("#cards .ask").count(), 2)
assert.equal(await p.inputValue("#cards .ask >> nth=0 >> .ask__edit"), "ls build")
await p.evaluate(([path]) => window.__mock.frame(path, "ask", { cards: [] }), [STREAM2])
await p.waitForSelector("#cards", { state: "hidden" })
assert.deepEqual(p.errors, [])
})
test("A1: reopening a chat whose reply waits shows the card from the stream's first pass", async () => {
const p = await page(agentChatOpen([["status", { text: "Waiting for you to allow bash…" }], ["ask", { cards: [deviceBashCard()] }]]))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask")
// Away to another chat and back: the stream is opened again and its first pass carries the card.
await p.click('.chatrow[data-id="chat1"]').catch(() => {})
await p.waitForFunction(() => !document.querySelector("#cards .ask"))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards:not([hidden]) .ask")
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "stream_open" && c.args.path.includes("/messages/")).length), 2)
assert.deepEqual(p.errors, [])
})
test("A1: a card at 390 px wide is on the screen and its buttons can be reached", async () => {
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]), { width: 390, height: 844 })
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
await p.waitForSelector("#cards:not([hidden]) .ask")
const allow = await p.locator("#cards .ask__actions .btn--primary").boundingBox()
assert.ok(allow && allow.y + allow.height <= 844 && allow.x >= 0 && allow.x + allow.width <= 390, `Allow is on the screen: ${JSON.stringify(allow)}`)
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
assert.deepEqual(p.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
browser = await launch()
let failed = 0
for (const { name, fn } of tests) {
if (only && !name.includes(only)) continue
try {
await fn()
console.log(`ok ${name}`)
} catch (err) {
failed++
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`)
}
}
await browser.close()
console.log(failed ? `tests/ui: ${failed} failed` : "tests/ui: all passed")
process.exit(failed ? 1 : 0)
+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)))