diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7481b..90c3376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 15aabdf..518872f 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -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", diff --git a/tests/ui/mock-tauri.js b/tests/ui/mock-tauri.js new file mode 100644 index 0000000..8bdfd35 --- /dev/null +++ b/tests/ui/mock-tauri.js @@ -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()], + } +})() diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs new file mode 100644 index 0000000..1927aec --- /dev/null +++ b/tests/ui/run.mjs @@ -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) diff --git a/ui/i18n.js b/ui/i18n.js index 60e660f..2ed6148 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -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", diff --git a/ui/js/chat.js b/ui/js/chat.js index ef54e70..095e970 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -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("") })) } diff --git a/ui/js/dom.js b/ui/js/dom.js index c0aa3c8..406ff79 100644 --- a/ui/js/dom.js +++ b/ui/js/dom.js @@ -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)))