Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
113306b60d
|
||
|
|
b13cfa4ace
|
||
|
|
fe4bf064f5
|
||
|
|
6874dd46cd
|
No files matched your search
@@ -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
@@ -1 +1 @@
|
||||
2.0.0-beta.7
|
||||
2.0.0-beta.8
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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,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 @@
|
||||
LLeMbas v2.0.0-beta.9 (3b7da5fc7ac72d5de9229919793dd299c03da6a8) tests/fixtures/api_v2
|
||||
LLeMbas v2.0.0-beta.10 (6d1ab36371c8492ee2590fbe25d112d73513a9ae) tests/fixtures/api_v2
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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é",
|
||||
|
||||
+30
-14
@@ -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>
|
||||
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
<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>
|
||||
<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>
|
||||
<div class="files__tree" id="local-tree"></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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<p class="status" id="server-status" hidden></p>
|
||||
<div class="files__tree" id="server-tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+70
-17
@@ -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)
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
+434
-97
@@ -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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 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"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 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) }))
|
||||
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: "ftree__row", title: e.path, dataset: { path: e.path } },
|
||||
icon(iconOf(e)),
|
||||
h("span", { class: "ftree__name", text: e.name }),
|
||||
)
|
||||
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 = (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)
|
||||
branch(kids, e.path, depth + 1)
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
|
||||
for (const e of entries) {
|
||||
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) }),
|
||||
)
|
||||
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,
|
||||
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 tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
|
||||
? 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,
|
||||
)
|
||||
if (isDir(e)) {
|
||||
let kids = null
|
||||
const expand = async (opening) => {
|
||||
row.setAttribute("aria-expanded", String(opening))
|
||||
if (!opening) {
|
||||
opened.delete(e.path)
|
||||
levels.delete(e.path)
|
||||
kids?.remove()
|
||||
kids = null
|
||||
return
|
||||
}
|
||||
opened.add(e.path)
|
||||
kids = h("ul", { role: "group" })
|
||||
li.append(kids)
|
||||
await level(kids, e.path, depth + 1)
|
||||
}
|
||||
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
|
||||
if (opened.has(e.path)) expand(true)
|
||||
} else row.addEventListener("click", () => open(e))
|
||||
parent.append(li)
|
||||
),
|
||||
)
|
||||
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 }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
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)
|
||||
|
||||
/** 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)
|
||||
}
|
||||
return { done, reload }
|
||||
/** 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.") }),
|
||||
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
|
||||
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(
|
||||
"select",
|
||||
{ class: "pane__pick", "aria-label": t("Folder"), 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 })),
|
||||
if (!local.fixed && local.roots.length > 1) {
|
||||
head.append(
|
||||
h(
|
||||
"select",
|
||||
{ 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,
|
||||
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) => {
|
||||
const where = t("This computer")
|
||||
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
@@ -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
@@ -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
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user