Author SHA1 Message Date
HomerandClaude Opus 5.5 113306b60d LLeMbas Desktop 1.0.0-beta.8: CLI 2.0.0-beta.8, the look and words of LLeMbas 2.0.0-beta.10
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
release / linux (push) Failing after 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:46:58 +00:00
HomerandClaude Opus 5.5 b13cfa4ace The chat's settings at the top right of its header; the composer keeps the message
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s
Model, effort, subagents and an agent chat's mode were chips inside the composer, cut off or out
of reach with a long model name or every chip on. They are in the chat's header now, beside the
Terminal, the activity panel, a Tools and skills button and the menu; a new agent chat's computer
and folder sit beside the title (where an open one says where it works), and its Terminal is the
header's button as an open chat's is. The composer keeps text, attach, Dictate, Draw a picture
and Send/Stop. Where the header has no room -- measured by fitHead on every drawing and resize:
the settings cut at their end, or the place squeezed under 10rem -- they fold behind Chat
settings, which opens them as a wrapped row under the header. The new chat page shows its header.

Tests (tests/ui CI2): the composer holds only the message's controls; at 1920/1280/1024/800/390
px, for a chat and an agent chat with long model names and every chip, every button is inside the
window and not clipped by its container, nothing is drawn over another, folded or not, and the
model's menu opens; a new agent chat at 1280 and 390. Earlier tests follow the chips' new places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:30:15 +00:00
HomerandClaude Opus 5.5 fe4bf064f5 Files in the web explorer's shape: crumbs, folder tree, list in columns, details and preview
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Both tabs use the window's width now, as the web's explorer does: where you are (crumbs), the
folder tree (a chevron opens, a name lists), the list on one grid with every row a subgrid --
name with an icon by kind, type, size, changed, a menu -- and on a wide window the chosen file's
details, the start of it and Open in the viewer (a double click opens it). The panes give way by
the room the page has (container queries, so the sidebar's share counts): the details at 60rem,
the tree at 40rem, the type and date columns when the list is under 28rem; a file then opens in
the viewer on a click. The tree and the list share one request per folder; every reading keeps
the never-empty states of the previous commit.

Tests (tests/ui FX): the administrator's / wide (tree, columns on one track, details, preview,
viewer), navigating by list, tree and crumbs with a long name, this computer's folder, and every
width 1920/1280/1024/800/390 with nothing sideways, every control inside the window and the
name's track never closed. H8/N8 follow the new list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:18:05 +00:00
HomerandClaude Opus 5.5 6874dd46cd Files: never an empty pane -- Loading…, a slow answer and a refusal said in the tree
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Files › Server drew nothing while GET /files was on its way: no word that it was being read, and a
refusal or a broken answer only after Rust's 60 s read timeout. To the administrator it looked as
if the server had no files, until the listing arrived. Every folder of both trees now says
Loading… while it is read, says after 8 s that the server has not answered (with Try again, the
late answer still drawn), and says a refusal, an answer that is not a listing, or a failure to
draw it in its place with Try again. An answer that comes after Read again, or after the folder
was closed, is dropped instead of appended beside the new one.

