Approval cards show again: the buttons no longer throw, the command is the item's detail
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:
7 files changed
+537
-17
No files matched your search
@@ -6,6 +6,22 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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
|
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ KEYS = (
|
|||||||
"The agent asks to run %(tool)s:",
|
"The agent asks to run %(tool)s:",
|
||||||
"The agent asks:",
|
"The agent asks:",
|
||||||
"Command",
|
"Command",
|
||||||
|
"%(job)s asks:",
|
||||||
|
"It says: %(purpose)s",
|
||||||
"Recommended",
|
"Recommended",
|
||||||
"Or type an answer",
|
"Or type an answer",
|
||||||
"Your answer",
|
"Your answer",
|
||||||
|
|||||||
@@ -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()],
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -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
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
const LEMBAS_I18N = {
|
const LEMBAS_I18N = {
|
||||||
"cs": {
|
"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.",
|
"%(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 finished": "Dokončeno: %(n)s",
|
||||||
"%(n)s missed": "zmeškáno: %(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…",
|
"Install LLeMbas %(version)s…": "Nainstalovat LLeMbas %(version)s…",
|
||||||
"Instruction files": "Soubory s pokyny",
|
"Instruction files": "Soubory s pokyny",
|
||||||
"It has not run yet.": "Ještě neběžel.",
|
"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 %(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ší.",
|
"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",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "%(n)s beendet",
|
||||||
"%(n)s missed": "%(n)s verpasst",
|
"%(n)s missed": "%(n)s verpasst",
|
||||||
@@ -449,6 +452,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…",
|
"Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…",
|
||||||
"Instruction files": "Anweisungsdateien",
|
"Instruction files": "Anweisungsdateien",
|
||||||
"It has not run yet.": "Er ist noch nicht gelaufen.",
|
"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 %(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.",
|
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop braucht einen LLeMbas-Server 2.0 oder neuer.",
|
||||||
"Last run": "Letzter Lauf",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "%(n)s terminados",
|
||||||
"%(n)s missed": "%(n)s perdidas",
|
"%(n)s missed": "%(n)s perdidas",
|
||||||
@@ -778,6 +783,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…",
|
"Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…",
|
||||||
"Instruction files": "Archivos de instrucciones",
|
"Instruction files": "Archivos de instrucciones",
|
||||||
"It has not run yet.": "Todavía no se ha ejecutado.",
|
"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 %(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.",
|
"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",
|
"Last run": "Última ejecución",
|
||||||
@@ -790,13 +796,13 @@ const LEMBAS_I18N = {
|
|||||||
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
|
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
|
||||||
"MCP tools": "Herramientas MCP",
|
"MCP tools": "Herramientas MCP",
|
||||||
"Manual — asks first": "Manual — pregunta primero",
|
"Manual — asks first": "Manual — pregunta primero",
|
||||||
"Max": "Máx.",
|
"Max": "Máx",
|
||||||
"Maximise": "Maximizar",
|
"Maximise": "Maximizar",
|
||||||
"Memory and personal": "Memoria y datos personales",
|
"Memory and personal": "Memoria y datos personales",
|
||||||
"Menu": "Menú",
|
"Menu": "Menú",
|
||||||
"Message": "Mensaje",
|
"Message": "Mensaje",
|
||||||
"Messages": "Mensajes",
|
"Messages": "Mensajes",
|
||||||
"Min": "Mín.",
|
"Min": "Mín",
|
||||||
"Minimise": "Minimizar",
|
"Minimise": "Minimizar",
|
||||||
"Missed": "Perdida",
|
"Missed": "Perdida",
|
||||||
"Mode": "Modo",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "%(n)s terminés",
|
||||||
"%(n)s missed": "%(n)s manquées",
|
"%(n)s missed": "%(n)s manquées",
|
||||||
@@ -1107,6 +1114,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…",
|
"Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…",
|
||||||
"Instruction files": "Fichiers d’instructions",
|
"Instruction files": "Fichiers d’instructions",
|
||||||
"It has not run yet.": "Elle ne s'est pas encore exécutée.",
|
"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 %(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.",
|
"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",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "%(n)s befejezve",
|
||||||
"%(n)s missed": "%(n)s kimaradt",
|
"%(n)s missed": "%(n)s kimaradt",
|
||||||
@@ -1436,6 +1445,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…",
|
"Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…",
|
||||||
"Instruction files": "Utasításfájlok",
|
"Instruction files": "Utasításfájlok",
|
||||||
"It has not run yet.": "Még nem futott.",
|
"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 %(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.",
|
"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",
|
"Last run": "Utolsó futtatás",
|
||||||
@@ -1448,13 +1458,13 @@ const LEMBAS_I18N = {
|
|||||||
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
|
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
|
||||||
"MCP tools": "MCP-eszközök",
|
"MCP tools": "MCP-eszközök",
|
||||||
"Manual — asks first": "Manual — előbb kérdez",
|
"Manual — asks first": "Manual — előbb kérdez",
|
||||||
"Max": "Max.",
|
"Max": "Max",
|
||||||
"Maximise": "Teljes méret",
|
"Maximise": "Teljes méret",
|
||||||
"Memory and personal": "Memória és személyes adatok",
|
"Memory and personal": "Memória és személyes adatok",
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
"Message": "Üzenet",
|
"Message": "Üzenet",
|
||||||
"Messages": "Üzenetek",
|
"Messages": "Üzenetek",
|
||||||
"Min": "Min.",
|
"Min": "Min",
|
||||||
"Minimise": "Kis méret",
|
"Minimise": "Kis méret",
|
||||||
"Missed": "Elmaradt",
|
"Missed": "Elmaradt",
|
||||||
"Mode": "Mód",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "%(n)s terminati",
|
||||||
"%(n)s missed": "%(n)s perse",
|
"%(n)s missed": "%(n)s perse",
|
||||||
@@ -1765,6 +1776,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…",
|
"Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…",
|
||||||
"Instruction files": "File di istruzioni",
|
"Instruction files": "File di istruzioni",
|
||||||
"It has not run yet.": "Non è ancora stata eseguita.",
|
"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 %(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.",
|
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop richiede un server LLeMbas 2.0 o successivo.",
|
||||||
"Last run": "Ultima esecuzione",
|
"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."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "Zakończone: %(n)s",
|
||||||
"%(n)s missed": "pominięte: %(n)s",
|
"%(n)s missed": "pominięte: %(n)s",
|
||||||
@@ -2094,6 +2107,7 @@ const LEMBAS_I18N = {
|
|||||||
"Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…",
|
"Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…",
|
||||||
"Instruction files": "Pliki z instrukcjami",
|
"Instruction files": "Pliki z instrukcjami",
|
||||||
"It has not run yet.": "Jeszcze się nie uruchomił.",
|
"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 %(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.",
|
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop wymaga serwera LLeMbas 2.0 lub nowszego.",
|
||||||
"Last run": "Ostatni przebieg",
|
"Last run": "Ostatni przebieg",
|
||||||
@@ -2106,7 +2120,7 @@ const LEMBAS_I18N = {
|
|||||||
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
|
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
|
||||||
"MCP tools": "Narzędzia MCP",
|
"MCP tools": "Narzędzia MCP",
|
||||||
"Manual — asks first": "Manual — najpierw pyta",
|
"Manual — asks first": "Manual — najpierw pyta",
|
||||||
"Max": "Maks",
|
"Max": "Max",
|
||||||
"Maximise": "Maksymalizuj",
|
"Maximise": "Maksymalizuj",
|
||||||
"Memory and personal": "Pamięć i dane osobiste",
|
"Memory and personal": "Pamięć i dane osobiste",
|
||||||
"Menu": "Menu",
|
"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ą."
|
"“%(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": {
|
"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.",
|
"%(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 finished": "Dokončené: %(n)s",
|
||||||
"%(n)s missed": "zmeškané: %(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…",
|
"Install LLeMbas %(version)s…": "Nainštalovať LLeMbas %(version)s…",
|
||||||
"Instruction files": "Súbory s pokynmi",
|
"Instruction files": "Súbory s pokynmi",
|
||||||
"It has not run yet.": "Ešte nebežal.",
|
"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 %(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ší.",
|
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potrebuje server LLeMbas 2.0 alebo novší.",
|
||||||
"Last run": "Posledný beh",
|
"Last run": "Posledný beh",
|
||||||
|
|||||||
+47
-11
@@ -417,10 +417,32 @@ function follower(chat, messageId) {
|
|||||||
|
|
||||||
// --- Cards ----------------------------------------------------------------------------------------
|
// --- 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) {
|
function renderCards(chat, cards) {
|
||||||
const box = clear($("cards"))
|
const box = $("cards")
|
||||||
box.hidden = !cards.length
|
const ids = cards.map((c) => String(c.id))
|
||||||
for (const card of cards) box.append(cardOf(chat, card))
|
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) {
|
function cardOf(chat, card) {
|
||||||
@@ -430,24 +452,39 @@ function cardOf(chat, card) {
|
|||||||
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
||||||
for (const item of card.items) {
|
for (const item of card.items) {
|
||||||
const part = h("div", { class: "ask__item" })
|
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 }))
|
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||||
|
let detailShown = false
|
||||||
if (item.kind === "approval") {
|
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:") }))
|
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) {
|
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", () => {
|
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]
|
else delete answers.texts[item.key]
|
||||||
})
|
})
|
||||||
part.append(edit)
|
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) {
|
} else if (item.kind === "plan" && item.plan) {
|
||||||
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
||||||
} else {
|
} else {
|
||||||
part.append(h("p", { class: "ask__title", text: item.title }))
|
part.append(h("p", { class: "ask__title", text: item.title }))
|
||||||
}
|
}
|
||||||
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
|
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
|
||||||
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
|
// 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) {
|
if (item.options?.length) {
|
||||||
const picked = new Set()
|
const picked = new Set()
|
||||||
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
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" })
|
const actions = h("div", { class: "ask__actions" })
|
||||||
if (decides) {
|
if (decides) {
|
||||||
const isPlan = card.items.some((i) => i.kind === "plan")
|
const isPlan = card.items.some((i) => i.kind === "plan")
|
||||||
append(
|
append(actions, [
|
||||||
actions,
|
|
||||||
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
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") }),
|
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") }),
|
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
||||||
)
|
])
|
||||||
} else {
|
} else {
|
||||||
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
|
|||||||
return el
|
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)) {
|
for (const c of children.flat(Infinity)) {
|
||||||
if (c === null || c === undefined || c === false) continue
|
if (c === null || c === undefined || c === false) continue
|
||||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
||||||
|
|||||||
Reference in new issue
Block a user