Tests (tests/ui N8): the administrator's / as the server's test client answers it (folders, a
mount, files at the root), held to show Loading…; the 8 s note under a fake clock; a 403 and Try
again; three answers that are not a listing; Read again while an answer is pending; 390 px. The
mock can hold an answer (`hold`, `__mock.release`) and change one (`__mock.answer`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:06:44 +00:00
18 changed files with 1441 additions and 202 deletions

No files matched your search

+38
View File
@@ -4,6 +4,44 @@ All notable changes to LLeMbas Desktop. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
## [1.0.0-beta.8] — 2026-11-10
LLeMbas CLI 2.0.0-beta.8 inside, the look and words of LLeMbas 2.0.0-beta.10.
### Changed
- **The chat's settings moved to the top right of its header; the composer keeps the message.**
The model picker, how hard it thinks, the subagents and an agent chat's mode sat as chips inside
the composer, where with a long model name or every chip on they were cut off and some could not
be reached. They are now in the chat's header, beside the Terminal, the activity panel, the
chat's tools and skills (a button of its own now, still in the chat's menu too) and the menu; a
new agent chat's computer and folder sit beside the title, where an open one says where it
works. The composer holds only the message's own: the text, attach, Dictate, Draw a picture and
Send/Stop. Where the header has no room for the settings -- measured, so a long model name
counts -- they fold behind Chat settings, which opens them as a wrapped row under the header:
nothing is cut off or drawn over another at any width (checked at 1920, 1280, 1024, 800 and
390 px with long model names and every chip on).
- **Files, in the web explorer's shape, using the window's width.** Both tabs (This computer and
Server) are now where you are (crumbs, each part a way back), the folder tree, the list in
columns -- name with an icon by kind, type, size, changed -- and, on a wide window, the chosen
file's details with the start of it and Open in the viewer (a double click opens it too). With
less room the details give way first (a file then opens in the viewer), then the tree, then the
type and date columns, by the room the page has rather than the window's width; nothing is cut
off or scrolls the page sideways (checked at 1920, 1280, 1024, 800 and 390 px with a long name).
The tree and the list share one request per folder.
### Fixed
- **Files › Server showed an empty pane while it waited.** Nothing was drawn until the server's
listing came back -- no word that it was being read -- and a refusal or a broken answer was said
only once Rust's read timeout (60 s) had passed; to the person it looked as if there were no
files at all. Every folder in both trees now says Loading… while it is read; after 8 s it says
the server has not answered yet and offers Try again (the late answer is still drawn); a
refusal, an answer that is not a listing, or anything that breaks drawing it is said in its
place with Try again. Read again while an answer was on its way no longer draws the listing
twice.
## [1.0.0-beta.7] — 2026-11-10
LLeMbas CLI 2.0.0-beta.7 inside, the look and words of LLeMbas 2.0.0-beta.9.
+1 -1
View File
@@ -1 +1 @@
2.0.0-beta.7
2.0.0-beta.8
+55
View File
@@ -543,6 +543,29 @@ KEYS = (
"approvals wait %(n)s min",
# chat.js: a picture as the next reply
"The reply to the next message is a picture drawn from it.",
# explorer.js: a directory still being read, or an answer that is not a listing
"The server has not answered in %(n)s seconds.",
"The server's answer could not be read.",
# explorer.js: the Files page as the web's explorer (the server's words, but the last)
"Name",
"Type",
"Size",
"Changed",
"folder",
"mount",
"All files",
"Home",
"Folders",
"Where you are",
"Nothing here yet. Not even a hobbit hole.",
"Contents",
"Actions",
"More for %(name)s",
"Path",
"Choose a file to see it here.",
"Details",
# chat.js: the chat's settings in its header, folded behind one button where they do not fit
"Chat settings",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -823,6 +846,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "Čeká na počítač",
"approvals wait %(n)s min": "schválení čeká %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
"The server has not answered in %(n)s seconds.": "Server neodpověděl do %(n)s sekund.",
"The server's answer could not be read.": "Odpověď serveru nešlo přečíst.",
"Choose a file to see it here.": "Vyberte soubor a uvidíte ho tady.",
"Details": "Podrobnosti",
},
"de": {
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
@@ -1098,6 +1125,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "Wartet auf den Computer",
"approvals wait %(n)s min": "Freigaben warten %(n)s Min.",
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
"The server has not answered in %(n)s seconds.": "Der Server hat seit %(n)s Sekunden nicht geantwortet.",
"The server's answer could not be read.": "Die Antwort des Servers konnte nicht gelesen werden.",
"Choose a file to see it here.": "Wählen Sie eine Datei, um sie hier zu sehen.",
"Details": "Details",
},
"es": {
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
@@ -1373,6 +1404,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "Esperando al ordenador",
"approvals wait %(n)s min": "las aprobaciones esperan %(n)s min",
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
"The server has not answered in %(n)s seconds.": "El servidor no ha respondido en %(n)s segundos.",
"The server's answer could not be read.": "No se pudo leer la respuesta del servidor.",
"Choose a file to see it here.": "Elige un archivo para verlo aquí.",
"Details": "Detalles",
},
"fr": {
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
@@ -1648,6 +1683,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "En attente de l’ordinateur",
"approvals wait %(n)s min": "les approbations attendent %(n)s min",
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
"The server has not answered in %(n)s seconds.": "Le serveur n’a pas répondu depuis %(n)s secondes.",
"The server's answer could not be read.": "La réponse du serveur n’a pas pu être lue.",
"Choose a file to see it here.": "Choisissez un fichier pour le voir ici.",
"Details": "Détails",
},
"hu": {
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
@@ -1923,6 +1962,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "A számítógépre vár",
"approvals wait %(n)s min": "a jóváhagyások %(n)s percig várnak",
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
"The server has not answered in %(n)s seconds.": "A szerver %(n)s másodperce nem válaszolt.",
"The server's answer could not be read.": "A szerver válasza nem olvasható.",
"Choose a file to see it here.": "Válasszon egy fájlt, hogy itt lássa.",
"Details": "Részletek",
},
"it": {
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
@@ -2198,6 +2241,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "In attesa del computer",
"approvals wait %(n)s min": "le approvazioni attendono %(n)s min",
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
"The server has not answered in %(n)s seconds.": "Il server non ha risposto in %(n)s secondi.",
"The server's answer could not be read.": "Non è stato possibile leggere la risposta del server.",
"Choose a file to see it here.": "Scegli un file per vederlo qui.",
"Details": "Dettagli",
},
"pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
@@ -2473,6 +2520,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "Czeka na komputer",
"approvals wait %(n)s min": "zatwierdzenia czekają %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
"The server has not answered in %(n)s seconds.": "Serwer nie odpowiedział od %(n)s sekund.",
"The server's answer could not be read.": "Nie udało się odczytać odpowiedzi serwera.",
"Choose a file to see it here.": "Wybierz plik, aby zobaczyć go tutaj.",
"Details": "Szczegóły",
},
"sk": {
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
@@ -2748,6 +2799,10 @@ OWN: dict[str, dict[str, str]] = {
"Waiting for the computer": "Čaká na počítač",
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
"The server has not answered in %(n)s seconds.": "Server neodpovedal do %(n)s sekúnd.",
"The server's answer could not be read.": "Odpoveď servera sa nedala prečítať.",
"Choose a file to see it here.": "Vyberte súbor a uvidíte ho tu.",
"Details": "Podrobnosti",
},
}
+1 -1
View File
@@ -2135,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "llembas-desktop"
version = "1.0.0-beta.7"
version = "1.0.0-beta.8"
dependencies = [
"libc",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "llembas-desktop"
version = "1.0.0-beta.7"
version = "1.0.0-beta.8"
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
authors = ["Jaroslav Beneš"]
license = "MIT"
+1 -1
View File
@@ -1 +1 @@
LLeMbas v2.0.0-beta.9 (3b7da5fc7ac72d5de9229919793dd299c03da6a8) tests/fixtures/api_v2
LLeMbas v2.0.0-beta.10 (6d1ab36371c8492ee2590fbe25d112d73513a9ae) tests/fixtures/api_v2
+22
View File
@@ -8,6 +8,8 @@
// /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.
// hold ["GET /files?path=%2F", ...] -- answers that wait (a slow server) until the test
// lets them go with `__mock.release(key)`; the table is read when they are let go.
// 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()`.
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
@@ -29,7 +31,16 @@
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
}
const held = [] // [{ key, go }] answers waiting for `release`
function route(method, path) {
if ((config.hold || []).includes(`${method} ${path}`)) {
return new Promise((resolve) => held.push({ key: `${method} ${path}`, go: resolve })).then(() => answer(method, path))
}
return answer(method, path)
}
function answer(method, path) {
const routes = config.routes || {}
const bare = path.split("?")[0]
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
@@ -130,6 +141,17 @@
window.__mock = {
calls,
/** Let the held answers for `key` go, in the order they were asked; stop holding new ones. */
release(key) {
config.hold = (config.hold || []).filter((k) => k !== key)
const going = held.filter((x) => x.key === key)
for (const x of going) held.splice(held.indexOf(x), 1)
for (const x of going) x.go()
},
/** Change the server's answer for a route from now on. */
answer(key, value) {
config.routes[key] = value
},
frame,
emit: dispatch,
/** The calls of one command, or of `api` with one method and path prefix. */
+413 -39
View File
@@ -189,9 +189,11 @@ function baseRoutes(over = {}) {
let browser
async function page(config, { width = 1280, height = 860 } = {}) {
async function page(config, { width = 1280, height = 860, clock = false } = {}) {
const context = await browser.newContext({ viewport: { width, height } })
const p = await context.newPage()
// The page's own timers, moved on by the test (`p.clock.fastForward`) rather than waited for.
if (clock) await p.clock.install()
const errors = []
p.on("pageerror", (e) => errors.push(String(e)))
await context.route(`${ORIGIN}/**`, (route) => {
@@ -353,11 +355,11 @@ async function newChatOn(p, kind, modelName) {
async function subagentsChip(p) {
// The chip comes after the others (an open chat's waits for the server's word).
await p.waitForTimeout(150)
return p.locator("#composer-chips .cchip--subagents")
return p.locator("#chat-settings .cchip--subagents")
}
async function openSlider(p) {
await p.click("#composer-chips .cchip--subagents")
await p.click("#chat-settings .cchip--subagents")
await p.waitForSelector(".range2")
return {
min: await p.getAttribute(".range2__input--min", "max"),
@@ -411,7 +413,7 @@ test("A5: subagents switched on while the app stays open: the next new chat offe
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
await p.waitForTimeout(5200)
await p.click("#new-chat-btn")
await p.waitForSelector("#composer-chips .cchip--subagents")
await p.waitForSelector("#chat-settings .cchip--subagents")
assert.deepEqual(p.errors, [])
})
@@ -468,9 +470,9 @@ test("B3: a new agent chat offers the Terminal once its folder is chosen, and op
const p = await page(b3())
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip--terminal")
await p.waitForSelector("#chat-tools [data-terminal-toggle]")
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
await p.click("#composer-chips .cchip--terminal")
await p.click("#chat-tools [data-terminal-toggle]")
await p.waitForSelector("#terminal:not([hidden]) .xterm")
await p.waitForFunction(() => window.__mock.terminals().length === 1)
const [shell] = await p.evaluate(() => window.__mock.terminals())
@@ -478,7 +480,7 @@ test("B3: a new agent chat offers the Terminal once its folder is chosen, and op
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
assert.equal(await p.textContent("#terminal-message"), "Connected.")
assert.equal(await p.textContent("#terminal-title"), "workbench")
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
assert.equal(await p.getAttribute("#chat-tools [data-terminal-toggle]", "aria-pressed"), "true")
// Keys go up through the bridge, never a socket of the page's own.
await p.click("#terminal .xterm")
await p.keyboard.type("ls")
@@ -569,22 +571,22 @@ test("B3: no Terminal where the device opens none, for a chat on the server, or
let p = await page(b3(off))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
await p.waitForSelector("#chat-settings .cchip")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "the device opens no terminal")
await openChatRow(p, "chat2")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
await p.context().close()
p = await page(b3({}))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Chat")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
await p.waitForSelector("#chat-settings .cchip")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "a chat on the server has no shell")
await p.context().close()
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
await p.waitForSelector("#chat-settings .cchip")
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "no folder yet")
assert.deepEqual(p.errors, [])
})
@@ -800,30 +802,30 @@ test("C4: Subagents for a new agent chat as /models?kind=agent says for this dev
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
await p.waitForSelector("#chat-settings .cchip")
await p.waitForTimeout(200)
return p
}
let p = await newAgent(facts)
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
await p.click("#composer-chips .cchip--subagents")
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 1, "offered, and the device runs them")
await p.click("#chat-settings .cchip--subagents")
await p.waitForSelector(".range2")
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
await p.context().close()
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
await p.context().close()
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "not offered")
await p.context().close()
p = await newAgent({ __error: { status: 400, detail: "older server" } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
await p.context().close()
// An open agent chat: its own `subagents` (chat_agent.json offers them).
const chat = fixture("chat_agent")
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
await openChatRow(p, "chat2")
await p.waitForSelector("#composer-chips .cchip--subagents")
await p.waitForSelector("#chat-settings .cchip--subagents")
assert.deepEqual(p.errors, [])
})
@@ -926,7 +928,7 @@ function d4(over = {}) {
}
async function pickDevice(p, name) {
await p.click("#composer-chips .cchip--device")
await p.click("#chat-place .cchip--device")
await p.locator(".menu .menu__item", { hasText: name }).first().click()
}
@@ -934,16 +936,16 @@ test("D4: a new agent chat defaults to this computer and can be sent to another
const p = await page(d4())
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip--device")
assert.match(await p.textContent("#composer-chips .cchip--device"), /This computer/)
await p.waitForSelector("#chat-place .cchip--device")
assert.match(await p.textContent("#chat-place .cchip--device"), /This computer/)
await pickDevice(p, "studio")
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
await p.waitForFunction(() => /studio/.test(document.querySelector("#chat-place .cchip--device")?.textContent || ""))
assert.match(await p.textContent("#new-chat .hello__sub"), /studio/, "the greeting names the computer")
assert.match(await p.textContent("#composer-chips"), /site/, "the folder its agent chats work in")
assert.match(await p.textContent("#chat-place"), /site/, "the folder its agent chats work in")
assert.ok(await p.isHidden("#link-notice"), "this computer's agent is not in the way")
await shot(p, "d4-device-1280")
// The Terminal opens on that computer, in that folder, before the first message.
await p.click("#composer-chips .cchip--terminal")
await p.click("#chat-tools [data-terminal-toggle]")
await p.waitForFunction(() => window.__mock.terminals().length === 1)
const [shell] = await p.evaluate(() => window.__mock.terminals())
assert.deepEqual(shell.target, { chat: null, device: "token9", directory: "/srv/site" })
@@ -962,18 +964,18 @@ test("D4: on a computer with no known folder the path is typed; back to this com
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await pickDevice(p, "studio")
await p.waitForSelector("#composer-chips .cchip.is-warn")
await p.waitForSelector("#chat-place .cchip.is-warn")
await p.fill("#composer-text", "Hello")
await p.click("#send")
await p.waitForSelector("#thread-note:not([hidden])")
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats").length), 0, "nothing sent without a folder")
await p.click("#composer-chips .cchip.is-warn")
await p.click("#chat-place .cchip.is-warn")
await p.waitForSelector("dialog[open] .dialog__input")
await p.fill("dialog[open] .dialog__input", "/home/dev/other")
await p.click("dialog[open] .btn--primary")
await p.waitForFunction(() => /other/.test(document.querySelector("#composer-chips").textContent))
await p.waitForFunction(() => /other/.test(document.querySelector("#chat-place").textContent))
await pickDevice(p, "This computer")
await p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent))
await p.waitForFunction(() => /project/.test(document.querySelector("#chat-place").textContent))
assert.deepEqual(p.errors, [])
})
@@ -981,14 +983,14 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th
let p = await page(b3())
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
assert.equal(await p.locator("#composer-chips .cchip--device").count(), 0)
await p.waitForSelector("#chat-settings .cchip")
assert.equal(await p.locator("#chat-place .cchip--device").count(), 0)
await p.context().close()
p = await page(d4(), { width: 390, height: 844 })
await p.evaluate(() => document.getElementById("new-chat-btn").click())
await p.click(".kind__tab >> text=Agent")
await pickDevice(p, "studio")
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
await p.waitForFunction(() => /studio/.test(document.querySelector("#chat-place .cchip--device")?.textContent || ""))
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "d4-device-390")
assert.deepEqual(p.errors, [])
@@ -1318,7 +1320,7 @@ function h8(over = {}, commands = {}) {
async function serverPane(p) {
await p.click("#nav-files")
await p.click('#files-tabs [data-tab="server"]')
await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]')
await p.waitForSelector('#server-list .frow[data-path="/home/frodo/lembas.md"]')
}
test("H8: the server's files: upload, a new folder, rename and delete, each read again from the server", async () => {
@@ -1330,21 +1332,21 @@ test("H8: the server's files: upload, a new folder, rename and delete, each read
assert.equal((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "library_upload").args.dir)), "/home/frodo")
await p.waitForFunction(() => window.__mock.api("GET", "/files?path=%2F").length === 2)
// A new folder inside a folder, from its row's menu.
await p.click('#server-tree li[data-path="/home/frodo/recipes"] > .tree__more')
await p.click('#server-list .frow[data-path="/home/frodo/recipes"] .frow__more')
await p.click(".menu .menu__item >> text=New folder")
await p.fill("dialog[open] .dialog__input", "Breads")
await p.click("dialog[open] .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/files/mkdir").length === 1)
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/mkdir")))[0].args.body, { path: "/home/frodo/recipes", name: "Breads" })
// Rename a file.
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more')
await p.click(".menu .menu__item >> text=Rename")
await p.fill("dialog[open] .dialog__input", "waybread.md")
await p.click("dialog[open] .btn--primary")
await p.waitForFunction(() => window.__mock.api("POST", "/files/rename").length === 1)
assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/rename")))[0].args.body, { path: "/home/frodo/lembas.md", name: "waybread.md" })
// Delete: to the trash, after asking.
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more')
await p.click(".menu .menu__item >> text=Delete")
await p.click("dialog[open] .btn--danger")
await p.waitForFunction(() => window.__mock.api("DELETE", "/files?path=%2Fhome%2Ffrodo%2Flembas.md").length === 1)
@@ -1360,7 +1362,7 @@ test("H8: a refusal or a file the server did not take is said in the pane", asyn
await p.click("#server-upload")
await p.waitForSelector("#server-status.is-error")
assert.equal(await p.textContent("#server-status"), "big.iso: This file is too large to send.")
await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more')
await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more')
await p.click(".menu .menu__item >> text=Rename")
await p.fill("dialog[open] .dialog__input", "x.md")
await p.click("dialog[open] .btn--primary")
@@ -1392,12 +1394,133 @@ test("H8: at 390 px the server's pane and the switches stay inside the window",
const p = await page(h8(), { width: 390, height: 844 })
await p.evaluate(() => document.getElementById("nav-files").click())
await p.click('#files-tabs [data-tab="server"]')
await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]')
await p.waitForSelector('#server-list .frow[data-path="/home/frodo/lembas.md"]')
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "h8-files-390")
assert.deepEqual(p.errors, [])
})
// N8: Files › Server for an administrator (owner, on 1.0.0-beta.7: "shows nothing", then it loaded
// late). The pane drew nothing at all while the listing was on its way -- no word that it was being
// read, and a refusal or a broken answer only as late as Rust's 60 s read timeout. An answer that
// came after Read again was appended beside the new one. The answer below is what the server's own
// test client gives an administrator for `GET /api/v2/files?path=/` (2.0.0-beta.9: `fs.start` is `/`
// for an organisation's owner), with a mount and files at the root as on a lived-in instance.
const ADMIN_ROOT = {
path: "/",
parent: null,
entries: [
{ name: "home", path: "/home", kind: "dir", size: 0, mime: "", updated_at: STAMP },
{ name: "shared", path: "/shared", kind: "mount", size: 0, mime: "", updated_at: STAMP },
{ name: "skills", path: "/skills", kind: "dir", size: 0, mime: "", updated_at: STAMP },
{ name: "retry_util.py", path: "/retry_util.py", kind: "file", size: 1917, mime: "text/x-python", updated_at: STAMP },
{ name: "test.py", path: "/test.py", kind: "file", size: 210, mime: "text/x-python", updated_at: STAMP },
],
}
const ROOT_KEY = "GET /files?path=%2F"
function n8(over = {}, hold = []) {
return {
status: status(),
routes: baseRoutes({
[ROOT_KEY]: ADMIN_ROOT,
"GET /files?path=%2Fhome": { path: "/home", parent: "/", entries: [{ name: "ada", path: "/home/ada", kind: "dir", size: 0, mime: "", updated_at: STAMP }] },
...over,
}),
hold,
streams: {},
}
}
async function openServerTab(p) {
await p.evaluate(() => document.getElementById("nav-files").click())
await p.click('#files-tabs [data-tab="server"]')
}
const serverRows = (p) => p.$$eval("#server-list .frow[data-path]", (l) => l.map((x) => x.dataset.path))
test("N8: the administrator's / is drawn: folders, the mount and the files at the root; Loading… while it is read", async () => {
const p = await page(n8({}, [ROOT_KEY]))
await openServerTab(p)
await p.waitForSelector("#server-list .fnote.is-loading")
assert.equal((await p.textContent("#server-list .fnote.is-loading")).trim(), "Loading…")
assert.ok(await p.isVisible("#server-list .fnote.is-loading"), "the pane is not empty while it waits")
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
assert.deepEqual(await serverRows(p), ["/home", "/shared", "/skills", "/retry_util.py", "/test.py"])
assert.equal(await p.locator("#server-list .is-loading").count(), 0)
// The mount opens like a folder; a folder's children come under it.
await p.click('#server-list .frow[data-path="/home"] .frow__open')
await p.waitForSelector('#server-list .frow[data-path="/home/ada"]')
assert.ok(await p.isVisible('#server-list .frow[data-path="/home/ada"]'))
await shot(p, "n8-admin-root-1280")
assert.deepEqual(p.errors, [])
})
test("N8: an answer that has not come in 8 s says so and offers Try again; the late answer is still drawn", async () => {
const p = await page(n8({}, [ROOT_KEY]), { clock: true })
await openServerTab(p)
await p.waitForSelector("#server-list .fnote.is-loading")
assert.equal(await p.locator("#server-list .fnote__retry").count(), 0)
await p.clock.fastForward(9000)
await p.waitForSelector("#server-list .fnote.is-loading .fnote__retry")
assert.match(await p.textContent("#server-list .fnote.is-loading"), /The server has not answered in 8 seconds\./)
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
assert.equal(await p.locator("#server-list .fnote").count(), 0)
assert.deepEqual(p.errors, [])
})
test("N8: a refusal is said in the pane with Try again, which reads the folder afresh", async () => {
const p = await page(n8({ [ROOT_KEY]: { __error: { status: 403, detail: "This token was not given the app scope." } } }))
await openServerTab(p)
await p.waitForSelector("#server-list .fnote.is-error")
assert.match(await p.textContent("#server-list .fnote.is-error"), /This token was not given the app scope\./)
assert.equal(await p.getAttribute("#server-list .fnote.is-error", "role"), "alert")
await p.evaluate(([k, v]) => window.__mock.answer(k, v), [ROOT_KEY, ADMIN_ROOT])
await p.click("#server-list .fnote__retry")
await p.waitForSelector('#server-list .frow[data-path="/home"]')
assert.equal(await p.locator("#server-list .is-error").count(), 0)
assert.equal((await p.evaluate((k) => window.__mock.api("GET", "/files?path=%2F").length)), 2)
assert.deepEqual(p.errors, [])
})
test("N8: an answer that is not a listing is said, never an empty pane", async () => {
for (const broken of [null, { path: "/" }, { detail: "Not Found" }]) {
const p = await page(n8({ [ROOT_KEY]: broken }))
await openServerTab(p)
await p.waitForSelector("#server-list .fnote.is-error")
assert.match(await p.textContent("#server-list .fnote.is-error"), /The server's answer could not be read\./)
assert.deepEqual(p.errors, [])
await p.context().close()
}
})
test("N8: Read again while the first answer is on its way draws the listing once", async () => {
const p = await page(n8({}, [ROOT_KEY]))
await openServerTab(p)
await p.waitForSelector("#server-list .fnote.is-loading")
await p.click("#server-refresh")
await p.waitForFunction((k) => window.__mock.api("GET", "/files?path=%2F").length === 2)
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
await p.waitForTimeout(50)
assert.deepEqual(await serverRows(p), ["/home", "/shared", "/skills", "/retry_util.py", "/test.py"])
assert.deepEqual(p.errors, [])
})
test("N8: at 390 px the waiting and the refusal stay inside the window, Try again reachable", async () => {
const p = await page(n8({ [ROOT_KEY]: { __error: { status: 0, detail: "Could not reach the server: connection timed out after a long wait at the very far end of the network" } } }), { width: 390, height: 844 })
await openServerTab(p)
await p.waitForSelector("#server-list .fnote__retry")
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
const box = await p.locator("#server-list .fnote__retry").boundingBox()
assert.ok(box && box.x >= 0 && box.x + box.width <= 390 && box.height >= 28, `Try again is on the screen: ${JSON.stringify(box)}`)
await shot(p, "n8-refused-390")
assert.deepEqual(p.errors, [])
})
// I8: voice (/v1/audio, through Rust) and drawing a picture (/v1/images, through Rust).
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
@@ -1721,6 +1844,257 @@ test("M8: no picture switch where the server cannot draw, or in an agent chat",
assert.deepEqual(q.errors, [])
})
// FX: the Files page as the web's explorer -- crumbs, the folder tree, the list in columns, the
// chosen file's details and preview, using the window's width; narrower, the details and then the
// tree give way, and nothing is ever cut off or scrolls the page sideways.
const LONG = "a-very-long-file-name-that-somebody-chose-for-the-lembas-recipe-and-its-variants.md"
function fx() {
const cfg = n8({
"GET /files?path=%2Fhome": { path: "/home", parent: "/", entries: [{ name: "ada", path: "/home/ada", kind: "dir", size: 0, mime: "", updated_at: STAMP }, { name: LONG, path: `/home/${LONG}`, kind: "file", size: 123456, mime: "text/markdown", updated_at: STAMP }] },
"GET /files/content": { path: "/test.py", name: "test.py", size: 210, mime: "text/x-python", kind: "code", language: "python", sha: "x", text: "print('lembas')\n" },
})
cfg.commands = {
local_list: { root: "/home/dev/project", path: "", more: false, entries: [{ name: "src", path: "src", kind: "dir", size: 0, modified: 1767323045 }, { name: "README.md", path: "README.md", kind: "file", size: 2048, modified: 1767323045 }] },
local_read: { root: "/home/dev/project", path: "README.md", name: "README.md", size: 2048, text: "# Project\n", withheld: null },
}
return cfg
}
test("FX: wide, the server's files are a tree, a list in columns and the chosen file's details with a preview", async () => {
const p = await page(fx(), { width: 1600, height: 900 })
await openServerTab(p)
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
// The tree: the top, then its folders; the top is where we are.
await p.waitForSelector('#server-tree li[data-path="/home"]')
assert.equal(await p.textContent("#server-tree .ftree__root"), "All files")
assert.equal(await p.getAttribute("#server-tree .ftree__root", "aria-current"), "true")
assert.deepEqual(await p.$$eval("#server-tree li[data-path]", (l) => l.map((x) => x.dataset.path)), ["/home", "/shared", "/skills"])
// The columns, and the kinds' words.
assert.deepEqual(await p.$$eval("#server-list .frow--head [role=columnheader]", (l) => l.map((x) => x.textContent)), ["Name", "Type", "Size", "Changed", "Actions"])
assert.equal(await p.textContent('#server-list .frow[data-path="/shared"] .frow__type'), "mount")
assert.equal(await p.textContent('#server-list .frow[data-path="/home"] .frow__type'), "folder")
assert.equal(await p.textContent('#server-list .frow[data-path="/test.py"] .frow__type'), "py")
assert.equal(await p.textContent('#server-list .frow[data-path="/test.py"] .frow__size'), "210 B")
// Every row's cells on the same tracks: the size column's left edge is one x for every row.
const xs = await p.$$eval("#server-list .frow .frow__size", (l) => [...new Set(l.map((x) => Math.round(x.getBoundingClientRect().right)))])
assert.equal(xs.length, 1, `sizes end on one line: ${xs}`)
// A file chosen: its details and the start of it.
assert.match(await p.textContent("#server-details"), /Choose a file to see it here\./)
await p.click('#server-list .frow[data-path="/test.py"] .frow__open')
await p.waitForSelector("#server-details .fdetails__text")
assert.equal(await p.textContent("#server-details .fdetails__name"), "test.py")
assert.equal(await p.textContent("#server-details .fdetails__text"), "print('lembas')\n")
assert.ok(await p.$eval('#server-list .frow[data-path="/test.py"]', (x) => x.classList.contains("is-chosen")))
assert.ok(await p.isHidden("#viewer"), "choosing is not opening")
await shot(p, "fx-server-1600")
// Open in the viewer from the details, and with a double click.
await p.click("#server-details .btn--primary")
await p.waitForSelector("#viewer:not([hidden])")
assert.equal(await p.textContent("#viewer-name"), "test.py")
assert.deepEqual(p.errors, [])
})
test("FX: a folder is listed from the list, the tree or the crumbs; the crumbs say where", async () => {
const p = await page(fx(), { width: 1280, height: 860 })
await openServerTab(p)
await p.waitForSelector('#server-list .frow[data-path="/home"]')
await p.click('#server-list .frow[data-path="/home"] .frow__open')
await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`)
assert.deepEqual(await p.$$eval("#server-crumbs > *:not(.crumbs__sep)", (l) => l.map((x) => x.textContent)), ["All files", "home"])
assert.equal(await p.getAttribute('#server-tree .ftree__row[data-path="/home"]', "aria-current"), "true")
// A long name gives, the rest of its row stays on its tracks and inside the list.
const row = await p.$eval(`#server-list .frow[data-path="/home/${LONG}"]`, (r) => {
const list = document.getElementById("server-list").getBoundingClientRect()
const more = r.querySelector(".frow__when").getBoundingClientRect()
return { right: more.right, list: list.right, label: r.querySelector(".frow__label").scrollWidth > r.querySelector(".frow__label").clientWidth }
})
assert.ok(row.right <= row.list && row.label, `the long name is cut with an ellipsis, the date inside: ${JSON.stringify(row)}`)
await p.click("#server-crumbs .crumbs__link")
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
await p.click('#server-tree .ftree__row[data-path="/home"]')
await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`)
await p.waitForSelector('#server-tree li[data-path="/home/ada"]')
await shot(p, "fx-server-1280")
assert.deepEqual(p.errors, [])
})
test("FX: this computer's folder in the same shape, its dates from the system's", async () => {
const p = await page(fx(), { width: 1600, height: 900 })
await p.evaluate(() => document.getElementById("nav-files").click())
await p.waitForSelector('#local-list .frow[data-path="README.md"]')
assert.equal(await p.textContent("#local-tree .ftree__root"), "project")
assert.equal(await p.textContent("#local-crumbs .crumbs__here"), "project")
assert.notEqual((await p.textContent('#local-list .frow[data-path="README.md"] .frow__when')).trim(), "")
await p.click('#local-list .frow[data-path="README.md"] .frow__open')
await p.waitForSelector("#local-details .fdetails__text")
assert.equal(await p.textContent("#local-details .fdetails__text"), "# Project\n")
await shot(p, "fx-local-1600")
assert.deepEqual(p.errors, [])
})
test("FX: at every width nothing scrolls sideways and nothing is cut off; narrower, a file opens in the viewer", async () => {
for (const [width, height] of [[1920, 1080], [1280, 860], [1024, 768], [800, 700], [390, 844]]) {
const p = await page(fx(), { width, height })
await openServerTab(p)
await p.waitForSelector('#server-list .frow[data-path="/home"]')
await p.click('#server-list .frow[data-path="/home"] .frow__open')
await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`)
assert.ok(await noSideways(p, width), `${width}: nothing scrolls sideways`)
// Every visible control of the bar and the rows is inside the window.
const out = await p.$$eval("#pane-server button, #pane-server .frow__size", (l, w) => l.filter((b) => { const r = b.getBoundingClientRect(); return r.width > 0 && (r.left < -1 || r.right > w + 1) }).map((b) => b.className || b.id), width)
assert.deepEqual(out, [], `${width}: controls inside the window`)
// The panes give way by the room the page has (60rem, 40rem of it), not the window's width.
const room = await p.$eval("#pane-server", (x) => x.getBoundingClientRect().width)
const details = await p.isVisible("#server-details")
assert.equal(details, room > 960, `${width}: the details pane at ${room} px`)
assert.equal(await p.isVisible("#server-tree"), room > 640, `${width}: the tree at ${room} px`)
// The name's track never closes: every name shows at least a few letters.
const names = await p.$$eval("#server-list .frow__label", (l) => Math.min(...l.map((x) => x.getBoundingClientRect().width)))
assert.ok(names >= 24, `${width}: names keep room (${names} px)`)
await shot(p, `fx-server-${width}`)
await p.click("#server-crumbs .crumbs__link")
await p.waitForSelector('#server-list .frow[data-path="/test.py"]')
await p.click('#server-list .frow[data-path="/test.py"] .frow__open')
if (details) await p.waitForSelector("#server-details .fdetails__text")
else await p.waitForSelector("#viewer:not([hidden])")
assert.deepEqual(p.errors, [])
await p.context().close()
}
})
// CI2: the chat's input overhauled -- the chat's settings (mode, model, effort, subagents) in the
// header's top right beside Terminal, the activity panel, tools and skills and the menu; the
// composer keeps the message's own: text, attach, Dictate, Draw a picture, Send/Stop. Nothing is
// cut off or out of reach at any width: where the settings do not fit they fold behind Chat
// settings, which opens them as a wrapped row of their own.
const LONG_MODEL = "Qwen3.8 Coder 480B A35B Instruct, long context, tools and vision"
function ci2(kind) {
const cfg = i8()
const m = models()
m.models[0] = { ...m.models[0], name: LONG_MODEL }
m.models[1] = { ...m.models[1], name: "Another model with a long name, served elsewhere" }
Object.assign(cfg.routes, {
"GET /models": m,
"GET /models?chat=chat1": m,
"GET /models?chat=chat2": m,
"GET /chats/chat2": { chat: agentChat({ subagents: { offered: true, named_only: false, cap: 8 } }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
"GET /chats/chat2/tree": { tree: [], live: true },
})
return cfg
}
/** Every visible button of the header and the composer inside the window, none of zero width. */
async function reachable(p, width) {
return p.$$eval(
"#chat-head button, #composer button",
(l, w) =>
l
.filter((b) => b.offsetParent !== null && getComputedStyle(b).visibility !== "hidden")
.filter((b) => {
const r = b.getBoundingClientRect()
// A button inside a container that clips it (overflow) is cut off too.
let clip = { left: 0, right: w }
for (let el = b.parentElement; el; el = el.parentElement) {
const cs = getComputedStyle(el)
if (cs.overflowX !== "visible") {
const c = el.getBoundingClientRect()
clip = { left: Math.max(clip.left, c.left), right: Math.min(clip.right, c.right) }
}
}
return r.width < 8 || r.left < clip.left - 1 || r.right > clip.right + 1
})
.map((b) => b.className || b.id),
width,
)
}
/** Pairs of the header's controls (and where the chat works) drawn over each other. */
async function overlapping(p) {
return p.$$eval("#chat-head button, #chat-head .place", (l) => {
const shown = l.filter((b) => b.offsetParent !== null && b.getBoundingClientRect().width > 0)
const out = []
for (let i = 0; i < shown.length; i++)
for (let j = i + 1; j < shown.length; j++) {
const a = shown[i].getBoundingClientRect()
const b = shown[j].getBoundingClientRect()
if (shown[i].contains(shown[j]) || shown[j].contains(shown[i])) continue
if (a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1) out.push(`${shown[i].className} / ${shown[j].className}`)
}
return out
})
}
test("CI2: the composer holds the message's own controls; the chat's settings are in its header", async () => {
const p = await page(ci2(), { width: 1600, height: 900 })
await openChatRow(p, "chat1")
await p.waitForSelector("#chat-settings .cchip--model")
await p.waitForSelector("#chat-settings .cchip--subagents")
assert.equal(await p.locator("#composer .cchip--model, #composer .cchip--subagents").count(), 0, "no settings in the composer")
assert.equal(await p.locator("#composer-chips .cchip--picture").count(), 1, "Draw a picture stays with the message")
assert.ok(await p.isVisible("#chat-tools #activity-open"))
assert.ok(await p.isVisible("#chat-tools #scope-open"))
assert.ok(await p.isHidden("#chat-fold"), "everything fits: nothing folded")
// Tools and skills opens from the header.
await p.click("#scope-open")
await p.waitForSelector(".scope")
await shot(p, "ci2-chat-1600")
assert.deepEqual(p.errors, [])
})
test("CI2: at 1920, 1280, 1024, 800 and 390 px every control is reachable, with long model names and every chip on", async () => {
for (const [width, height] of [[1920, 1080], [1280, 860], [1024, 768], [800, 700], [390, 844]]) {
for (const id of ["chat1", "chat2"]) {
const p = await page(ci2(), { width, height })
await p.evaluate((x) => document.querySelector(`.chatrow[data-id="${x}"]`).click(), id)
await p.waitForSelector("#messages .msg")
await p.waitForSelector("#chat-settings .cchip--subagents", { state: "attached" })
await p.waitForTimeout(100)
assert.ok(await noSideways(p, width), `${width} ${id}: nothing sideways`)
assert.deepEqual(await reachable(p, width), [], `${width} ${id}: every control inside`)
assert.deepEqual(await overlapping(p), [], `${width} ${id}: nothing drawn over another`)
const folded = await p.isVisible("#chat-fold")
if (folded) {
await p.click("#chat-fold")
await p.waitForSelector("#chat-settings .cchip--model", { state: "visible" })
assert.deepEqual(await reachable(p, width), [], `${width} ${id}: unfolded, every setting inside`)
assert.deepEqual(await overlapping(p), [], `${width} ${id}: unfolded, nothing drawn over another`)
}
// Every setting is there and can be used: the model's menu opens.
const names = await p.$$eval("#chat-settings .cchip", (l) => l.map((x) => x.className))
assert.ok(names.some((n) => n.includes("cchip--model")) && names.some((n) => n.includes("cchip--subagents")), `${width} ${id}: ${names}`)
if (id === "chat2") assert.ok(await p.isVisible("#chat-tools [data-terminal-toggle]"), `${width}: the Terminal`)
await p.click("#chat-settings .cchip--model")
await p.waitForSelector(".menu")
assert.match(await p.textContent(".menu"), /Another model/)
await p.keyboard.press("Escape")
await shot(p, `ci2-${id}-${width}${folded ? "-unfolded" : ""}`)
assert.deepEqual(p.errors, [])
await p.context().close()
}
}
})
test("CI2: a new agent chat says where it works beside the title and its settings at the right; the composer only the message", async () => {
for (const width of [1280, 390]) {
const p = await page(ci2(), { width, height: 844 })
await p.waitForSelector("#chat-settings .cchip--model", { state: "attached" })
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#chat-place .cchip")
assert.equal(await p.locator("#composer .cchip").count(), 0, "nothing but the message in an agent chat's composer")
assert.ok(await noSideways(p, width))
assert.deepEqual(await reachable(p, width), [], `${width}: every control inside`)
assert.deepEqual(await overlapping(p), [], `${width}: nothing drawn over another`)
if (await p.isVisible("#chat-fold")) await p.click("#chat-fold")
assert.deepEqual(await overlapping(p), [], `${width}: unfolded, nothing drawn over another`)
assert.deepEqual(await reachable(p, width), [], `${width}: unfolded, every control inside`)
await shot(p, `ci2-new-agent-${width}`)
assert.deepEqual(p.errors, [])
await p.context().close()
}
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
+3 -3
View File
@@ -1,5 +1,5 @@
{
"tag": "v2.0.0-beta.9",
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"sha256": "46d010bb862b06345c05f726cd1e6ae8415866324bfb0a55b8642ce986a9e769"
"tag": "v2.0.0-beta.10",
"commit": "6d1ab36371c8492ee2590fbe25d112d73513a9ae",
"sha256": "9e5ac5c6f1fd04d0edba89f492b817194439f980566d329975f2035c2012c8cc"
}
+216 -39
View File
@@ -497,7 +497,29 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
.head__title { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.head__place { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
.head__tools { display: flex; gap: var(--sp-1); align-items: center; justify-content: end; }
.main[data-new="true"] .head { display: none; }
/* The chat's header: the title, where it works, then its settings and tools at the right. The
settings take what room is left after the title's minimum; where they do not fit they fold
behind Chat settings (chat.js `fitHead`, measured), which opens them as a wrapped row of their
own under the header. */
.head.chathead {
grid-template-columns: minmax(min(6rem, 30%), max-content) minmax(0, 1fr) minmax(0, max-content) auto auto;
grid-template-areas: "title place set fold tools";
column-gap: var(--sp-2);
}
.chathead > .head__title { grid-area: title; }
.chathead > .head__place { grid-area: place; overflow: hidden; }
.chathead > .head__tools { grid-area: tools; }
.chathead__fold { grid-area: fold; }
.chathead__fold[hidden] { display: none; }
.chathead__settings { grid-area: set; display: flex; flex-wrap: nowrap; gap: var(--sp-1); align-items: center; justify-content: start; min-width: 0; overflow: hidden; }
.chathead__settings > * { flex: none; }
.chathead__settings:empty { display: none; }
.chathead[data-fold="true"] .chathead__settings { display: none; }
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title place set fold tools" "row row row row row"; row-gap: var(--sp-2); }
.chathead[data-fold="true"][data-unfold="true"] .chathead__settings { grid-area: row; display: flex; flex-wrap: wrap; justify-content: start; overflow: visible; padding-bottom: var(--sp-1); }
.chathead__settings .cchip { max-width: min(var(--w-field), 100%); }
.head__place .cchip { min-width: 0; }
.head__place > .cchip:last-child { flex: 0 1 auto; }
.main > .head { grid-row: 1; }
.main > .thread { grid-row: 2; }
.main > .dock { grid-row: 3; }
@@ -530,7 +552,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
/* A new chat is the same page, empty: the greeting low in the space above the composer, which
sits in the middle of the window rather than at its foot. */
.main[data-new="true"] { grid-template-rows: 0 minmax(0, 1fr) auto minmax(0, 0.8fr); }
.main[data-new="true"] { grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 0.8fr); }
.main[data-new="true"] .thread { display: grid; align-content: end; padding-bottom: var(--sp-6); }
.hello { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; }
.hello__mark { width: var(--sp-10); height: var(--sp-10); }
@@ -1414,53 +1436,206 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
}
.tabs__tab[aria-selected="true"] { color: var(--ink); background: var(--surface-active); }
.files { display: grid; align-content: start; }
.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; }
.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; }
.files__where { min-width: 0; }
/* The server's pane: Upload and New folder before Read again. */
#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); }
#server-status { white-space: pre-line; }
.files__where select { width: 100%; height: var(--control-h-sm); }
.files__tree { min-height: 0; }
.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); }
.pane__pick { width: 100%; }
.pane__note { padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--ink-muted); }
.files__tree > .btn { justify-self: start; }
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
.tree__row {
width: 100%;
height: var(--control-h-sm);
/* --- Files: two explorers in the web's shape ------------------------------------------------
The page's whole width under its head, like a file manager: where you are and what can be done
there, then the folder tree, the list and the chosen file's details side by side, each its own
scroller. The list is one grid with a track per column and every row a subgrid of it, so a long
name never pushes the type, size or date out of line: the name's track is the one that gives.
Narrower, the details go first (a file then opens in the viewer), then the tree (the crumbs
are the way up), then the type and date columns. */
.page__body.files { display: grid; grid-template-rows: minmax(0, 1fr); padding: 0; overflow: hidden; }
.page__body.files > * { max-width: none; margin: 0; }
.fx { display: grid; grid-template-rows: auto auto minmax(0, 1fr); min-width: 0; min-height: 0; container: fx / inline-size; }
.fx > .fx__bar { grid-row: 1; }
.fx > .fx__status { grid-row: 2; }
.fx > .fx__panes { grid-row: 3; }
.fx[hidden] { display: none; }
.fx__bar {
display: grid;
grid-template-columns: calc(var(--depth, 0) * var(--sp-4)) 1rem 1rem minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "where crumbs tools";
align-items: center;
gap: var(--sp-2);
min-height: var(--header-height);
padding: var(--sp-1) var(--sp-4);
border-bottom: var(--border-w) solid var(--border);
background: var(--surface);
}
.fx__where { grid-area: where; min-width: 0; max-width: 18rem; }
.fx__bar > .crumbs { grid-area: crumbs; }
.fx__where:empty { display: none; }
.fx__tools { grid-area: tools; display: flex; align-items: center; gap: var(--sp-1); }
.fx__status { margin: 0; padding: var(--sp-2) var(--sp-4); border-bottom: var(--border-w) solid var(--border); white-space: pre-line; }
.fx__status[hidden] { display: none; }
.pane__pick { width: 100%; height: var(--control-h-sm); }
.crumbs {
display: flex;
align-items: center;
gap: var(--sp-1);
min-width: 0;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
font: var(--text-sm) var(--font-mono);
}
.crumbs__sep { color: var(--ink-faint); }
.crumbs__link {
min-height: var(--control-h-sm);
padding: 0 var(--sp-1);
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
}
.crumbs__link:hover { color: var(--ink); background: var(--surface-hover); }
.crumbs__here { padding: 0 var(--sp-1); color: var(--ink); font-weight: 600; }
.fx__panes { display: grid; grid-template-columns: minmax(11rem, 14rem) minmax(0, 1fr) minmax(14rem, 18rem); min-width: 0; min-height: 0; }
.fx__tree, .fx__list, .fx__details { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.fx__tree { padding: var(--sp-2); border-right: var(--border-w) solid var(--border); background: var(--surface); }
.fx__list { padding: 0 var(--sp-4) var(--sp-8); container: fxlist / inline-size; }
.fx__details { display: grid; align-content: start; gap: var(--sp-3); padding: var(--sp-4); border-left: var(--border-w) solid var(--border); background: var(--surface); }
.fx[data-empty="true"] .fx__tree, .fx[data-empty="true"] .fx__details { display: none; }
.fx[data-empty="true"] .fx__panes { grid-template-columns: minmax(0, 1fr); }
/* The folder tree: a chevron opens a folder, its name lists it. */
.ftree, .ftree ul { list-style: none; margin: 0; padding: 0; }
.ftree__line { display: grid; grid-template-columns: calc(var(--depth, 0) * var(--sp-3)) 1.25rem minmax(0, 1fr); align-items: center; }
.ftree__line::before { content: ""; }
.ftree__chevron { display: grid; place-items: center; width: 1.25rem; height: var(--control-h-sm); padding: 0; color: var(--ink-faint); background: transparent; border: 0; cursor: pointer; }
.ftree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); }
.ftree__chevron[aria-expanded="true"] .icon { transform: rotate(90deg); }
.ftree__row {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr);
gap: var(--sp-2);
align-items: center;
width: 100%;
min-height: var(--control-h-sm);
padding: 0 var(--sp-2);
text-align: left;
font: var(--text-sm) var(--font-body);
text-align: left;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
}
.ftree__row:hover { color: var(--ink); background: var(--surface-hover); }
.ftree__row.is-current { color: var(--ink); background: var(--surface-active); font-weight: 600; }
.ftree__root { margin-bottom: var(--sp-1); }
.ftree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* The list. */
.frows {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(3.5rem, auto) minmax(4.5rem, auto) minmax(6.5rem, auto) var(--control-h-sm);
column-gap: var(--sp-4);
}
.frow {
position: relative;
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
min-height: var(--row-h);
margin: 0 calc(-1 * var(--sp-2));
padding: 0 var(--sp-2);
border-bottom: var(--border-w) solid var(--border);
border-radius: var(--radius-sm);
}
.frow:not(.frow--head):hover { background: var(--surface-hover); }
.frow.is-chosen { background: var(--surface-active); }
.frow--head {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg);
color: var(--ink-faint);
font: var(--text-xs) var(--font-ui);
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 0;
}
.frow__name { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.frow__open {
display: inline-flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
min-height: var(--row-h);
padding: 0;
font: var(--text-sm) var(--font-body);
text-align: left;
color: var(--ink);
background: transparent;
border: 0;
border-radius: var(--radius-lg);
cursor: pointer;
}
/* The indent's track needs an item of its own, or the chevron would sit in it. */
.tree__row::before { content: ""; }
.tree__row:hover { background: var(--surface-hover); }
.tree__row .icon { color: var(--ink-muted); }
.tree__chevron { display: grid; place-items: center; width: 1rem; }
.tree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); }
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
/* A row with a more button beside it: the button on the row's own line, its folder below both. */
.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
.tree__item > ul { grid-column: 1 / -1; }
.tree__more { opacity: 0; }
.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; }
@media (hover: none) { .tree__more { opacity: 1; } }
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
/* The whole row opens what it names; the buttons at its end stay on top. */
.frow__open::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
.frow__open:focus-visible { outline: none; }
.frow__open:focus-visible::after { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); }
.frow__name .badge, .frow__actions { position: relative; z-index: 1; }
.frow__icon { display: inline-flex; color: var(--ink-muted); }
.frow__icon--dir, .frow__icon--mount { color: var(--accent); }
.frow__icon--code, .frow__icon--html { color: var(--leaf); }
.frow__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.frow__type { font: var(--text-xs) var(--font-mono); color: var(--ink-faint); white-space: nowrap; }
.frow__size, .frow__when { font-size: var(--text-sm); color: var(--ink-muted); white-space: nowrap; text-align: right; }
.frow--head .frow__size, .frow--head .frow__when { font-size: var(--text-xs); }
.frow__actions { display: flex; justify-content: flex-end; }
.frow__more { opacity: 0; }
.frow:hover .frow__more, .frow__more:focus-visible, .frow__more[aria-expanded="true"] { opacity: 1; }
@media (hover: none) { .frow__more { opacity: 1; } }
.badge { padding: 0 var(--sp-1); font: var(--text-xs) var(--font-ui); color: var(--ink-muted); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); white-space: nowrap; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fx__empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4); text-align: center; color: var(--ink-muted); }
.fx__empty .icon { width: 24px; height: 24px; color: var(--leaf); }
/* The chosen file. */
.fdetails__none { display: grid; justify-items: center; gap: var(--sp-2); padding: var(--sp-8) 0; text-align: center; color: var(--ink-muted); font-size: var(--text-sm); }
.fdetails__none .icon { width: 24px; height: 24px; color: var(--ink-faint); }
.fdetails__id { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; }
.fdetails__name { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow-wrap: anywhere; }
.fdetails__facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-sm); }
.fdetails__facts dt { color: var(--ink-faint); }
.fdetails__facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.fdetails__do { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.fdetails__preview { min-width: 0; }
.fdetails__text { margin: 0; max-height: 24rem; overflow: auto; padding: var(--sp-3); font: var(--text-xs) / var(--leading-normal) var(--font-mono); white-space: pre; color: var(--ink); background: var(--bg); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); }
.fdetails__note { font-size: var(--text-sm); color: var(--ink-muted); }
/* Loading, still waiting, refused: the words, then Try again on the same line when it fits. */
.fnote { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-1); padding-inline-start: calc(var(--sp-1) + var(--depth, 0) * var(--sp-3)); font-size: var(--text-sm); color: var(--ink-muted); }
.ftree .fnote { font-size: var(--text-xs); }
.fnote.is-error { color: var(--warning); }
.fnote > span { min-width: 0; overflow-wrap: anywhere; }
.fnote__retry { flex: none; }
/* By the room the page has, not the window's: the sidebar takes its share first. */
@container fx (max-width: 60rem) {
.fx__panes { grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr); }
.fx__details { display: none; }
}
@container fx (max-width: 40rem) {
.fx__panes { grid-template-columns: minmax(0, 1fr); }
.fx__tree { display: none; }
.fx__bar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "where where" "crumbs tools"; padding: var(--sp-1) var(--sp-3); }
.fx__where { max-width: none; }
.fx__list { padding: 0 var(--sp-3) var(--sp-8); }
}
/* A list narrower than its columns keeps the name and the size; the type and date go. */
@container fxlist (max-width: 28rem) {
.frows { grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm); column-gap: var(--sp-2); }
.frows .frow__type, .frows .frow__when { display: none; }
}
@media (max-width: 48rem) {
.frow, .frow__open { min-height: var(--tap-min); }
}
/* An empty page: an icon, a heading, a sentence, centred. */
.empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4) var(--sp-4); text-align: center; }
@@ -1775,6 +1950,8 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.head__place { grid-column: 1 / -1; grid-row: 2; }
.head__place:empty { display: none; }
.cchip { max-width: var(--w-field-xs); }
.head.chathead { grid-template-columns: minmax(min(6rem, 30%), 1fr) minmax(0, max-content) auto auto; grid-template-areas: "title set fold tools" "place place place place"; }
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title set fold tools" "place place place place" "row row row row"; }
}
/* 48rem (--bp-narrow): the sidebar is a drawer over the page, opened from the title bar. */
+160
View File
@@ -47,6 +47,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastního modelu běží na prvním volném z těchto, v tomto pořadí. Žádný nezaškrtnutý: všechny.",
"About": "O aplikaci",
"Account": "Účet",
"Actions": "Akce",
"Activity": "Aktivita",
"Add": "Přidat",
"Add folder…": "Přidat složku…",
@@ -54,6 +55,7 @@ const LEMBAS_I18N = {
"Agent": "Agent",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentní konverzace mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači.",
"Agent: %(why)s": "Agent: %(why)s",
"All files": "Všechny soubory",
"Allow": "Povolit",
"Always": "Vždy",
"Always allow": "Vždy povolit",
@@ -77,10 +79,13 @@ const LEMBAS_I18N = {
"Background tasks": "Úlohy na pozadí",
"Cancel": "Zrušit",
"Carry it out": "Provést to",
"Changed": "Změněno",
"Chat": "Konverzace",
"Chat settings": "Nastavení konverzace",
"Chats": "Konverzace",
"Check for updates…": "Zkontrolovat aktualizace…",
"Choose": "Vybrat",
"Choose a file to see it here.": "Vyberte soubor a uvidíte ho tady.",
"Choose a folder first.": "Nejprve vyberte složku.",
"Choose a folder…": "Vybrat složku…",
"Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.",
@@ -101,6 +106,7 @@ const LEMBAS_I18N = {
"Connected.": "Připojeno.",
"Connected. This shell is also open in another tab, and they share a size.": "Připojeno. Tento shell je otevřený i na jiné kartě a sdílejí velikost.",
"Connecting…": "Připojuje se…",
"Contents": "Obsah",
"Context": "Kontext",
"Context and background tasks": "Kontext a úlohy na pozadí",
"Context used": "Využitý kontext",
@@ -122,6 +128,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Smazat tuto konverzaci?",
"Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?",
"Deny": "Zamítnout",
"Details": "Podrobnosti",
"Dictate a message": "Nadiktovat zprávu",
"Disconnected. Close and reopen to reconnect.": "Odpojeno. Pro opětovné připojení zavřete a znovu otevřete.",
"Done": "Hotovo",
@@ -155,10 +162,12 @@ const LEMBAS_I18N = {
"Folder name": "Název složky",
"Folder on %(device)s": "Složka na zařízení %(device)s",
"Folder on the computer": "Složka na počítači",
"Folders": "Složky",
"Free space": "Volné místo",
"Hello": "Dobrý den",
"Hello, %(name)s": "Dobrý den, %(name)s",
"History": "Historie",
"Home": "Domů",
"How many work on it at once, the run's own agent among them, on the same model.": "Kolik jich na ní pracuje současně, včetně vlastního agenta běhu, na stejném modelu.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastními slovy. Server z nich vytvoří pravidlo a pokyn a před uložením vám ukáže další spuštění.",
"Install LLeMbas %(version)s…": "Nainstalovat LLeMbas %(version)s…",
@@ -197,10 +206,12 @@ const LEMBAS_I18N = {
"Models": "Modely",
"Models for scheduled tasks": "Modely pro naplánované úlohy",
"More": "Více",
"More for %(name)s": "Další pro %(name)s",
"Move to folder…": "Přesunout do složky…",
"Move to the trash": "Přesunout do koše",
"Move “%(name)s” to the trash? It can be restored from there.": "Přesunout „%(name)s“ do koše? Odtamtud ho lze obnovit.",
"Moved to the trash.": "Přesunuto do koše.",
"Name": "Název",
"Never": "Nikdy",
"New agent chat": "Nová agentní konverzace",
"New chat": "Nová konverzace",
@@ -225,6 +236,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Zaznamenal model",
"Nothing has been run in this shell yet.": "V tomto shellu se zatím nic nespustilo.",
"Nothing has been said in the room yet.": "V místnosti zatím nikdo nic neřekl.",
"Nothing here yet. Not even a hobbit hole.": "Zatím tu nic není. Ani hobití nora.",
"Nothing is scheduled.": "Nic není naplánováno.",
"Nothing matches.": "Nic neodpovídá.",
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
@@ -250,6 +262,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Nebo napište odpověď",
"Outputs": "Výstupy",
"Page": "Stránka",
"Path": "Cesta",
"Pause": "Pozastavit",
"Pause %(title)s": "Pozastavit %(title)s",
"Paused": "Pozastaveno",
@@ -316,6 +329,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Přihlášen jako %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.",
"Since the last reply": "Od poslední odpovědi",
"Size": "Velikost",
"Skills": "Dovednosti",
"Skipped": "Vynecháno",
"Something to remember…": "Něco k zapamatování…",
@@ -379,7 +393,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpověď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
"The result is no longer there.": "Výsledek už tu není.",
"The server has not answered in %(n)s seconds.": "Server neodpověděl do %(n)s sekund.",
"The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s",
"The server's answer could not be read.": "Odpověď serveru nešlo přečíst.",
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
"The terminal could not be loaded.": "Terminál se nepodařilo načíst.",
@@ -410,6 +426,7 @@ const LEMBAS_I18N = {
"Tools": "Nástroje",
"Tools and skills…": "Nástroje a dovednosti…",
"Try again": "Zkusit znovu",
"Type": "Typ",
"Type a command or a chat's title": "Napište příkaz nebo název konverzace",
"Type a folder…": "Zadat složku…",
"Unpin chat": "Odepnout konverzaci",
@@ -431,6 +448,7 @@ const LEMBAS_I18N = {
"When": "Kdy",
"When it matters": "Když na tom záleží",
"Where it runs": "Kde se spouští",
"Where you are": "Kde jste",
"Wide": "Široký",
"Writing": "Píše",
"Writing down…": "Zapisuji…",
@@ -466,6 +484,7 @@ const LEMBAS_I18N = {
"failed": "selhalo",
"findings: %(n)s": "zjištění: %(n)s",
"finished": "dokončený",
"folder": "složka",
"free": "volný",
"idle": "nečinný",
"it serves one request at a time and is answering another": "vyřizuje jeden požadavek najednou a právě vyřizuje jiný",
@@ -473,6 +492,7 @@ const LEMBAS_I18N = {
"late": "se zpožděním",
"light": "světlý",
"messages: %(n)s": "zprávy: %(n)s",
"mount": "připojení",
"not linked": "nepropojeno",
"not linked now": "teď není propojený",
"off": "vypnuto",
@@ -532,6 +552,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Eine Aufgabe ohne eigenes Modell läuft auf dem ersten freien davon, in dieser Reihenfolge. Keines angehakt: alle.",
"About": "Über",
"Account": "Konto",
"Actions": "Aktionen",
"Activity": "Aktivität",
"Add": "Hinzufügen",
"Add folder…": "Ordner hinzufügen…",
@@ -539,6 +560,7 @@ const LEMBAS_I18N = {
"Agent": "Agent",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agenten-Chats können in diesen Ordnern und allem darin arbeiten — nirgendwo sonst auf diesem Computer.",
"Agent: %(why)s": "Agent: %(why)s",
"All files": "Alle Dateien",
"Allow": "Erlauben",
"Always": "Immer",
"Always allow": "Immer erlauben",
@@ -562,10 +584,13 @@ const LEMBAS_I18N = {
"Background tasks": "Hintergrundaufgaben",
"Cancel": "Abbrechen",
"Carry it out": "Ausführen",
"Changed": "Geändert",
"Chat": "Chat",
"Chat settings": "Chat-Einstellungen",
"Chats": "Chats",
"Check for updates…": "Nach Updates suchen…",
"Choose": "Auswählen",
"Choose a file to see it here.": "Wählen Sie eine Datei, um sie hier zu sehen.",
"Choose a folder first.": "Wählen Sie zuerst einen Ordner.",
"Choose a folder…": "Ordner wählen…",
"Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.",
@@ -586,6 +611,7 @@ const LEMBAS_I18N = {
"Connected.": "Verbunden.",
"Connected. This shell is also open in another tab, and they share a size.": "Verbunden. Diese Shell ist auch in einem anderen Tab offen, und beide teilen sich eine Größe.",
"Connecting…": "Verbinde…",
"Contents": "Inhalt",
"Context": "Kontext",
"Context and background tasks": "Kontext und Hintergrundaufgaben",
"Context used": "Genutzter Kontext",
@@ -607,6 +633,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Diesen Chat löschen?",
"Delete this scheduled task?": "Diese geplante Aufgabe löschen?",
"Deny": "Ablehnen",
"Details": "Details",
"Dictate a message": "Nachricht diktieren",
"Disconnected. Close and reopen to reconnect.": "Getrennt. Schließen und erneut öffnen, um neu zu verbinden.",
"Done": "Fertig",
@@ -640,10 +667,12 @@ const LEMBAS_I18N = {
"Folder name": "Ordnername",
"Folder on %(device)s": "Ordner auf %(device)s",
"Folder on the computer": "Ordner auf dem Computer",
"Folders": "Ordner",
"Free space": "Freier Platz",
"Hello": "Hallo",
"Hello, %(name)s": "Hallo, %(name)s",
"History": "Verlauf",
"Home": "Start",
"How many work on it at once, the run's own agent among them, on the same model.": "Wie viele gleichzeitig daran arbeiten, der eigene Agent des Laufs mitgezählt, mit demselben Modell.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "In Ihren eigenen Worten. Der Server macht daraus eine Regel und eine Anweisung und zeigt Ihnen die nächsten Läufe, bevor etwas gespeichert wird.",
"Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…",
@@ -682,10 +711,12 @@ const LEMBAS_I18N = {
"Models": "Modelle",
"Models for scheduled tasks": "Modelle für geplante Aufgaben",
"More": "Mehr",
"More for %(name)s": "Mehr für %(name)s",
"Move to folder…": "In Ordner verschieben…",
"Move to the trash": "In den Papierkorb",
"Move “%(name)s” to the trash? It can be restored from there.": "„%(name)s“ in den Papierkorb verschieben? Von dort lässt es sich wiederherstellen.",
"Moved to the trash.": "In den Papierkorb verschoben.",
"Name": "Name",
"Never": "Nie",
"New agent chat": "Neuer Agenten-Chat",
"New chat": "Neuer Chat",
@@ -710,6 +741,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Von einem Modell notiert",
"Nothing has been run in this shell yet.": "In dieser Shell wurde noch nichts ausgeführt.",
"Nothing has been said in the room yet.": "Im Raum wurde noch nichts gesagt.",
"Nothing here yet. Not even a hobbit hole.": "Hier ist noch nichts. Nicht einmal eine Hobbithöhle.",
"Nothing is scheduled.": "Nichts ist geplant.",
"Nothing matches.": "Nichts gefunden.",
"Nothing remembered yet.": "Noch nichts behalten.",
@@ -735,6 +767,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Oder eine Antwort eingeben",
"Outputs": "Ausgaben",
"Page": "Seite",
"Path": "Pfad",
"Pause": "Pausieren",
"Pause %(title)s": "%(title)s pausieren",
"Paused": "Pausiert",
@@ -801,6 +834,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Angemeldet als %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.",
"Since the last reply": "Seit der letzten Antwort",
"Size": "Größe",
"Skills": "Fertigkeiten",
"Skipped": "Ausgelassen",
"Something to remember…": "Etwas, das behalten werden soll…",
@@ -864,7 +898,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Die Antwort ist fertig.",
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
"The result is no longer there.": "Das Ergebnis ist nicht mehr da.",
"The server has not answered in %(n)s seconds.": "Der Server hat seit %(n)s Sekunden nicht geantwortet.",
"The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s",
"The server's answer could not be read.": "Die Antwort des Servers konnte nicht gelesen werden.",
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein Bild.",
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
"The terminal could not be loaded.": "Das Terminal konnte nicht geladen werden.",
@@ -895,6 +931,7 @@ const LEMBAS_I18N = {
"Tools": "Werkzeuge",
"Tools and skills…": "Werkzeuge und Fähigkeiten…",
"Try again": "Erneut versuchen",
"Type": "Typ",
"Type a command or a chat's title": "Befehl oder Chat-Titel eingeben",
"Type a folder…": "Ordner eingeben…",
"Unpin chat": "Chat loslösen",
@@ -916,6 +953,7 @@ const LEMBAS_I18N = {
"When": "Wann",
"When it matters": "Wenn es zählt",
"Where it runs": "Wo es läuft",
"Where you are": "Wo Sie sind",
"Wide": "Breit",
"Writing": "Schreibe",
"Writing down…": "Wird aufgeschrieben…",
@@ -951,6 +989,7 @@ const LEMBAS_I18N = {
"failed": "fehlgeschlagen",
"findings: %(n)s": "Erkenntnisse: %(n)s",
"finished": "fertig",
"folder": "Ordner",
"free": "frei",
"idle": "untätig",
"it serves one request at a time and is answering another": "es bedient eine Anfrage auf einmal und beantwortet gerade eine andere",
@@ -958,6 +997,7 @@ const LEMBAS_I18N = {
"late": "verspätet",
"light": "hell",
"messages: %(n)s": "Nachrichten: %(n)s",
"mount": "Einhängung",
"not linked": "nicht verbunden",
"not linked now": "gerade nicht verbunden",
"off": "aus",
@@ -1017,6 +1057,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Una tarea sin modelo propio se ejecuta en el primero libre de estos, en este orden. Ninguno marcado: todos.",
"About": "Acerca de",
"Account": "Cuenta",
"Actions": "Acciones",
"Activity": "Actividad",
"Add": "Añadir",
"Add folder…": "Añadir carpeta…",
@@ -1024,6 +1065,7 @@ const LEMBAS_I18N = {
"Agent": "Agente",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Los chats de agente pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este equipo.",
"Agent: %(why)s": "Agente: %(why)s",
"All files": "Todos los archivos",
"Allow": "Permitir",
"Always": "Siempre",
"Always allow": "Permitir siempre",
@@ -1047,10 +1089,13 @@ const LEMBAS_I18N = {
"Background tasks": "Tareas en segundo plano",
"Cancel": "Cancelar",
"Carry it out": "Llevarlo a cabo",
"Changed": "Cambiado",
"Chat": "Chat",
"Chat settings": "Ajustes del chat",
"Chats": "Chats",
"Check for updates…": "Buscar actualizaciones…",
"Choose": "Elegir",
"Choose a file to see it here.": "Elige un archivo para verlo aquí.",
"Choose a folder first.": "Elija primero una carpeta.",
"Choose a folder…": "Elegir una carpeta…",
"Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.",
@@ -1071,6 +1116,7 @@ const LEMBAS_I18N = {
"Connected.": "Conectado.",
"Connected. This shell is also open in another tab, and they share a size.": "Conectado. Este shell también está abierto en otra pestaña, y comparten tamaño.",
"Connecting…": "Conectando…",
"Contents": "Contenido",
"Context": "Contexto",
"Context and background tasks": "Contexto y tareas en segundo plano",
"Context used": "Contexto usado",
@@ -1092,6 +1138,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "¿Eliminar este chat?",
"Delete this scheduled task?": "¿Eliminar esta tarea programada?",
"Deny": "Denegar",
"Details": "Detalles",
"Dictate a message": "Dictar un mensaje",
"Disconnected. Close and reopen to reconnect.": "Desconectado. Cierre y vuelva a abrir para reconectar.",
"Done": "Hecho",
@@ -1125,10 +1172,12 @@ const LEMBAS_I18N = {
"Folder name": "Nombre de la carpeta",
"Folder on %(device)s": "Carpeta en %(device)s",
"Folder on the computer": "Carpeta en el ordenador",
"Folders": "Carpetas",
"Free space": "Espacio libre",
"Hello": "Hola",
"Hello, %(name)s": "Hola, %(name)s",
"History": "Historial",
"Home": "Inicio",
"How many work on it at once, the run's own agent among them, on the same model.": "Cuántos trabajan en ella a la vez, contando el propio agente de la ejecución, con el mismo modelo.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con sus propias palabras. El servidor las convierte en una regla y una instrucción, y le muestra las próximas ejecuciones antes de guardar nada.",
"Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…",
@@ -1167,10 +1216,12 @@ const LEMBAS_I18N = {
"Models": "Modelos",
"Models for scheduled tasks": "Modelos para tareas programadas",
"More": "Más",
"More for %(name)s": "Más para %(name)s",
"Move to folder…": "Mover a una carpeta…",
"Move to the trash": "Mover a la papelera",
"Move “%(name)s” to the trash? It can be restored from there.": "¿Mover «%(name)s» a la papelera? Se puede restaurar desde allí.",
"Moved to the trash.": "Movido a la papelera.",
"Name": "Nombre",
"Never": "Nunca",
"New agent chat": "Nuevo chat de agente",
"New chat": "Nuevo chat",
@@ -1195,6 +1246,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Anotado por un modelo",
"Nothing has been run in this shell yet.": "Aún no se ha ejecutado nada en este shell.",
"Nothing has been said in the room yet.": "Todavía no se ha dicho nada en la sala.",
"Nothing here yet. Not even a hobbit hole.": "Aquí aún no hay nada. Ni siquiera un agujero hobbit.",
"Nothing is scheduled.": "No hay nada programado.",
"Nothing matches.": "No hay coincidencias.",
"Nothing remembered yet.": "Todavía no se recuerda nada.",
@@ -1220,6 +1272,7 @@ const LEMBAS_I18N = {
"Or type an answer": "O escriba una respuesta",
"Outputs": "Resultados",
"Page": "Página",
"Path": "Ruta",
"Pause": "Pausar",
"Pause %(title)s": "Pausar %(title)s",
"Paused": "En pausa",
@@ -1286,6 +1339,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Sesión iniciada como %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.",
"Since the last reply": "Desde la última respuesta",
"Size": "Tamaño",
"Skills": "Habilidades",
"Skipped": "Omitida",
"Something to remember…": "Algo que recordar…",
@@ -1349,7 +1403,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La respuesta está lista.",
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
"The result is no longer there.": "El resultado ya no está.",
"The server has not answered in %(n)s seconds.": "El servidor no ha respondido en %(n)s segundos.",
"The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s",
"The server's answer could not be read.": "No se pudo leer la respuesta del servidor.",
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
"The terminal could not be loaded.": "No se ha podido cargar el terminal.",
@@ -1380,6 +1436,7 @@ const LEMBAS_I18N = {
"Tools": "Herramientas",
"Tools and skills…": "Herramientas y habilidades…",
"Try again": "Reintentar",
"Type": "Tipo",
"Type a command or a chat's title": "Escriba un comando o el título de un chat",
"Type a folder…": "Escribir una carpeta…",
"Unpin chat": "Dejar de fijar el chat",
@@ -1401,6 +1458,7 @@ const LEMBAS_I18N = {
"When": "Cuándo",
"When it matters": "Cuando importa",
"Where it runs": "Dónde se ejecuta",
"Where you are": "Dónde está",
"Wide": "Ancha",
"Writing": "Escribiendo",
"Writing down…": "Transcribiendo…",
@@ -1436,6 +1494,7 @@ const LEMBAS_I18N = {
"failed": "falló",
"findings: %(n)s": "hallazgos: %(n)s",
"finished": "terminado",
"folder": "carpeta",
"free": "libre",
"idle": "inactivo",
"it serves one request at a time and is answering another": "atiende una petición a la vez y está respondiendo otra",
@@ -1443,6 +1502,7 @@ const LEMBAS_I18N = {
"late": "con retraso",
"light": "claro",
"messages: %(n)s": "mensajes: %(n)s",
"mount": "montaje",
"not linked": "no vinculado",
"not linked now": "no vinculado ahora",
"off": "desactivado",
@@ -1502,6 +1562,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Une tâche sans modèle à elle tourne sur le premier libre de ceux-ci, dans cet ordre. Aucun coché : tous.",
"About": "À propos",
"Account": "Compte",
"Actions": "Actions",
"Activity": "Activité",
"Add": "Ajouter",
"Add folder…": "Ajouter un dossier…",
@@ -1509,6 +1570,7 @@ const LEMBAS_I18N = {
"Agent": "Agent",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Les discussions d'agent peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur.",
"Agent: %(why)s": "Agent : %(why)s",
"All files": "Tous les fichiers",
"Allow": "Autoriser",
"Always": "Toujours",
"Always allow": "Toujours autoriser",
@@ -1532,10 +1594,13 @@ const LEMBAS_I18N = {
"Background tasks": "Tâches en arrière-plan",
"Cancel": "Annuler",
"Carry it out": "Exécuter",
"Changed": "Modifié",
"Chat": "Conversation",
"Chat settings": "Paramètres de la conversation",
"Chats": "Conversations",
"Check for updates…": "Rechercher des mises à jour…",
"Choose": "Choisir",
"Choose a file to see it here.": "Choisissez un fichier pour le voir ici.",
"Choose a folder first.": "Choisissez d’abord un dossier.",
"Choose a folder…": "Choisir un dossier…",
"Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.",
@@ -1556,6 +1621,7 @@ const LEMBAS_I18N = {
"Connected.": "Connecté.",
"Connected. This shell is also open in another tab, and they share a size.": "Connecté. Ce shell est aussi ouvert dans un autre onglet, et ils partagent une taille.",
"Connecting…": "Connexion…",
"Contents": "Contenu",
"Context": "Contexte",
"Context and background tasks": "Contexte et tâches en arrière-plan",
"Context used": "Contexte utilisé",
@@ -1577,6 +1643,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Supprimer cette conversation ?",
"Delete this scheduled task?": "Supprimer cette tâche planifiée ?",
"Deny": "Refuser",
"Details": "Détails",
"Dictate a message": "Dicter un message",
"Disconnected. Close and reopen to reconnect.": "Déconnecté. Fermez et rouvrez pour vous reconnecter.",
"Done": "Terminé",
@@ -1610,10 +1677,12 @@ const LEMBAS_I18N = {
"Folder name": "Nom du dossier",
"Folder on %(device)s": "Dossier sur %(device)s",
"Folder on the computer": "Dossier sur l’ordinateur",
"Folders": "Dossiers",
"Free space": "Espace libre",
"Hello": "Bonjour",
"Hello, %(name)s": "Bonjour, %(name)s",
"History": "Historique",
"Home": "Accueil",
"How many work on it at once, the run's own agent among them, on the same model.": "Combien y travaillent à la fois, l’agent de l’exécution compris, avec le même modèle.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Avec vos propres mots. Le serveur en fait une règle et une instruction, et vous montre les prochaines exécutions avant que rien ne soit enregistré.",
"Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…",
@@ -1652,10 +1721,12 @@ const LEMBAS_I18N = {
"Models": "Modèles",
"Models for scheduled tasks": "Modèles pour les tâches planifiées",
"More": "Plus",
"More for %(name)s": "Plus pour %(name)s",
"Move to folder…": "Déplacer dans un dossier…",
"Move to the trash": "Placer dans la corbeille",
"Move “%(name)s” to the trash? It can be restored from there.": "Placer « %(name)s » dans la corbeille ? On peut l'y restaurer.",
"Moved to the trash.": "Placé dans la corbeille.",
"Name": "Nom",
"Never": "Jamais",
"New agent chat": "Nouvelle discussion d'agent",
"New chat": "Nouvelle conversation",
@@ -1680,6 +1751,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Noté par un modèle",
"Nothing has been run in this shell yet.": "Rien n'a encore été exécuté dans ce shell.",
"Nothing has been said in the room yet.": "Rien n’a encore été dit dans la salle.",
"Nothing here yet. Not even a hobbit hole.": "Rien ici pour l'instant. Pas même un trou de hobbit.",
"Nothing is scheduled.": "Rien n’est planifié.",
"Nothing matches.": "Aucun résultat.",
"Nothing remembered yet.": "Rien n’est encore retenu.",
@@ -1705,6 +1777,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Ou saisissez une réponse",
"Outputs": "Sorties",
"Page": "Page",
"Path": "Chemin",
"Pause": "Mettre en pause",
"Pause %(title)s": "Mettre %(title)s en pause",
"Paused": "En pause",
@@ -1771,6 +1844,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Connecté en tant que %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.",
"Since the last reply": "Depuis la dernière réponse",
"Size": "Taille",
"Skills": "Compétences",
"Skipped": "Ignorée",
"Something to remember…": "Quelque chose à retenir…",
@@ -1834,7 +1908,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La réponse est prête.",
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
"The result is no longer there.": "Le résultat n'est plus là.",
"The server has not answered in %(n)s seconds.": "Le serveur n’a pas répondu depuis %(n)s secondes.",
"The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s",
"The server's answer could not be read.": "La réponse du serveur n’a pas pu être lue.",
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
"The terminal could not be loaded.": "Le terminal n'a pas pu être chargé.",
@@ -1865,6 +1941,7 @@ const LEMBAS_I18N = {
"Tools": "Outils",
"Tools and skills…": "Outils et compétences…",
"Try again": "Réessayer",
"Type": "Type",
"Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation",
"Type a folder…": "Saisir un dossier…",
"Unpin chat": "Désépingler la discussion",
@@ -1886,6 +1963,7 @@ const LEMBAS_I18N = {
"When": "Quand",
"When it matters": "Quand c'est important",
"Where it runs": "Où elle s’exécute",
"Where you are": "Où vous êtes",
"Wide": "Large",
"Writing": "Écriture",
"Writing down…": "Transcription…",
@@ -1921,6 +1999,7 @@ const LEMBAS_I18N = {
"failed": "échoué",
"findings: %(n)s": "constats : %(n)s",
"finished": "terminé",
"folder": "dossier",
"free": "libre",
"idle": "inactif",
"it serves one request at a time and is answering another": "il traite une requête à la fois et en traite une autre",
@@ -1928,6 +2007,7 @@ const LEMBAS_I18N = {
"late": "en retard",
"light": "clair",
"messages: %(n)s": "messages : %(n)s",
"mount": "montage",
"not linked": "non relié",
"not linked now": "non relié pour l’instant",
"off": "désactivé",
@@ -1987,6 +2067,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "A saját modell nélküli feladat ezek közül az első szabadon fut, ebben a sorrendben. Ha egy sincs bejelölve: mindegyiken.",
"About": "Névjegy",
"Account": "Fiók",
"Actions": "Műveletek",
"Activity": "Tevékenység",
"Add": "Hozzáadás",
"Add folder…": "Mappa hozzáadása…",
@@ -1994,6 +2075,7 @@ const LEMBAS_I18N = {
"Agent": "Ügynök",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Az ügynökcsevegések ezekben a mappákban és mindenben, ami bennük van, dolgozhatnak — sehol máshol ezen a számítógépen.",
"Agent: %(why)s": "Ügynök: %(why)s",
"All files": "Minden fájl",
"Allow": "Engedélyezés",
"Always": "Mindig",
"Always allow": "Mindig engedélyez",
@@ -2017,10 +2099,13 @@ const LEMBAS_I18N = {
"Background tasks": "Háttérfeladatok",
"Cancel": "Mégse",
"Carry it out": "Végrehajtás",
"Changed": "Módosítva",
"Chat": "Csevegés",
"Chat settings": "Csevegés beállításai",
"Chats": "Csevegések",
"Check for updates…": "Frissítések keresése…",
"Choose": "Kiválasztás",
"Choose a file to see it here.": "Válasszon egy fájlt, hogy itt lássa.",
"Choose a folder first.": "Először válasszon mappát.",
"Choose a folder…": "Mappa kiválasztása…",
"Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.",
@@ -2041,6 +2126,7 @@ const LEMBAS_I18N = {
"Connected.": "Kapcsolódva.",
"Connected. This shell is also open in another tab, and they share a size.": "Kapcsolódva. Ez a shell egy másik lapon is nyitva van, és közös a méretük.",
"Connecting…": "Kapcsolódás…",
"Contents": "Tartalom",
"Context": "Kontextus",
"Context and background tasks": "Kontextus és háttérfeladatok",
"Context used": "Felhasznált kontextus",
@@ -2062,6 +2148,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Törli ezt a csevegést?",
"Delete this scheduled task?": "Törli ezt az ütemezett feladatot?",
"Deny": "Elutasítás",
"Details": "Részletek",
"Dictate a message": "Üzenet diktálása",
"Disconnected. Close and reopen to reconnect.": "Kapcsolat bontva. Az újrakapcsolódáshoz zárja be és nyissa meg újra.",
"Done": "Kész",
@@ -2095,10 +2182,12 @@ const LEMBAS_I18N = {
"Folder name": "Mappa neve",
"Folder on %(device)s": "Mappa ezen: %(device)s",
"Folder on the computer": "Mappa a számítógépen",
"Folders": "Mappák",
"Free space": "Szabad hely",
"Hello": "Üdv",
"Hello, %(name)s": "Üdv, %(name)s",
"History": "Előzmények",
"Home": "Kezdőlap",
"How many work on it at once, the run's own agent among them, on the same model.": "Hányan dolgoznak rajta egyszerre, a futás saját ügynökét is beleértve, ugyanazon a modellen.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "A saját szavaival. A szerver szabályt és utasítást készít belőlük, és mentés előtt megmutatja a következő futásokat.",
"Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…",
@@ -2137,10 +2226,12 @@ const LEMBAS_I18N = {
"Models": "Modellek",
"Models for scheduled tasks": "Modellek az ütemezett feladatokhoz",
"More": "Több",
"More for %(name)s": "Továbbiak: %(name)s",
"Move to folder…": "Áthelyezés mappába…",
"Move to the trash": "Áthelyezés a lomtárba",
"Move “%(name)s” to the trash? It can be restored from there.": "Áthelyezi a lomtárba: „%(name)s”? Onnan visszaállítható.",
"Moved to the trash.": "A lomtárba került.",
"Name": "Név",
"Never": "Soha",
"New agent chat": "Új ügynökcsevegés",
"New chat": "Új csevegés",
@@ -2165,6 +2256,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Egy modell jegyezte fel",
"Nothing has been run in this shell yet.": "Ebben a shellben még semmi sem futott.",
"Nothing has been said in the room yet.": "A szobában még senki nem mondott semmit.",
"Nothing here yet. Not even a hobbit hole.": "Itt még nincs semmi. Még egy hobbitlyuk sem.",
"Nothing is scheduled.": "Nincs semmi ütemezve.",
"Nothing matches.": "Nincs találat.",
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
@@ -2190,6 +2282,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Vagy írjon választ",
"Outputs": "Kimenetek",
"Page": "Oldal",
"Path": "Útvonal",
"Pause": "Szüneteltetés",
"Pause %(title)s": "%(title)s szüneteltetése",
"Paused": "Szüneteltetve",
@@ -2256,6 +2349,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Bejelentkezve: %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.",
"Since the last reply": "Az utolsó válasz óta",
"Size": "Méret",
"Skills": "Készségek",
"Skipped": "Kihagyva",
"Something to remember…": "Valami, amit meg kell jegyezni…",
@@ -2319,7 +2413,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "A válasz kész.",
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
"The result is no longer there.": "Az eredmény már nincs meg.",
"The server has not answered in %(n)s seconds.": "A szerver %(n)s másodperce nem válaszolt.",
"The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s",
"The server's answer could not be read.": "A szerver válasza nem olvasható.",
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
"The terminal could not be loaded.": "A terminált nem sikerült betölteni.",
@@ -2350,6 +2446,7 @@ const LEMBAS_I18N = {
"Tools": "Eszközök",
"Tools and skills…": "Eszközök és készségek…",
"Try again": "Újrapróbálás",
"Type": "Típus",
"Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét",
"Type a folder…": "Mappa megadása…",
"Unpin chat": "Kitűzés megszüntetése",
@@ -2371,6 +2468,7 @@ const LEMBAS_I18N = {
"When": "Mikor",
"When it matters": "Ha számít",
"Where it runs": "Hol fut",
"Where you are": "Hol van",
"Wide": "Széles",
"Writing": "Írás",
"Writing down…": "Leírás…",
@@ -2406,6 +2504,7 @@ const LEMBAS_I18N = {
"failed": "sikertelen",
"findings: %(n)s": "megállapítások: %(n)s",
"finished": "kész",
"folder": "mappa",
"free": "szabad",
"idle": "tétlen",
"it serves one request at a time and is answering another": "egyszerre egy kérést szolgál ki, és éppen egy másikat válaszol meg",
@@ -2413,6 +2512,7 @@ const LEMBAS_I18N = {
"late": "késve",
"light": "világos",
"messages: %(n)s": "üzenetek: %(n)s",
"mount": "csatolás",
"not linked": "nincs összekapcsolva",
"not linked now": "most nincs összekapcsolva",
"off": "ki",
@@ -2472,6 +2572,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Un’attività senza un modello proprio gira sul primo libero di questi, in quest’ordine. Nessuno spuntato: tutti.",
"About": "Informazioni",
"Account": "Account",
"Actions": "Azioni",
"Activity": "Attività",
"Add": "Aggiungi",
"Add folder…": "Aggiungi cartella…",
@@ -2479,6 +2580,7 @@ const LEMBAS_I18N = {
"Agent": "Agente",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Le chat con agente possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro punto di questo computer.",
"Agent: %(why)s": "Agente: %(why)s",
"All files": "Tutti i file",
"Allow": "Consenti",
"Always": "Sempre",
"Always allow": "Consenti sempre",
@@ -2502,10 +2604,13 @@ const LEMBAS_I18N = {
"Background tasks": "Attività in background",
"Cancel": "Annulla",
"Carry it out": "Eseguilo",
"Changed": "Modificato",
"Chat": "Chat",
"Chat settings": "Impostazioni della chat",
"Chats": "Chat",
"Check for updates…": "Cerca aggiornamenti…",
"Choose": "Scegli",
"Choose a file to see it here.": "Scegli un file per vederlo qui.",
"Choose a folder first.": "Scegli prima una cartella.",
"Choose a folder…": "Scegli una cartella…",
"Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.",
@@ -2526,6 +2631,7 @@ const LEMBAS_I18N = {
"Connected.": "Connesso.",
"Connected. This shell is also open in another tab, and they share a size.": "Connesso. Questa shell è aperta anche in un'altra scheda, e le due condividono la dimensione.",
"Connecting…": "Connessione in corso…",
"Contents": "Contenuto",
"Context": "Contesto",
"Context and background tasks": "Contesto e attività in background",
"Context used": "Contesto usato",
@@ -2547,6 +2653,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Eliminare questa chat?",
"Delete this scheduled task?": "Eliminare questa attività pianificata?",
"Deny": "Nega",
"Details": "Dettagli",
"Dictate a message": "Detta un messaggio",
"Disconnected. Close and reopen to reconnect.": "Disconnesso. Chiudi e riapri per riconnetterti.",
"Done": "Fatto",
@@ -2580,10 +2687,12 @@ const LEMBAS_I18N = {
"Folder name": "Nome della cartella",
"Folder on %(device)s": "Cartella su %(device)s",
"Folder on the computer": "Cartella sul computer",
"Folders": "Cartelle",
"Free space": "Spazio libero",
"Hello": "Ciao",
"Hello, %(name)s": "Ciao, %(name)s",
"History": "Cronologia",
"Home": "Home",
"How many work on it at once, the run's own agent among them, on the same model.": "Quanti ci lavorano alla volta, compreso l’agente dell’esecuzione, sullo stesso modello.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con parole tue. Il server ne ricava una regola e un’istruzione e ti mostra le prossime esecuzioni prima di salvare.",
"Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…",
@@ -2622,10 +2731,12 @@ const LEMBAS_I18N = {
"Models": "Modelli",
"Models for scheduled tasks": "Modelli per le attività pianificate",
"More": "Altro",
"More for %(name)s": "Altro per %(name)s",
"Move to folder…": "Sposta in una cartella…",
"Move to the trash": "Sposta nel cestino",
"Move “%(name)s” to the trash? It can be restored from there.": "Spostare «%(name)s» nel cestino? Si può ripristinare da lì.",
"Moved to the trash.": "Spostato nel cestino.",
"Name": "Nome",
"Never": "Mai",
"New agent chat": "Nuova chat con agente",
"New chat": "Nuova chat",
@@ -2650,6 +2761,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Annotato da un modello",
"Nothing has been run in this shell yet.": "In questa shell non è ancora stato eseguito nulla.",
"Nothing has been said in the room yet.": "Nella stanza non è ancora stato detto nulla.",
"Nothing here yet. Not even a hobbit hole.": "Ancora niente qui. Nemmeno una caverna hobbit.",
"Nothing is scheduled.": "Non c’è niente di pianificato.",
"Nothing matches.": "Nessun risultato.",
"Nothing remembered yet.": "Ancora niente da ricordare.",
@@ -2675,6 +2787,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Oppure scriva una risposta",
"Outputs": "Risultati",
"Page": "Pagina",
"Path": "Percorso",
"Pause": "Metti in pausa",
"Pause %(title)s": "Metti in pausa %(title)s",
"Paused": "In pausa",
@@ -2741,6 +2854,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Accesso effettuato come %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.",
"Since the last reply": "Dall’ultima risposta",
"Size": "Dimensione",
"Skills": "Abilità",
"Skipped": "Saltata",
"Something to remember…": "Qualcosa da ricordare…",
@@ -2804,7 +2918,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La risposta è pronta.",
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
"The result is no longer there.": "Il risultato non c’è più.",
"The server has not answered in %(n)s seconds.": "Il server non ha risposto in %(n)s secondi.",
"The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s",
"The server's answer could not be read.": "Non è stato possibile leggere la risposta del server.",
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
"The server's answer was not audio.": "La risposta del server non era audio.",
"The terminal could not be loaded.": "Impossibile caricare il terminale.",
@@ -2835,6 +2951,7 @@ const LEMBAS_I18N = {
"Tools": "Strumenti",
"Tools and skills…": "Strumenti e abilità…",
"Try again": "Riprova",
"Type": "Tipo",
"Type a command or a chat's title": "Digiti un comando o il titolo di una chat",
"Type a folder…": "Digita una cartella…",
"Unpin chat": "Togli la chat dalle fissate",
@@ -2856,6 +2973,7 @@ const LEMBAS_I18N = {
"When": "Quando",
"When it matters": "Quando conta",
"Where it runs": "Dove viene eseguito",
"Where you are": "Dove sei",
"Wide": "Larga",
"Writing": "Scrittura",
"Writing down…": "Trascrizione…",
@@ -2891,6 +3009,7 @@ const LEMBAS_I18N = {
"failed": "fallito",
"findings: %(n)s": "risultati: %(n)s",
"finished": "finito",
"folder": "cartella",
"free": "libero",
"idle": "inattivo",
"it serves one request at a time and is answering another": "serve una richiesta alla volta e ne sta servendo un’altra",
@@ -2898,6 +3017,7 @@ const LEMBAS_I18N = {
"late": "in ritardo",
"light": "chiaro",
"messages: %(n)s": "messaggi: %(n)s",
"mount": "montaggio",
"not linked": "non collegato",
"not linked now": "non collegato ora",
"off": "disattivato",
@@ -2957,6 +3077,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Zadanie bez własnego modelu działa na pierwszym wolnym z tych, w tej kolejności. Żaden niezaznaczony: wszystkie.",
"About": "O aplikacji",
"Account": "Konto",
"Actions": "Działania",
"Activity": "Aktywność",
"Add": "Dodaj",
"Add folder…": "Dodaj folder…",
@@ -2964,6 +3085,7 @@ const LEMBAS_I18N = {
"Agent": "Agent",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Czaty z agentem mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze.",
"Agent: %(why)s": "Agent: %(why)s",
"All files": "Wszystkie pliki",
"Allow": "Pozwól",
"Always": "Zawsze",
"Always allow": "Zawsze zezwalaj",
@@ -2987,10 +3109,13 @@ const LEMBAS_I18N = {
"Background tasks": "Zadania w tle",
"Cancel": "Anuluj",
"Carry it out": "Wykonaj",
"Changed": "Zmieniono",
"Chat": "Czat",
"Chat settings": "Ustawienia czatu",
"Chats": "Czaty",
"Check for updates…": "Sprawdź aktualizacje…",
"Choose": "Wybierz",
"Choose a file to see it here.": "Wybierz plik, aby zobaczyć go tutaj.",
"Choose a folder first.": "Najpierw wybierz folder.",
"Choose a folder…": "Wybierz folder…",
"Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.",
@@ -3011,6 +3136,7 @@ const LEMBAS_I18N = {
"Connected.": "Połączono.",
"Connected. This shell is also open in another tab, and they share a size.": "Połączono. Ta powłoka jest otwarta też w innej karcie i dzielą one rozmiar.",
"Connecting…": "Łączenie…",
"Contents": "Zawartość",
"Context": "Kontekst",
"Context and background tasks": "Kontekst i zadania w tle",
"Context used": "Użyty kontekst",
@@ -3032,6 +3158,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Usunąć ten czat?",
"Delete this scheduled task?": "Usunąć to zaplanowane zadanie?",
"Deny": "Odrzuć",
"Details": "Szczegóły",
"Dictate a message": "Podyktuj wiadomość",
"Disconnected. Close and reopen to reconnect.": "Rozłączono. Aby połączyć ponownie, zamknij i otwórz ponownie.",
"Done": "Gotowe",
@@ -3065,10 +3192,12 @@ const LEMBAS_I18N = {
"Folder name": "Nazwa folderu",
"Folder on %(device)s": "Folder na %(device)s",
"Folder on the computer": "Folder na komputerze",
"Folders": "Foldery",
"Free space": "Wolne miejsce",
"Hello": "Cześć",
"Hello, %(name)s": "Cześć, %(name)s",
"History": "Historia",
"Home": "Start",
"How many work on it at once, the run's own agent among them, on the same model.": "Ilu pracuje nad nim naraz, łącznie z własnym agentem uruchomienia, na tym samym modelu.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Własnymi słowami. Serwer zamieni je w regułę i polecenie i pokaże następne uruchomienia, zanim cokolwiek zostanie zapisane.",
"Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…",
@@ -3107,10 +3236,12 @@ const LEMBAS_I18N = {
"Models": "Modele",
"Models for scheduled tasks": "Modele dla zaplanowanych zadań",
"More": "Więcej",
"More for %(name)s": "Więcej dla %(name)s",
"Move to folder…": "Przenieś do folderu…",
"Move to the trash": "Przenieś do kosza",
"Move “%(name)s” to the trash? It can be restored from there.": "Przenieść „%(name)s” do kosza? Można go stamtąd przywrócić.",
"Moved to the trash.": "Przeniesiono do kosza.",
"Name": "Nazwa",
"Never": "Nigdy",
"New agent chat": "Nowy czat z agentem",
"New chat": "Nowy czat",
@@ -3135,6 +3266,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Zanotowane przez model",
"Nothing has been run in this shell yet.": "W tej powłoce nic jeszcze nie uruchomiono.",
"Nothing has been said in the room yet.": "W pokoju nikt jeszcze nic nie powiedział.",
"Nothing here yet. Not even a hobbit hole.": "Jeszcze nic tu nie ma. Nawet hobbiciej norki.",
"Nothing is scheduled.": "Nic nie jest zaplanowane.",
"Nothing matches.": "Nic nie pasuje.",
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
@@ -3160,6 +3292,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Albo wpisz odpowiedź",
"Outputs": "Wyniki",
"Page": "Strona",
"Path": "Ścieżka",
"Pause": "Wstrzymaj",
"Pause %(title)s": "Wstrzymaj %(title)s",
"Paused": "Wstrzymany",
@@ -3226,6 +3359,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Zalogowano jako %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.",
"Since the last reply": "Od ostatniej odpowiedzi",
"Size": "Rozmiar",
"Skills": "Umiejętności",
"Skipped": "Pominięty",
"Something to remember…": "Coś do zapamiętania…",
@@ -3289,7 +3423,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpowiedź jest gotowa.",
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
"The result is no longer there.": "Wyniku już nie ma.",
"The server has not answered in %(n)s seconds.": "Serwer nie odpowiedział od %(n)s sekund.",
"The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s",
"The server's answer could not be read.": "Nie udało się odczytać odpowiedzi serwera.",
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
"The terminal could not be loaded.": "Nie udało się wczytać terminala.",
@@ -3320,6 +3456,7 @@ const LEMBAS_I18N = {
"Tools": "Narzędzia",
"Tools and skills…": "Narzędzia i umiejętności…",
"Try again": "Spróbuj ponownie",
"Type": "Typ",
"Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu",
"Type a folder…": "Wpisz folder…",
"Unpin chat": "Odepnij czat",
@@ -3341,6 +3478,7 @@ const LEMBAS_I18N = {
"When": "Kiedy",
"When it matters": "Gdy to ważne",
"Where it runs": "Gdzie działa",
"Where you are": "Gdzie jesteś",
"Wide": "Szeroki",
"Writing": "Pisanie",
"Writing down…": "Zapisywanie…",
@@ -3376,6 +3514,7 @@ const LEMBAS_I18N = {
"failed": "nie powiodło się",
"findings: %(n)s": "ustalenia: %(n)s",
"finished": "zakończony",
"folder": "folder",
"free": "wolny",
"idle": "bezczynny",
"it serves one request at a time and is answering another": "obsługuje jedno żądanie naraz i właśnie obsługuje inne",
@@ -3383,6 +3522,7 @@ const LEMBAS_I18N = {
"late": "z opóźnieniem",
"light": "jasny",
"messages: %(n)s": "wiadomości: %(n)s",
"mount": "montowanie",
"not linked": "niepołączony",
"not linked now": "teraz niepołączony",
"off": "wył.",
@@ -3442,6 +3582,7 @@ const LEMBAS_I18N = {
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastného modelu beží na prvom voľnom z týchto, v tomto poradí. Žiadny nezaškrtnutý: všetky.",
"About": "O aplikácii",
"Account": "Účet",
"Actions": "Akcie",
"Activity": "Aktivita",
"Add": "Pridať",
"Add folder…": "Pridať priečinok…",
@@ -3449,6 +3590,7 @@ const LEMBAS_I18N = {
"Agent": "Agent",
"Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentské konverzácie môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači.",
"Agent: %(why)s": "Agent: %(why)s",
"All files": "Všetky súbory",
"Allow": "Povoliť",
"Always": "Vždy",
"Always allow": "Vždy povoliť",
@@ -3472,10 +3614,13 @@ const LEMBAS_I18N = {
"Background tasks": "Úlohy na pozadí",
"Cancel": "Zrušiť",
"Carry it out": "Vykonať to",
"Changed": "Zmenené",
"Chat": "Konverzácia",
"Chat settings": "Nastavenia konverzácie",
"Chats": "Konverzácie",
"Check for updates…": "Skontrolovať aktualizácie…",
"Choose": "Vybrať",
"Choose a file to see it here.": "Vyberte súbor a uvidíte ho tu.",
"Choose a folder first.": "Najprv vyberte priečinok.",
"Choose a folder…": "Vybrať priečinok…",
"Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.",
@@ -3496,6 +3641,7 @@ const LEMBAS_I18N = {
"Connected.": "Pripojené.",
"Connected. This shell is also open in another tab, and they share a size.": "Pripojené. Tento shell je otvorený aj v inej karte a zdieľajú veľkosť.",
"Connecting…": "Pripája sa…",
"Contents": "Obsah",
"Context": "Kontext",
"Context and background tasks": "Kontext a úlohy na pozadí",
"Context used": "Využitý kontext",
@@ -3517,6 +3663,7 @@ const LEMBAS_I18N = {
"Delete this chat?": "Zmazať túto konverzáciu?",
"Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?",
"Deny": "Odmietnuť",
"Details": "Podrobnosti",
"Dictate a message": "Nadiktovať správu",
"Disconnected. Close and reopen to reconnect.": "Odpojené. Zavrite a znova otvorte, aby sa pripojilo.",
"Done": "Hotovo",
@@ -3550,10 +3697,12 @@ const LEMBAS_I18N = {
"Folder name": "Názov zložky",
"Folder on %(device)s": "Priečinok na zariadení %(device)s",
"Folder on the computer": "Priečinok na počítači",
"Folders": "Priečinky",
"Free space": "Voľné miesto",
"Hello": "Dobrý deň",
"Hello, %(name)s": "Dobrý deň, %(name)s",
"History": "História",
"Home": "Domov",
"How many work on it at once, the run's own agent among them, on the same model.": "Koľko na nej pracuje naraz, vrátane vlastného agenta behu, na rovnakom modeli.",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastnými slovami. Server z nich vytvorí pravidlo a pokyn a pred uložením vám ukáže ďalšie spustenia.",
"Install LLeMbas %(version)s…": "Nainštalovať LLeMbas %(version)s…",
@@ -3592,10 +3741,12 @@ const LEMBAS_I18N = {
"Models": "Modely",
"Models for scheduled tasks": "Modely pre naplánované úlohy",
"More": "Viac",
"More for %(name)s": "Ďalšie pre %(name)s",
"Move to folder…": "Presunúť do priečinka…",
"Move to the trash": "Presunúť do koša",
"Move “%(name)s” to the trash? It can be restored from there.": "Presunúť „%(name)s“ do koša? Odtiaľ sa dá obnoviť.",
"Moved to the trash.": "Presunuté do koša.",
"Name": "Názov",
"Never": "Nikdy",
"New agent chat": "Nová agentská konverzácia",
"New chat": "Nová konverzácia",
@@ -3620,6 +3771,7 @@ const LEMBAS_I18N = {
"Noted by a model": "Zaznamenal model",
"Nothing has been run in this shell yet.": "V tomto shelli sa zatiaľ nič nespustilo.",
"Nothing has been said in the room yet.": "V miestnosti zatiaľ nikto nič nepovedal.",
"Nothing here yet. Not even a hobbit hole.": "Zatiaľ tu nič nie je. Ani hobitia nora.",
"Nothing is scheduled.": "Nič nie je naplánované.",
"Nothing matches.": "Nič nezodpovedá.",
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
@@ -3645,6 +3797,7 @@ const LEMBAS_I18N = {
"Or type an answer": "Alebo napíšte odpoveď",
"Outputs": "Výstupy",
"Page": "Stránka",
"Path": "Cesta",
"Pause": "Pozastaviť",
"Pause %(title)s": "Pozastaviť %(title)s",
"Paused": "Pozastavené",
@@ -3711,6 +3864,7 @@ const LEMBAS_I18N = {
"Signed in as %(who)s.": "Prihlásený ako %(who)s.",
"Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.",
"Since the last reply": "Od poslednej odpovede",
"Size": "Veľkosť",
"Skills": "Schopnosti",
"Skipped": "Vynechané",
"Something to remember…": "Niečo na zapamätanie…",
@@ -3774,7 +3928,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpoveď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
"The result is no longer there.": "Výsledok už tu nie je.",
"The server has not answered in %(n)s seconds.": "Server neodpovedal do %(n)s sekúnd.",
"The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s",
"The server's answer could not be read.": "Odpoveď servera sa nedala prečítať.",
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
"The terminal could not be loaded.": "Terminál sa nepodarilo načítať.",
@@ -3805,6 +3961,7 @@ const LEMBAS_I18N = {
"Tools": "Nástroje",
"Tools and skills…": "Nástroje a schopnosti…",
"Try again": "Skúsiť znova",
"Type": "Typ",
"Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie",
"Type a folder…": "Zadať priečinok…",
"Unpin chat": "Odopnúť konverzáciu",
@@ -3826,6 +3983,7 @@ const LEMBAS_I18N = {
"When": "Kedy",
"When it matters": "Keď na tom záleží",
"Where it runs": "Kde sa spúšťa",
"Where you are": "Kde ste",
"Wide": "Široký",
"Writing": "Píše",
"Writing down…": "Zapisujem…",
@@ -3861,6 +4019,7 @@ const LEMBAS_I18N = {
"failed": "zlyhalo",
"findings: %(n)s": "zistenia: %(n)s",
"finished": "dokončený",
"folder": "priečinok",
"free": "voľný",
"idle": "nečinný",
"it serves one request at a time and is answering another": "vybavuje jednu požiadavku naraz a práve vybavuje inú",
@@ -3868,6 +4027,7 @@ const LEMBAS_I18N = {
"late": "s oneskorením",
"light": "svetlá",
"messages: %(n)s": "správy: %(n)s",
"mount": "pripojenie",
"not linked": "neprepojené",
"not linked now": "teraz nie je prepojený",
"off": "vypnuté",
+26 -10
View File
@@ -64,9 +64,11 @@
<div class="scrim" id="scrim" aria-hidden="true"></div>
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
<header class="head">
<header class="head chathead" id="chat-head" data-fold="false" data-unfold="false">
<h1 class="head__title" id="chat-title"></h1>
<div class="head__place" id="chat-place"></div>
<div class="chathead__settings" id="chat-settings" role="toolbar" aria-label="Chat settings" data-i18n-aria-label></div>
<button type="button" class="tool chathead__fold" id="chat-fold" aria-expanded="false" aria-controls="chat-settings" aria-label="Chat settings" title="Chat settings" data-i18n-aria-label data-i18n-title></button>
<div class="head__tools" id="chat-tools"></div>
</header>
<div class="thread" id="thread">
@@ -100,22 +102,36 @@
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
</header>
<div class="page__body files">
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
<div class="files__head">
<div class="files__where" id="local-where"></div>
<section class="fx" id="pane-local" aria-label="This computer" data-i18n-aria-label>
<div class="fx__bar">
<div class="fx__where" id="local-where"></div>
<nav class="crumbs" id="local-crumbs" aria-label="Where you are" data-i18n-aria-label></nav>
<div class="fx__tools">
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="files__tree" id="local-tree"></div>
</div>
<div class="fx__panes">
<aside class="fx__tree" id="local-tree" aria-label="Folders" data-i18n-aria-label></aside>
<div class="fx__list" id="local-list"></div>
<aside class="fx__details" id="local-details" aria-label="Details" data-i18n-aria-label></aside>
</div>
</section>
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="files__head">
<div class="files__where muted" id="server-where"></div>
<section class="fx" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="fx__bar">
<div class="fx__where"></div>
<nav class="crumbs" id="server-crumbs" aria-label="Where you are" data-i18n-aria-label></nav>
<div class="fx__tools">
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<p class="status" id="server-status" hidden></p>
<div class="files__tree" id="server-tree"></div>
</div>
<p class="status fx__status" id="server-status" hidden></p>
<div class="fx__panes">
<aside class="fx__tree" id="server-tree" aria-label="Folders" data-i18n-aria-label></aside>
<div class="fx__list" id="server-list"></div>
<aside class="fx__details" id="server-details" aria-label="Details" data-i18n-aria-label></aside>
</div>
</section>
</div>
</section>
+70 -17
View File
@@ -731,8 +731,49 @@ function renderHead(chat) {
}
// An archived chat says so where it is read, with the way back into the sidebar.
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
if (!chat.archived) tools.append(h("button", { type: "button", class: "tool", id: "scope-open", "aria-label": t("Tools and skills…"), title: t("Tools and skills…"), "aria-haspopup": "dialog", onclick: (e) => scopePopover(e.currentTarget, open?.chat || chat) }, icon(ICONS.tools)))
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
fitHead()
}
// --- The chat's settings, top right ---------------------------------------------------------------
//
// The model, how hard it thinks, the subagents and an agent chat's mode sit in the chat's header,
// beside the Terminal, the activity panel, the tools and skills and the chat's menu; the composer
// keeps only what belongs to the message. Where the header has no room for them all on its line,
// they fold behind one button (Chat settings), which opens them as a row of their own under the
// header -- wrapped, every one reachable, never a line cut off at its end. Whether they fit is
// measured, not guessed: a long model name or one more chip folds them at any width.
function fitHead() {
const head = $("chat-head")
const box = $("chat-settings")
if (!head || !box) return
const any = box.childElementCount > 0
head.dataset.fold = "false"
// Folded when the settings are cut at their end, or when they leave where the chat works (its
// folder) less than its own width or 10rem, whichever is less.
const place = $("chat-place")
const room = Math.min(place.scrollWidth, 10 * parseFloat(getComputedStyle(document.documentElement).fontSize))
const squeezed = place.childElementCount > 0 && place.clientWidth + 1 < room
const fold = any && (box.scrollWidth > box.clientWidth + 1 || squeezed)
head.dataset.fold = String(fold)
if (!fold) head.dataset.unfold = "false"
$("chat-fold").hidden = !fold
$("chat-fold").setAttribute("aria-expanded", String(head.dataset.unfold === "true"))
}
function initHead() {
$("chat-fold").replaceChildren(icon(ICONS.sliders))
$("chat-fold").hidden = true
$("chat-fold").addEventListener("click", () => {
const head = $("chat-head")
head.dataset.unfold = String(head.dataset.unfold !== "true")
$("chat-fold").setAttribute("aria-expanded", head.dataset.unfold)
if (head.dataset.unfold === "true") $("chat-settings").querySelector("button")?.focus()
})
new ResizeObserver(() => fitHead()).observe($("chat-head"))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
@@ -966,7 +1007,8 @@ let chipsDrawn = 0 // which drawing is the latest: an older one's late answers a
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
async function renderChips() {
const box = $("composer-chips")
const box = $("chat-settings")
const message = $("composer-chips")
const drawing = ++chipsDrawn
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
if (draft) {
@@ -974,18 +1016,20 @@ async function renderChips() {
const models = ctx.models()
const model = models.find((m) => m.id === draft.model)
const parts = []
const where = [] // an agent chat's computer and folder: where it works, beside the title
if (draft.kind === "agent") {
const others = otherDevices()
if (others.length) parts.push(deviceChip(others))
if (others.length) where.push(deviceChip(others))
if (draftHere()) {
const roots = s?.remote?.enabled ? s.remote.roots : []
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
where.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
} else {
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
where.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
}
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
$("chat-place").replaceChildren(...where)
const shell = terminalTarget()
aimTerminal(shell)
parts.push(modelChip(models, draft.model, (id) => {
@@ -994,8 +1038,11 @@ async function renderChips() {
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (draft.kind === "chat" && picturesOffered()) message.replaceChildren(pictureChip())
else {
wantPicture = false
message.replaceChildren()
}
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : ""
@@ -1007,20 +1054,20 @@ async function renderChips() {
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
})
}
// Last, so the chat's own settings come first where the line scrolls.
if (shell) {
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
b.dataset.terminalToggle = ""
b.setAttribute("aria-pressed", String(terminalOpen()))
b.setAttribute("aria-controls", "terminal")
parts.push(b)
}
// The Terminal, as an open chat has it: in the header's tools.
$("chat-tools").replaceChildren(...(shell ? [terminalButton()] : []))
box.replaceChildren(...parts.filter(Boolean))
fitHead()
return
}
const chat = open?.chat
aimTerminal(terminalTarget())
if (!chat?.kind) return box.replaceChildren()
if (!chat?.kind) {
box.replaceChildren()
message.replaceChildren()
fitHead()
return
}
const save = async (body) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
@@ -1041,12 +1088,17 @@ async function renderChips() {
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (chat.kind === "chat" && picturesOffered() && !chat.archived) message.replaceChildren(pictureChip())
else {
wantPicture = false
message.replaceChildren()
}
box.replaceChildren(...parts.filter(Boolean))
fitHead()
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
fitHead()
}
/**
@@ -1505,5 +1557,6 @@ export function initChat(context) {
}
})
onAgentChange(renderNotice)
initHead()
showComposer(false)
}
+5
View File
@@ -104,6 +104,11 @@ export const ICONS = {
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
tools: "M10.6 1.5a3.9 3.9 0 00-3.7 5.1L1.8 11.7a1.6 1.6 0 002.3 2.3l5.1-5.1a3.9 3.9 0 005.1-3.7l-.1-.9-2.1 2.1-1.9-.4-.4-1.9 2.1-2.1zM3 12.4l.6.6 5.3-5.3-.6-.6z",
sliders: ["M2 4h7v1.5H2zM12 4h2v1.5h-2zM2 10.5h2V12H2zM7 10.5h7V12H7z", "M9.5 2.75h2v4h-2zM4.5 9.25h2v4h-2z"],
fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"],
fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"],
drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-1.5z"],
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
}
+423 -86
View File
@@ -1,99 +1,376 @@
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
// The Files page: two explorers, kept apart as two tabs -- this computer's (the agent's folders,
// read through a command that reaches only them, src-tauri/src/files.rs) and the server's (the
// person's files, as the chats see them, read through /api/v2). Each is the web explorer's shape,
// using the window's width: where you are (crumbs) and what can be done there, the folder tree,
// the list (name, type, size, changed), and the chosen file's details and preview. A file opens in
// the viewer either way.
//
// Nothing here is ever an empty box: a directory being read says Loading…, past SLOW_MS it says
// the server has not answered (with Try again, the late answer still drawn), and a refusal, an
// answer that is not a listing or a failure to draw it is said in its place with Try again.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, post, del, why } from "./bridge.js"
import { menu, ask } from "./popup.js"
import { openViewer } from "./viewer.js"
import { openViewer, showKind, withheldWords } from "./viewer.js"
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
// A path the person cannot read is the server's 404, in its words.
// A path the person cannot read is the server's 404, in its words. `/` answers with where the
// person's explorer opens (`fs.start`): `/` itself for an organisation's owner or administrator,
// the person's home for anyone else -- its `path` says which.
export const SERVER_FILES = {
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
}
/** How long a directory may take before the pane says it is still waiting, and offers to ask again. */
export const SLOW_MS = 8000
/** How long an answer is shared between the tree and the list after it came. */
const SHARED_MS = 1500
/** The most of a file the details pane previews: the viewer has the rest. */
const PREVIEW_CHARS = 6000
/** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
/**
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
*/
function tree(box, { list, open, rootPath, actions }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const levels = new Map() // path -> { ul, depth }, each directory drawn now
const opened = new Set() // the directories open now
const level = async (parent, path, depth) => {
levels.set(path, { ul: parent, depth })
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
return
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i
/** An entry's icon, by what the viewer would make of it (the web's `file_icon`). */
function iconOf(e) {
if (e.kind === "mount") return ICONS.drive
if (isDir(e)) return ICONS.folder
if ((e.mime || "").startsWith("image/") || IMAGE.test(e.name)) return ICONS.picture
const { kind } = showKind(e.path || e.name)
return kind === "code" || kind === "html" ? ICONS.fileCode : kind === "markdown" || kind === "text" ? ICONS.fileText : ICONS.file
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
/** The Type column: a folder, a mount, or the extension (the web's `type_label`). */
function typeOf(e) {
if (e.kind === "mount") return t("mount")
if (isDir(e)) return t("folder")
const i = e.name.lastIndexOf(".")
return i > 0 ? e.name.slice(i + 1).toLowerCase() : ""
}
/** A stamp as a date the person reads, and the whole of it for a title. */
function dateOf(stamp) {
if (!stamp) return { short: "", full: "" }
const d = new Date(stamp)
if (Number.isNaN(d.getTime())) return { short: "", full: "" }
const lang = document.documentElement.lang || undefined
return {
short: new Intl.DateTimeFormat(lang, { day: "numeric", month: "short", year: "numeric" }).format(d),
full: new Intl.DateTimeFormat(lang, { dateStyle: "long", timeStyle: "short" }).format(d),
}
}
/** Is the details pane on the screen (wide windows): a file is chosen there, else opened. */
const detailsShown = (el) => !!el && getComputedStyle(el).display !== "none"
/**
* Read something into `holder` and draw it, never leaving it empty: Loading… at once, the slow
* note and Try again past SLOW_MS, a refusal or a failure to draw in its place. `tag` is the
* element the notes are (an `li` inside a list). Only the latest reading of a holder is drawn:
* one that comes after the holder was read again, or left the page, is dropped. Returns a promise
* that settles once the reading is drawn or said.
*/
function reading(holder, { tag = "div", depth = 0, load, draw }) {
const mine = (readings.get(holder) || 0) + 1
readings.set(holder, mine)
const stale = () => readings.get(holder) !== mine || !holder.isConnected
const again = () => reading(holder, { tag, depth, load, draw })
const retry = () => h("button", { type: "button", class: "btn btn--sm fnote__retry", text: t("Try again"), onclick: again })
const note = (cls, role, text) => {
const n = h(tag, { class: `fnote ${cls}`, role }, h("span", { text }))
n.style.setProperty("--depth", String(depth))
return n
}
clear(holder)
const loading = note("is-loading", "status", t("Loading…"))
loading.setAttribute("aria-live", "polite")
holder.append(loading)
const slow = setTimeout(() => {
if (!stale()) loading.append(h("span", { text: t("The server has not answered in %(n)s seconds.", { n: Math.round(SLOW_MS / 1000) }) }), retry())
}, SLOW_MS)
const said = (text) => {
const n = note("is-error", "alert", text)
n.append(retry())
clear(holder).append(n)
}
return Promise.resolve()
.then(load)
.then(
(answer) => {
clearTimeout(slow)
if (stale()) return
try {
clear(holder)
draw(answer)
} catch (err) {
said(err instanceof Error ? err.message : why(err))
}
},
(err) => {
clearTimeout(slow)
if (!stale()) said(why(err))
},
)
}
const readings = new WeakMap() // holder -> the reading it shows now (a number)
/**
* One explorer, inside the pane's elements (`crumbs`, `tree`, `list`, `details`). `source`:
* key "local" | "server" (the rows' data, the tests' handle)
* list(path) -> { path, entries } (`path` as answered: the server's `/` says where it is)
* top() the directory the explorer opens at (the server's: as `/` answered)
* ask(path) the path to ask for a directory (the server's top is asked as `/`)
* crumbs(path) -> [[name, path], ...] from the top down
* rootName() the tree's first line
* open(entry) in the viewer
* preview(entry) -> { text, withheld, size }
* actions(entry) (optional) -> menu items
*/
function explorer(el, source) {
let current = null // the directory listed now (as answered)
let chosen = null // the file whose details show
// One request per directory for the tree and the list together: an answer is shared while it
// is on its way and for a moment after (the tree drawn again once the top is known); a refusal
// is never kept, so Try again asks afresh. Read again and a change forget them all.
const pending = new Map() // asked path -> promise
const fetchDir = (path) => {
const asked = source.ask(path)
if (!pending.has(asked)) {
const p = Promise.resolve().then(() => source.list(asked))
pending.set(asked, p)
const forget = () => pending.get(asked) === p && pending.delete(asked)
p.then(() => setTimeout(forget, SHARED_MS), forget)
}
return pending.get(asked)
}
// --- The folder tree: folders only, opened lazily; a name lists its folder.
const treeBox = el.tree
const opened = new Set()
const branches = new Map() // path -> { ul, depth }
const drawTree = () => {
clear(treeBox)
const rootRow = h(
"button",
{ type: "button", class: "ftree__row ftree__root", dataset: { path: source.top() }, onclick: () => go(source.top()) },
icon(ICONS.folder),
h("span", { class: "ftree__name", text: source.rootName() }),
)
const ul = h("ul", { class: "ftree", role: "tree", "aria-label": t("Folders") })
treeBox.append(rootRow, ul)
branch(ul, source.top(), 0)
mark()
}
const branch = (ul, path, depth) => {
branches.set(path, { ul, depth })
return reading(ul, {
tag: "li",
depth,
load: () => fetchDir(path),
draw: (answer) => {
const dirs = (answer.entries || []).filter(isDir)
for (const e of dirs) ul.append(twig(e, depth))
mark()
},
})
}
const twig = (e, depth) => {
const chevron = h("button", { type: "button", class: "ftree__chevron", "aria-label": e.name, "aria-expanded": "false", tabindex: "-1" }, icon(ICONS.chevron))
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
icon(isDir(e) ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
{ type: "button", class: "ftree__row", title: e.path, dataset: { path: e.path } },
icon(iconOf(e)),
h("span", { class: "ftree__name", text: e.name }),
)
row.style.setProperty("--depth", String(depth))
const items = actions?.(e) || []
const li = h(
"li",
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
row,
items.length
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
)
if (isDir(e)) {
const line = h("div", { class: "ftree__line" }, chevron, row)
line.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem", dataset: { path: e.path } }, line)
let kids = null
const expand = async (opening) => {
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
const expand = (opening) => {
chevron.setAttribute("aria-expanded", String(opening))
li.setAttribute("aria-expanded", String(opening))
kids?.remove()
kids = null
if (!opening) {
opened.delete(e.path)
branches.delete(e.path)
return
}
opened.add(e.path)
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
branch(kids, e.path, depth + 1)
}
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
chevron.addEventListener("click", () => expand(chevron.getAttribute("aria-expanded") !== "true"))
row.addEventListener("click", () => {
go(e.path)
if (chevron.getAttribute("aria-expanded") !== "true") expand(true)
})
if (opened.has(e.path)) expand(true)
} else row.addEventListener("click", () => open(e))
parent.append(li)
return li
}
/** The tree's line for the folder listed now. */
const mark = () => {
for (const r of treeBox.querySelectorAll(".ftree__row")) {
const here = r.dataset.path === current || (r.classList.contains("ftree__root") && current === source.top())
r.classList.toggle("is-current", here)
if (here) r.setAttribute("aria-current", "true")
else r.removeAttribute("aria-current")
}
}
const done = level(ul, rootPath, 0)
const reload = (path) => {
const at = levels.get(path) || levels.get(rootPath)
if (!at) return Promise.resolve()
clear(at.ul)
return level(at.ul, levels.has(path) ? path : rootPath, at.depth)
// --- Where you are.
const drawCrumbs = () => {
const parts = source.crumbs(current ?? source.top())
clear(el.crumbs).append(
...parts.flatMap(([name, at], i) => {
const last = i === parts.length - 1
const part = last
? h("span", { class: "crumbs__here", "aria-current": "page", title: at, text: name })
: h("button", { type: "button", class: "crumbs__link", title: at, text: name, onclick: () => go(at) })
return i ? [h("span", { class: "crumbs__sep", "aria-hidden": "true", text: "/" }), part] : [part]
}),
)
el.crumbs.scrollLeft = el.crumbs.scrollWidth
}
return { done, reload }
// --- The list.
const go = (path) => {
current = path
chosen = null
drawDetails()
drawCrumbs()
mark()
return reading(el.list, {
load: () => fetchDir(path),
draw: (answer) => {
if (!answer || !Array.isArray(answer.entries)) throw { detail: t("The server's answer could not be read.") }
if (answer.path !== undefined && answer.path !== path && source.ask(path) === source.ask(answer.path)) current = answer.path
drawCrumbs()
mark()
drawRows(answer.entries)
},
})
}
const drawRows = (entries) => {
if (!entries.length) {
el.list.append(h("div", { class: "fx__empty" }, icon(ICONS.folder), h("p", { text: t("Nothing here yet. Not even a hobbit hole.") })))
return
}
const head = h(
"div",
{ class: "frow frow--head", role: "row" },
h("span", { role: "columnheader", text: t("Name") }),
h("span", { role: "columnheader", class: "frow__type", text: t("Type") }),
h("span", { role: "columnheader", class: "frow__size", text: t("Size") }),
h("span", { role: "columnheader", class: "frow__when", text: t("Changed") }),
h("span", { role: "columnheader" }, h("span", { class: "visually-hidden", text: t("Actions") })),
)
el.list.append(h("div", { class: "frows", role: "table", "aria-label": t("Contents") }, head, entries.map(row)))
}
const row = (e) => {
const date = dateOf(e.updated_at)
const items = source.actions?.(e) || []
const openRow = h(
"button",
{ type: "button", class: "frow__open", title: e.path },
h("span", { class: `frow__icon frow__icon--${e.kind === "mount" ? "mount" : isDir(e) ? "dir" : showKind(e.name).kind}` }, icon(iconOf(e))),
h("span", { class: "frow__label", text: e.name }),
)
const r = h(
"div",
{ class: "frow", role: "row", dataset: { path: e.path, kind: e.kind } },
h("span", { role: "cell", class: "frow__name" }, openRow, e.kind === "mount" ? h("span", { class: "badge", text: t("mount") }) : null),
h("span", { role: "cell", class: "frow__type", text: typeOf(e) }),
h("span", { role: "cell", class: "frow__size", text: isDir(e) ? "" : bytes(e.size) }),
h("span", { role: "cell", class: "frow__when", title: date.full, text: date.short }),
h(
"span",
{ role: "cell", class: "frow__actions" },
items.length
? h("button", { type: "button", class: "tool frow__more", "aria-label": t("More for %(name)s", { name: e.name }), title: t("More for %(name)s", { name: e.name }), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
),
)
openRow.addEventListener("click", () => {
if (isDir(e)) go(e.path)
else if (detailsShown(el.details)) choose(e)
else source.open(e)
})
openRow.addEventListener("dblclick", () => {
if (!isDir(e)) source.open(e)
})
return r
}
// --- The chosen file's details and preview.
const choose = (e) => {
chosen = e
for (const r of el.list.querySelectorAll(".frow[data-path]")) r.classList.toggle("is-chosen", r.dataset.path === e.path)
drawDetails()
}
const drawDetails = () => {
const box = el.details
if (!box) return
for (const r of el.list.querySelectorAll(".frow.is-chosen")) if (!chosen || r.dataset.path !== chosen.path) r.classList.remove("is-chosen")
clear(box)
if (!chosen) {
box.append(h("div", { class: "fdetails__none" }, icon(ICONS.file), h("p", { text: t("Choose a file to see it here.") })))
return
}
const e = chosen
const date = dateOf(e.updated_at)
const fact = (label, value, title) => (value ? [h("dt", { text: label }), h("dd", { title: title || value, text: value })] : [])
const preview = h("div", { class: "fdetails__preview", "aria-label": t("Preview") })
box.append(
h("div", { class: "fdetails__id" }, h("span", { class: `frow__icon frow__icon--${showKind(e.name).kind}` }, icon(iconOf(e))), h("h2", { class: "fdetails__name", title: e.name, text: e.name })),
h("dl", { class: "fdetails__facts" }, fact(t("Type"), typeOf(e)), fact(t("Size"), bytes(e.size)), fact(t("Changed"), date.short, date.full), fact(t("Path"), e.path)),
h("div", { class: "fdetails__do" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Open in the viewer"), onclick: () => source.open(e) })),
preview,
)
reading(preview, {
load: () => source.preview(e),
draw: (f) => {
if (chosen !== e) return
if (f.withheld) preview.append(h("p", { class: "fdetails__note", text: withheldWords(f.withheld, f.size ?? e.size) }))
else {
const text = String(f.text ?? "")
preview.append(h("pre", { class: "fdetails__text", text: text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}\n…` : text }))
}
},
})
}
/** Read again: the list, the tree as it is open, the file chosen. */
const refresh = () => {
pending.clear()
const at = current ?? source.top()
drawTree()
return go(at)
}
/** A directory changed (an upload, a new folder, a rename, a delete): draw what is there now. */
const changed = (dir) => {
pending.clear()
const b = branches.get(dir)
if (b) branch(b.ul, dir, b.depth)
if (dir === current || source.ask(dir) === source.ask(current ?? source.top())) return go(current ?? source.top())
return Promise.resolve()
}
return { go, refresh, changed, drawTree, current: () => current ?? source.top() }
}
// --- This computer ------------------------------------------------------------------------------
let local = { root: "", roots: [], fixed: false }
let localPane = null
/**
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
@@ -105,32 +382,63 @@ export function showLocal({ roots, root, fixed }) {
if (changed) renderLocal()
}
const baseName = (p) => String(p).replace(/\/+$/, "").split("/").pop() || p
function renderLocal() {
const head = clear($("local-where"))
const box = $("local-tree")
const els = { crumbs: $("local-crumbs"), tree: $("local-tree"), list: $("local-list"), details: $("local-details") }
$("pane-local").dataset.empty = String(!local.roots.length)
if (!local.roots.length) {
clear(box).append(
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
clear(els.crumbs)
clear(els.tree)
clear(els.details)
clear(els.list).append(
h(
"div",
{ class: "fx__empty" },
icon(ICONS.folder),
h("p", { text: t("No project folder yet: the agent can open nothing on this computer.") }),
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
),
)
localPane = null
return
}
if (local.fixed) {
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
} else {
const pick = h(
if (!local.fixed && local.roots.length > 1) {
head.append(
h(
"select",
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
{ class: "pane__pick", "aria-label": t("Folder"), title: local.root, onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
),
)
head.append(pick)
}
const root = local.root
return tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
const where = t("This computer")
localPane = explorer(els, {
key: "local",
top: () => "",
ask: (path) => path || "",
rootName: () => baseName(root),
crumbs: (path) => {
const out = [[baseName(root), ""]]
let at = ""
for (const part of String(path).split("/").filter(Boolean)) {
at = at ? `${at}/${part}` : part
out.push([part, at])
}
return out
},
list: async (path) => {
const r = await invoke("local_list", { root, path })
if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") }
return {
path: r.path ?? path,
entries: r.entries.map((e) => ({ ...e, updated_at: e.modified ? new Date(e.modified * 1000).toISOString() : null })),
}
},
preview: (e) => invoke("local_read", { root, path: e.path }),
open: async (e) => {
try {
const f = await invoke("local_read", { root, path: e.path })
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
@@ -139,6 +447,8 @@ function renderLocal() {
}
},
})
localPane.drawTree()
localPane.go("")
}
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
@@ -159,11 +469,11 @@ export async function readServer(path) {
// POST /files/rename { path, name }
// DELETE /files?path= to the trash, as on the web
let server = null // the tree drawn now: { reload(path) }
let serverRoot = "/" // the directory the server answered `/` with: the person's home
let server = null // the explorer drawn now
let serverRoot = "/" // the directory the server answered `/` with: `/` or the person's home
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir)
const reloadServer = (dir) => server?.changed(dir)
function serverSaid(text, error = false) {
const line = $("server-status")
@@ -226,23 +536,48 @@ async function deleteEntry(e) {
function serverActions(e) {
const items = []
if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) })
if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
else items.push({ label: t("Open in the viewer"), run: () => openServerFile(e.path, e.name) })
if (e.kind !== "mount") items.push(null, { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
return items
}
export function renderServer() {
const box = $("server-tree")
serverSaid("")
server = tree(box, {
rootPath: "/",
const els = { crumbs: $("server-crumbs"), tree: $("server-tree"), list: $("server-list"), details: $("server-details") }
let first = true
server = explorer(els, {
key: "server",
top: () => serverRoot,
// The top is asked as `/`, which is what the server opens a person at.
ask: (path) => (!path || path === serverRoot ? "/" : path),
rootName: () => (serverRoot === "/" ? t("All files") : t("Home")),
crumbs: (path) => {
const out = [[serverRoot === "/" ? t("All files") : t("Home"), serverRoot]]
const rest = path.startsWith(serverRoot) ? path.slice(serverRoot.length) : path
let at = serverRoot
for (const part of rest.split("/").filter(Boolean)) {
at = `${at.replace(/\/$/, "")}/${part}`
out.push([part, at])
}
return out
},
list: async (path) => {
const r = await get(SERVER_FILES.list(path))
if (path === "/" && r.path) serverRoot = r.path
return r.entries || []
if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") }
if (path === "/" && r.path && r.path !== serverRoot) {
serverRoot = r.path
// The top is known now: the tree and the crumbs start there.
if (first) queueMicrotask(() => server?.drawTree())
}
first = false
return r
},
preview: (e) => readServer(e.path),
open: (e) => openServerFile(e.path, e.name),
actions: serverActions,
})
server.drawTree()
return server.go(serverRoot)
}
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
@@ -269,7 +604,7 @@ export async function openServerFile(path, name) {
let tab = "local"
let serverRead = false
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
/** Show one of the two explorers: `local` (this computer) or `server`. The server's is read once. */
export function showFilesTab(which) {
tab = which === "server" ? "server" : "local"
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
@@ -285,13 +620,15 @@ export function initExplorer() {
const tabBtn = (which, ico, label) =>
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
$("server-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("server-refresh").addEventListener("click", () => {
serverSaid("")
return server ? server.refresh() : renderServer()
})
$("server-upload").replaceChildren(icon(ICONS.send))
$("server-upload").addEventListener("click", () => uploadInto(serverRoot))
$("server-upload").addEventListener("click", () => uploadInto(server?.current() ?? serverRoot))
$("server-mkdir").replaceChildren(icon(ICONS.folder))
$("server-mkdir").addEventListener("click", () => makeFolder(serverRoot))
$("server-mkdir").addEventListener("click", () => makeFolder(server?.current() ?? serverRoot))
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
$("local-refresh").addEventListener("click", () => (localPane ? localPane.refresh() : renderLocal()))
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function closeViewer() {
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
* language already.
*/
function withheldWords(withheld, size) {
export function withheldWords(withheld, size) {
if (typeof withheld === "object" && withheld.message) return String(withheld.message)
const reason = typeof withheld === "object" ? withheld.reason : withheld
return reason === "too_big" || reason === "too_large"
+3 -1
View File
@@ -1,4 +1,4 @@
/* LLeMbas v2.0.0-beta.9 (3b7da5fc7ac7) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
/* LLeMbas v2.0.0-beta.10 (6d1ab36371c8) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
The font-face rules are left out; the app bundles no font. */
/*
Design tokens.
@@ -114,6 +114,8 @@
--w-field-sm: 10rem; /* a short select's floor */
--w-field: 14rem; /* a capped field, a label column, a small menu */
--w-chip: 16rem; /* a name that truncates: a model, a path */
--w-chip-sm: 12rem; /* the same name where the bar is narrow */
--w-select-sm: 9rem; /* a short select in a toolbar (the chat's mode) */
--w-menu-sm: 20rem; /* a compact popover, an attachment chip */
--w-menu: 24rem; /* a popover, a results list, a card's column */
--w-menu-lg: 28rem; /* a wide popover */
+2 -2
View File
@@ -1,6 +1,6 @@
{
"tag": "v2.0.0-beta.9",
"commit": "3b7da5fc7ac72d5de9229919793dd299c03da6a8",
"tag": "v2.0.0-beta.10",
"commit": "6d1ab36371c8492ee2590fbe25d112d73513a9ae",
"files": {
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},