From 97c2301bab14567896cf9efe639f259632d55922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 12:55:54 +0000 Subject: [PATCH] A new agent chat on another linked computer (as the web and Android can) Where the account has another computer linked (GET /devices), a chip before the folder chooses it; this computer stays the default. Its folder is one its agent chats have worked in, or a path typed in; the Terminal before the first message opens there too. Tests D4; failing UI tests are also GitHub annotations. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ scripts/gen-i18n.py | 73 ++++++++++++++++++++++++++ tests/ui/run.mjs | 90 ++++++++++++++++++++++++++++++++ ui/i18n.js | 64 +++++++++++++++++++++++ ui/js/chat.js | 122 ++++++++++++++++++++++++++++++++++++++------ 5 files changed, 338 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b353ab..acf0026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ All notable changes to LLeMbas Desktop. The format follows found again in the app. Archived, under Files in the sidebar, lists them newest first; each opens as any chat does, goes back into the sidebar with Restore chat, or is deleted. An archived chat that is open says so in its header, with Restore chat beside it and in its menu. +- **An agent chat on another computer.** A new agent chat could only work on this computer. Where + the account has another computer linked, a chip before the folder chooses it (this computer is + the default). Its folder is one its agent chats have worked in, or a path typed in; the Terminal + before the first message opens there too. ## [1.0.0-beta.6] — 2026-11-10 diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 38dfb23..db4d006 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -449,6 +449,15 @@ KEYS = ( "Archived", "Restore chat", "No archived chats.", + # chat.js: a new agent chat on another computer + "Computer", + "Folder on %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.", + "Choose", + "Choose a folder…", + "Type a folder…", + "Choose a folder first.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -678,6 +687,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Čas", "No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.", "No archived chats.": "Žádné archivované konverzace.", + "Computer": "Počítač", + "Folder on %(device)s": "Složka na zařízení %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta ke složce na tom počítači. Jeho agent otevře jen složky, které dostal.", + "Choose": "Vybrat", + "Choose a folder…": "Vybrat složku…", + "Type a folder…": "Zadat složku…", + "Choose a folder first.": "Nejprve vyberte složku.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.", }, "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.", @@ -902,6 +919,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Uhrzeit", "No day ticked: every day.": "Kein Tag angehakt: jeden Tag.", "No archived chats.": "Keine archivierten Chats.", + "Computer": "Computer", + "Folder on %(device)s": "Ordner auf %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Der vollständige Pfad des Ordners auf diesem Computer. Sein Agent öffnet nur Ordner, die ihm freigegeben wurden.", + "Choose": "Auswählen", + "Choose a folder…": "Ordner wählen…", + "Type a folder…": "Ordner eingeben…", + "Choose a folder first.": "Wählen Sie zuerst einen Ordner.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.", }, "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.", @@ -1126,6 +1151,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Hora", "No day ticked: every day.": "Ningún día marcado: todos los días.", "No archived chats.": "No hay chats archivados.", + "Computer": "Equipo", + "Folder on %(device)s": "Carpeta en %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "La ruta completa de la carpeta en ese equipo. Su agente solo abre las carpetas que se le han dado.", + "Choose": "Elegir", + "Choose a folder…": "Elegir una carpeta…", + "Type a folder…": "Escribir una carpeta…", + "Choose a folder first.": "Elija primero una carpeta.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.", }, "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.", @@ -1350,6 +1383,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Heure", "No day ticked: every day.": "Aucun jour coché : tous les jours.", "No archived chats.": "Aucune conversation archivée.", + "Computer": "Ordinateur", + "Folder on %(device)s": "Dossier sur %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Le chemin complet du dossier sur cet ordinateur. Son agent n’ouvre que les dossiers qui lui ont été confiés.", + "Choose": "Choisir", + "Choose a folder…": "Choisir un dossier…", + "Type a folder…": "Saisir un dossier…", + "Choose a folder first.": "Choisissez d’abord un dossier.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.", }, "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.", @@ -1574,6 +1615,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Időpont", "No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.", "No archived chats.": "Nincs archivált beszélgetés.", + "Computer": "Számítógép", + "Folder on %(device)s": "Mappa ezen: %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "A mappa teljes elérési útja azon a számítógépen. Az ügynöke csak a neki megadott mappákat nyitja meg.", + "Choose": "Kiválasztás", + "Choose a folder…": "Mappa kiválasztása…", + "Type a folder…": "Mappa megadása…", + "Choose a folder first.": "Először válasszon mappát.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.", }, "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.", @@ -1798,6 +1847,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Ora", "No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.", "No archived chats.": "Nessuna chat archiviata.", + "Computer": "Computer", + "Folder on %(device)s": "Cartella su %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Il percorso completo della cartella su quel computer. Il suo agente apre solo le cartelle che gli sono state date.", + "Choose": "Scegli", + "Choose a folder…": "Scegli una cartella…", + "Type a folder…": "Digita una cartella…", + "Choose a folder first.": "Scegli prima una cartella.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2022,6 +2079,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Godzina", "No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.", "No archived chats.": "Brak zarchiwizowanych czatów.", + "Computer": "Komputer", + "Folder on %(device)s": "Folder na %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Pełna ścieżka folderu na tym komputerze. Jego agent otwiera tylko foldery, które mu udostępniono.", + "Choose": "Wybierz", + "Choose a folder…": "Wybierz folder…", + "Type a folder…": "Wpisz folder…", + "Choose a folder first.": "Najpierw wybierz folder.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.", }, "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.", @@ -2246,6 +2311,14 @@ OWN: dict[str, dict[str, str]] = { "Time": "Čas", "No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.", "No archived chats.": "Žiadne archivované konverzácie.", + "Computer": "Počítač", + "Folder on %(device)s": "Priečinok na zariadení %(device)s", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.", + "Choose": "Vybrať", + "Choose a folder…": "Vybrať priečinok…", + "Type a folder…": "Zadať priečinok…", + "Choose a folder first.": "Najprv vyberte priečinok.", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.", }, } diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 92a98af..c47af3c 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -887,6 +887,94 @@ test("D2: at 390 px the archived page stays inside the window, its buttons reach assert.deepEqual(p.errors, []) }) +// D4: a new agent chat on another linked computer. `GET /devices` lists this one (its host is the +// app's own) and another; folders on the other come from its agent chats in the sidebar, or typed. + +const STUDIO = { id: "token9", name: "studio", host: "studio", platform: "linux", terminal: true } + +function d4(over = {}) { + return { + status: status(), + routes: baseRoutes({ + "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }, STUDIO] }, + "GET /chats": { chats: [chatRow(), agentChat(), agentChat({ id: "chat7", title: "Site tweaks", running: false, device: { token_id: "token9", directory: "/srv/site" } })], more: false }, + "POST /chats": { chat: agentChat({ id: "chat8", running: false, device: { token_id: "token9", directory: "/srv/site" } }), message: userMessage("m9", "Check the logs"), reply: null }, + ...over, + }), + streams: {}, + terminal: [READY, ["output", "dev@studio:/srv/site$ "]], + } +} + +async function pickDevice(p, name) { + await p.click("#composer-chips .cchip--device") + await p.locator(".menu .menu__item", { hasText: name }).first().click() +} + +test("D4: a new agent chat defaults to this computer and can be sent to another linked one, in a folder it knows", async () => { + 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 pickDevice(p, "studio") + await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .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.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.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" }) + await p.fill("#composer-text", "Check the logs") + await p.click("#send") + await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1) + const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats")) + assert.equal(made.args.body.kind, "agent") + assert.equal(made.args.body.device, "token9") + assert.equal(made.args.body.directory, "/srv/site") + assert.deepEqual(p.errors, []) +}) + +test("D4: on a computer with no known folder the path is typed; back to this computer gives its own folders", async () => { + const p = await page(d4({ "GET /chats": { chats: [chatRow(), agentChat()], more: false } })) + 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.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.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 pickDevice(p, "This computer") + await p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent)) + assert.deepEqual(p.errors, []) +}) + +test("D4: with no other computer linked there is no choice to make; at 390 px the chips stay inside", async () => { + 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.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 || "")) + assert.ok(await noSideways(p, 390), "nothing scrolls sideways") + await shot(p, "d4-device-390") + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" @@ -900,6 +988,8 @@ for (const { name, fn } of tests) { } catch (err) { failed++ console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`) + // On GitHub Actions, also as an annotation: readable without the job's log. + if (process.env.GITHUB_ACTIONS) console.log(`::error title=tests/ui::${`${name}: ${String(err?.message || err)}`.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").slice(0, 900)}`) } } await browser.close() diff --git a/ui/i18n.js b/ui/i18n.js index 47e2ede..c413b11 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -50,6 +50,7 @@ const LEMBAS_I18N = { "Allow": "Povolit", "Always": "Vždy", "Always allow": "Vždy povolit", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentní konverzace pracuje ve složce na tomto počítači přes agenta vestavěného v této aplikaci.", "Another device": "Jiné zařízení", "Answer": "Odpovědět", @@ -67,6 +68,9 @@ const LEMBAS_I18N = { "Chat": "Konverzace", "Chats": "Konverzace", "Check for updates…": "Zkontrolovat aktualizace…", + "Choose": "Vybrat", + "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.", "Close": "Zavřít", "Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)", @@ -80,6 +84,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Rezerva na zhuštění", "Compacts before the next reply.": "Shrne se před další odpovědí.", "Compacts by itself once the window is known.": "Shrne se samo, jakmile bude známé okno.", + "Computer": "Počítač", "Connect": "Připojit", "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.", @@ -127,6 +132,7 @@ const LEMBAS_I18N = { "Findings": "Zjištění", "Finished %(n)s": "Dokončeno: %(n)s", "Folder": "Složka", + "Folder on %(device)s": "Složka na zařízení %(device)s", "Free space": "Volné místo", "Hello": "Dobrý den", "Hello, %(name)s": "Dobrý den, %(name)s", @@ -302,6 +308,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikace se k vašemu serveru přihlásí jednou, ve vašem prohlížeči, jako každé zařízení. Pak může číst a psát vaše konverzace, jako webové rozhraní.", "The connection to the reply was lost: %(why)s": "Spojení s odpovědí se přerušilo: %(why)s", "The first free scheduling model": "První volný model pro plánování", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta ke složce na tom počítači. Jeho agent otevře jen složky, které dostal.", "The instruction each run is given, on its own: say everything it needs.": "Instrukce, kterou dostane každé spuštění samostatně: uveďte vše, co potřebuje.", "The last %(n)s runs failed: %(why)s": "Posledních %(n)s spuštění selhalo: %(why)s", "The model's own": "Podle modelu", @@ -340,6 +347,7 @@ const LEMBAS_I18N = { "Tools": "Nástroje", "Try again": "Zkusit znovu", "Type a command or a chat's title": "Napište příkaz nebo název konverzace", + "Type a folder…": "Zadat složku…", "Up to %(n)s at once": "Nejvýše %(n)s současně", "Wait until the files are sent.": "Počkejte, až se soubory odešlou.", "Waiting for a free model": "Čeká na volný model", @@ -450,6 +458,7 @@ const LEMBAS_I18N = { "Allow": "Erlauben", "Always": "Immer", "Always allow": "Immer erlauben", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Ein Agenten-Chat arbeitet in einem Ordner auf diesem Computer, über den in diese App eingebauten Agenten.", "Another device": "Ein anderes Gerät", "Answer": "Antworten", @@ -467,6 +476,9 @@ const LEMBAS_I18N = { "Chat": "Chat", "Chats": "Chats", "Check for updates…": "Nach Updates suchen…", + "Choose": "Auswählen", + "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.", "Close": "Schließen", "Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)", @@ -480,6 +492,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Puffer für die Verdichtung", "Compacts before the next reply.": "Wird vor der nächsten Antwort zusammengefasst.", "Compacts by itself once the window is known.": "Wird von selbst zusammengefasst, sobald das Fenster bekannt ist.", + "Computer": "Computer", "Connect": "Verbinden", "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.", @@ -527,6 +540,7 @@ const LEMBAS_I18N = { "Findings": "Erkenntnisse", "Finished %(n)s": "Fertig: %(n)s", "Folder": "Ordner", + "Folder on %(device)s": "Ordner auf %(device)s", "Free space": "Freier Platz", "Hello": "Hallo", "Hello, %(name)s": "Hallo, %(name)s", @@ -702,6 +716,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Die App meldet sich einmal an Ihrem Server an, in Ihrem Browser, wie jedes Gerät. Danach kann sie Ihre Chats lesen und schreiben, wie die Weboberfläche.", "The connection to the reply was lost: %(why)s": "Die Verbindung zur Antwort ist abgebrochen: %(why)s", "The first free scheduling model": "Das erste freie Planungsmodell", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Der vollständige Pfad des Ordners auf diesem Computer. Sein Agent öffnet nur Ordner, die ihm freigegeben wurden.", "The instruction each run is given, on its own: say everything it needs.": "Die Anweisung, die jeder Lauf für sich erhält: Sagen Sie alles, was er braucht.", "The last %(n)s runs failed: %(why)s": "Die letzten %(n)s Läufe sind fehlgeschlagen: %(why)s", "The model's own": "Die des Modells", @@ -740,6 +755,7 @@ const LEMBAS_I18N = { "Tools": "Werkzeuge", "Try again": "Erneut versuchen", "Type a command or a chat's title": "Befehl oder Chat-Titel eingeben", + "Type a folder…": "Ordner eingeben…", "Up to %(n)s at once": "Bis zu %(n)s gleichzeitig", "Wait until the files are sent.": "Warten Sie, bis die Dateien gesendet sind.", "Waiting for a free model": "Wartet auf ein freies Modell", @@ -850,6 +866,7 @@ const LEMBAS_I18N = { "Allow": "Permitir", "Always": "Siempre", "Always allow": "Permitir siempre", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Un chat de agente trabaja en una carpeta de este equipo, a través del agente integrado en esta aplicación.", "Another device": "Otro dispositivo", "Answer": "Responder", @@ -867,6 +884,9 @@ const LEMBAS_I18N = { "Chat": "Chat", "Chats": "Chats", "Check for updates…": "Buscar actualizaciones…", + "Choose": "Elegir", + "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.", "Close": "Cerrar", "Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)", @@ -880,6 +900,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Reserva para la compactación", "Compacts before the next reply.": "Se compacta antes de la próxima respuesta.", "Compacts by itself once the window is known.": "Se compacta sola en cuanto se conozca la ventana.", + "Computer": "Equipo", "Connect": "Conectar", "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.", @@ -927,6 +948,7 @@ const LEMBAS_I18N = { "Findings": "Hallazgos", "Finished %(n)s": "Terminadas: %(n)s", "Folder": "Carpeta", + "Folder on %(device)s": "Carpeta en %(device)s", "Free space": "Espacio libre", "Hello": "Hola", "Hello, %(name)s": "Hola, %(name)s", @@ -1102,6 +1124,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "La aplicación inicia sesión en su servidor una vez, en su navegador, como cualquier dispositivo. Después puede leer y escribir sus chats, como la interfaz web.", "The connection to the reply was lost: %(why)s": "Se perdió la conexión con la respuesta: %(why)s", "The first free scheduling model": "El primer modelo de programación libre", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "La ruta completa de la carpeta en ese equipo. Su agente solo abre las carpetas que se le han dado.", "The instruction each run is given, on its own: say everything it needs.": "La instrucción que recibe cada ejecución por sí sola: diga todo lo que necesita.", "The last %(n)s runs failed: %(why)s": "Las últimas %(n)s ejecuciones fallaron: %(why)s", "The model's own": "La del modelo", @@ -1140,6 +1163,7 @@ const LEMBAS_I18N = { "Tools": "Herramientas", "Try again": "Reintentar", "Type a command or a chat's title": "Escriba un comando o el título de un chat", + "Type a folder…": "Escribir una carpeta…", "Up to %(n)s at once": "Hasta %(n)s a la vez", "Wait until the files are sent.": "Espere a que se envíen los archivos.", "Waiting for a free model": "Esperando un modelo libre", @@ -1250,6 +1274,7 @@ const LEMBAS_I18N = { "Allow": "Autoriser", "Always": "Toujours", "Always allow": "Toujours autoriser", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Une discussion d'agent travaille dans un dossier de cet ordinateur, via l'agent intégré à cette application.", "Another device": "Un autre appareil", "Answer": "Répondre", @@ -1267,6 +1292,9 @@ const LEMBAS_I18N = { "Chat": "Conversation", "Chats": "Conversations", "Check for updates…": "Rechercher des mises à jour…", + "Choose": "Choisir", + "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.", "Close": "Fermer", "Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)", @@ -1280,6 +1308,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Réserve de compactage", "Compacts before the next reply.": "Compacté avant la prochaine réponse.", "Compacts by itself once the window is known.": "Compacté de lui-même dès que la fenêtre est connue.", + "Computer": "Ordinateur", "Connect": "Se connecter", "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.", @@ -1327,6 +1356,7 @@ const LEMBAS_I18N = { "Findings": "Constats", "Finished %(n)s": "Terminées : %(n)s", "Folder": "Dossier", + "Folder on %(device)s": "Dossier sur %(device)s", "Free space": "Espace libre", "Hello": "Bonjour", "Hello, %(name)s": "Bonjour, %(name)s", @@ -1502,6 +1532,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "L'application se connecte une fois à votre serveur, dans votre navigateur, comme tout appareil. Elle peut ensuite lire et écrire vos discussions, comme l'interface web.", "The connection to the reply was lost: %(why)s": "La connexion à la réponse a été perdue : %(why)s", "The first free scheduling model": "Le premier modèle de planification libre", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Le chemin complet du dossier sur cet ordinateur. Son agent n’ouvre que les dossiers qui lui ont été confiés.", "The instruction each run is given, on its own: say everything it needs.": "L'instruction donnée à chaque exécution, seule : dites tout ce dont elle a besoin.", "The last %(n)s runs failed: %(why)s": "Les %(n)s dernières exécutions ont échoué : %(why)s", "The model's own": "Celle du modèle", @@ -1540,6 +1571,7 @@ const LEMBAS_I18N = { "Tools": "Outils", "Try again": "Réessayer", "Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation", + "Type a folder…": "Saisir un dossier…", "Up to %(n)s at once": "Jusqu’à %(n)s à la fois", "Wait until the files are sent.": "Attendez que les fichiers soient envoyés.", "Waiting for a free model": "En attente d’un modèle libre", @@ -1650,6 +1682,7 @@ const LEMBAS_I18N = { "Allow": "Engedélyezés", "Always": "Mindig", "Always allow": "Mindig engedélyez", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Az ügynökcsevegés egy mappában dolgozik ezen a számítógépen, az alkalmazásba épített ügynökön keresztül.", "Another device": "Másik eszköz", "Answer": "Válasz", @@ -1667,6 +1700,9 @@ const LEMBAS_I18N = { "Chat": "Csevegés", "Chats": "Csevegések", "Check for updates…": "Frissítések keresése…", + "Choose": "Kiválasztás", + "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.", "Close": "Bezárás", "Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)", @@ -1680,6 +1716,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Tömörítési tartalék", "Compacts before the next reply.": "A következő válasz előtt tömörül.", "Compacts by itself once the window is known.": "Magától tömörül, amint ismert az ablak.", + "Computer": "Számítógép", "Connect": "Csatlakozás", "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.", @@ -1727,6 +1764,7 @@ const LEMBAS_I18N = { "Findings": "Megállapítások", "Finished %(n)s": "Kész: %(n)s", "Folder": "Mappa", + "Folder on %(device)s": "Mappa ezen: %(device)s", "Free space": "Szabad hely", "Hello": "Üdv", "Hello, %(name)s": "Üdv, %(name)s", @@ -1902,6 +1940,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Az alkalmazás egyszer jelentkezik be a szerverére, a böngészőjében, mint bármely eszköz. Ezután olvashatja és írhatja a csevegéseit, mint a webes felület.", "The connection to the reply was lost: %(why)s": "A kapcsolat a válasszal megszakadt: %(why)s", "The first free scheduling model": "Az első szabad ütemezési modell", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "A mappa teljes elérési útja azon a számítógépen. Az ügynöke csak a neki megadott mappákat nyitja meg.", "The instruction each run is given, on its own: say everything it needs.": "Az utasítás, amelyet minden futás önmagában kap: mondjon el mindent, amire szüksége van.", "The last %(n)s runs failed: %(why)s": "Az utolsó %(n)s futás sikertelen: %(why)s", "The model's own": "A modell sajátja", @@ -1940,6 +1979,7 @@ const LEMBAS_I18N = { "Tools": "Eszközök", "Try again": "Újrapróbálás", "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…", "Up to %(n)s at once": "Legfeljebb %(n)s egyszerre", "Wait until the files are sent.": "Várjon, amíg a fájlok elküldődnek.", "Waiting for a free model": "Szabad modellre vár", @@ -2050,6 +2090,7 @@ const LEMBAS_I18N = { "Allow": "Consenti", "Always": "Sempre", "Always allow": "Consenti sempre", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Una chat con agente lavora in una cartella di questo computer, tramite l'agente integrato in questa app.", "Another device": "Un altro dispositivo", "Answer": "Rispondi", @@ -2067,6 +2108,9 @@ const LEMBAS_I18N = { "Chat": "Chat", "Chats": "Chat", "Check for updates…": "Cerca aggiornamenti…", + "Choose": "Scegli", + "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.", "Close": "Chiudi", "Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)", @@ -2080,6 +2124,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Riserva per la compattazione", "Compacts before the next reply.": "Si compatta prima della prossima risposta.", "Compacts by itself once the window is known.": "Si compatta da sola appena la finestra è nota.", + "Computer": "Computer", "Connect": "Connetti", "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.", @@ -2127,6 +2172,7 @@ const LEMBAS_I18N = { "Findings": "Risultati", "Finished %(n)s": "Terminate: %(n)s", "Folder": "Cartella", + "Folder on %(device)s": "Cartella su %(device)s", "Free space": "Spazio libero", "Hello": "Ciao", "Hello, %(name)s": "Ciao, %(name)s", @@ -2302,6 +2348,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "L'app accede al suo server una volta, nel suo browser, come ogni dispositivo. Poi può leggere e scrivere le sue chat, come l'interfaccia web.", "The connection to the reply was lost: %(why)s": "La connessione alla risposta si è interrotta: %(why)s", "The first free scheduling model": "Il primo modello di pianificazione libero", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Il percorso completo della cartella su quel computer. Il suo agente apre solo le cartelle che gli sono state date.", "The instruction each run is given, on its own: say everything it needs.": "L’istruzione che ogni esecuzione riceve da sola: di’ tutto ciò che le serve.", "The last %(n)s runs failed: %(why)s": "Le ultime %(n)s esecuzioni non sono riuscite: %(why)s", "The model's own": "Quello del modello", @@ -2340,6 +2387,7 @@ const LEMBAS_I18N = { "Tools": "Strumenti", "Try again": "Riprova", "Type a command or a chat's title": "Digiti un comando o il titolo di una chat", + "Type a folder…": "Digita una cartella…", "Up to %(n)s at once": "Fino a %(n)s alla volta", "Wait until the files are sent.": "Attenda che i file siano inviati.", "Waiting for a free model": "In attesa di un modello libero", @@ -2450,6 +2498,7 @@ const LEMBAS_I18N = { "Allow": "Pozwól", "Always": "Zawsze", "Always allow": "Zawsze zezwalaj", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Czat z agentem pracuje w folderze na tym komputerze, przez agenta wbudowanego w tę aplikację.", "Another device": "Inne urządzenie", "Answer": "Odpowiedź", @@ -2467,6 +2516,9 @@ const LEMBAS_I18N = { "Chat": "Czat", "Chats": "Czaty", "Check for updates…": "Sprawdź aktualizacje…", + "Choose": "Wybierz", + "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.", "Close": "Zamknij", "Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)", @@ -2480,6 +2532,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Rezerwa na kompakcję", "Compacts before the next reply.": "Zostanie streszczona przed następną odpowiedzią.", "Compacts by itself once the window is known.": "Zostanie streszczona sama, gdy okno będzie znane.", + "Computer": "Komputer", "Connect": "Połącz", "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.", @@ -2527,6 +2580,7 @@ const LEMBAS_I18N = { "Findings": "Ustalenia", "Finished %(n)s": "Zakończone: %(n)s", "Folder": "Folder", + "Folder on %(device)s": "Folder na %(device)s", "Free space": "Wolne miejsce", "Hello": "Cześć", "Hello, %(name)s": "Cześć, %(name)s", @@ -2702,6 +2756,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikacja loguje się do Twojego serwera raz, w przeglądarce, jak każde urządzenie. Potem może czytać i pisać Twoje czaty, jak interfejs WWW.", "The connection to the reply was lost: %(why)s": "Połączenie z odpowiedzią zostało zerwane: %(why)s", "The first free scheduling model": "Pierwszy wolny model do planowania", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Pełna ścieżka folderu na tym komputerze. Jego agent otwiera tylko foldery, które mu udostępniono.", "The instruction each run is given, on its own: say everything it needs.": "Instrukcja, którą każde uruchomienie dostaje samodzielnie: podaj wszystko, czego potrzebuje.", "The last %(n)s runs failed: %(why)s": "Ostatnie uruchomienia nie powiodły się (%(n)s): %(why)s", "The model's own": "Domyślne modelu", @@ -2740,6 +2795,7 @@ const LEMBAS_I18N = { "Tools": "Narzędzia", "Try again": "Spróbuj ponownie", "Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu", + "Type a folder…": "Wpisz folder…", "Up to %(n)s at once": "Do %(n)s naraz", "Wait until the files are sent.": "Poczekaj, aż pliki zostaną wysłane.", "Waiting for a free model": "Czeka na wolny model", @@ -2850,6 +2906,7 @@ const LEMBAS_I18N = { "Allow": "Povoliť", "Always": "Vždy", "Always allow": "Vždy povoliť", + "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentská konverzácia pracuje v priečinku na tomto počítači cez agenta vstavaného v tejto aplikácii.", "Another device": "Iné zariadenie", "Answer": "Odpovedať", @@ -2867,6 +2924,9 @@ const LEMBAS_I18N = { "Chat": "Konverzácia", "Chats": "Konverzácie", "Check for updates…": "Skontrolovať aktualizácie…", + "Choose": "Vybrať", + "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ť.", "Close": "Zavrieť", "Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)", @@ -2880,6 +2940,7 @@ const LEMBAS_I18N = { "Compaction buffer": "Rezerva na zhustenie", "Compacts before the next reply.": "Zhrnie sa pred ďalšou odpoveďou.", "Compacts by itself once the window is known.": "Zhrnie sa samo, keď bude okno známe.", + "Computer": "Počítač", "Connect": "Pripojiť", "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ť.", @@ -2927,6 +2988,7 @@ const LEMBAS_I18N = { "Findings": "Zistenia", "Finished %(n)s": "Dokončené: %(n)s", "Folder": "Priečinok", + "Folder on %(device)s": "Priečinok na zariadení %(device)s", "Free space": "Voľné miesto", "Hello": "Dobrý deň", "Hello, %(name)s": "Dobrý deň, %(name)s", @@ -3102,6 +3164,7 @@ const LEMBAS_I18N = { "The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikácia sa na váš server prihlási raz, vo vašom prehliadači, ako každé zariadenie. Potom môže čítať a písať vaše konverzácie, ako webové rozhranie.", "The connection to the reply was lost: %(why)s": "Spojenie s odpoveďou sa prerušilo: %(why)s", "The first free scheduling model": "Prvý voľný model na plánovanie", + "The folder's full path on that computer. Its agent opens only folders it has been given.": "Úplná cesta k priečinku na tom počítači. Jeho agent otvorí len priečinky, ktoré dostal.", "The instruction each run is given, on its own: say everything it needs.": "Inštrukcia, ktorú dostane každé spustenie samostatne: uveďte všetko, čo potrebuje.", "The last %(n)s runs failed: %(why)s": "Posledné spustenia zlyhali (%(n)s): %(why)s", "The model's own": "Podľa modelu", @@ -3140,6 +3203,7 @@ const LEMBAS_I18N = { "Tools": "Nástroje", "Try again": "Skúsiť znova", "Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie", + "Type a folder…": "Zadať priečinok…", "Up to %(n)s at once": "Najviac %(n)s naraz", "Wait until the files are sent.": "Počkajte, kým sa súbory odošlú.", "Waiting for a free model": "Čaká na voľný model", diff --git a/ui/js/chat.js b/ui/js/chat.js index 01c6c9c..16b1869 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -17,7 +17,7 @@ import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubag import { readyIds, sending, sent, discard } from "./attach.js" import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js" import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js" -import { folderName } from "./list.js" +import { folderName, listed } from "./list.js" import { unarchive } from "./archived.js" import { isProgress, progressLine, progressTicks } from "./progress.js" import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js" @@ -42,12 +42,46 @@ function deviceOf(id) { return (ctx.devices?.() || []).find((d) => d.id === id) || null } -/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */ -function draftDevice() { +/** This computer's device: the one linked right now, else the last one it was. */ +function hereDevice() { const mine = ctx.thisDevices() return mine.find((id) => deviceOf(id)) || mine[0] || "" } +/** + * The device a new agent chat goes to: the one chosen on its chip while it is still linked, else + * this computer's. + */ +function draftDevice() { + if (draft?.device && deviceOf(draft.device)) return draft.device + return hereDevice() +} + +/** Whether the new agent chat works here (the agent built into this app) or on another device. */ +function draftHere() { + const d = draftDevice() + return !d || ctx.thisDevices().includes(d) +} + +/** The other linked devices of this account: where else a new agent chat can work. */ +function otherDevices() { + const mine = ctx.thisDevices() + return (ctx.devices?.() || []).filter((d) => !mine.includes(d.id)) +} + +/** + * The folders an agent chat on another device has worked in: the sidebar's agent chats there, + * newest first. This app cannot browse another computer; these and a typed path are the choice. + */ +function knownFolders(device) { + const out = [] + for (const c of listed()) { + const dir = c.kind === "agent" && c.device?.token_id === device ? c.device.directory : "" + if (dir && !out.includes(dir)) out.push(dir) + } + return out.slice(0, 8) +} + /** * What the Terminal panel is for, as the page stands: a new agent chat whose device and folder * are chosen (the shell before the first message), an open agent chat on a device that opens @@ -58,7 +92,7 @@ function terminalTarget() { if (draft.kind !== "agent" || !draft.dir) return null const device = deviceOf(draftDevice()) if (!device?.terminal) return null - return { device: device.id, directory: draft.dir, name: device.name || t("This computer") } + return { device: device.id, directory: draft.dir, name: device.name || (draftHere() ? t("This computer") : t("Another device")) } } const chat = open?.chat if (chat?.kind !== "agent" || !chat.device) return null @@ -839,6 +873,51 @@ function folderChip(roots, value, onChange) { }) } +/** + * Which computer a new agent chat works on: this one (the agent built into this app), or another + * linked device of the account, as `GET /devices` lists them. Shown only where there is another. + */ +function deviceChip(others) { + const here = draftHere() + const current = deviceOf(draftDevice()) + const choose = (id) => { + if (id === draftDevice()) return + draft.device = id + const elsewhere = !ctx.thisDevices().includes(id) + draft.dir = elsewhere ? knownFolders(id)[0] || "" : "" + renderChips() + renderHello() + renderNotice() + } + const items = [{ label: t("This computer"), hint: ctx.status()?.host || "", checked: here, run: () => choose(hereDevice() || "") }] + for (const d of others) items.push({ label: d.name || d.host || d.id, hint: [d.host, d.platform].filter(Boolean).join(" · "), checked: !here && current?.id === d.id, run: () => choose(d.id) }) + const label = here ? t("This computer") : current?.name || current?.host || t("Another device") + const b = chip({ ico: ICONS.terminal, label, title: `${t("Computer")}: ${label}`, cls: "cchip--device", onclick: (e) => menu(e.currentTarget, items) }) + return b +} + +/** The folder of a new agent chat on another device: one it has worked in, or one typed. */ +function elsewhereFolderChip(device, value, onChange) { + const type = async () => { + const dir = await ask({ title: t("Folder on %(device)s", { device: deviceOf(device)?.name || t("Another device") }), text: t("The folder's full path on that computer. Its agent opens only folders it has been given."), value: value || "", ok: t("Choose"), cancel: t("Cancel") }) + if (dir) onChange(dir) + } + const known = knownFolders(device) + if (!value && !known.length) return chip({ ico: ICONS.folder, label: t("Choose a folder…"), menuish: false, cls: "is-warn", onclick: type }) + return chip({ + ico: ICONS.folder, + label: value ? folderName(value) : t("Choose a folder…"), + title: value ? `${t("Folder")}: ${value}` : "", + cls: value ? "" : "is-warn", + onclick: (e) => + menu(e.currentTarget, [ + ...known.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })), + known.length ? null : undefined, + { label: t("Type a folder…"), run: type }, + ].filter((x) => x !== undefined)), + }) +} + let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped /** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */ @@ -852,9 +931,15 @@ async function renderChips() { const model = models.find((m) => m.id === draft.model) const parts = [] if (draft.kind === "agent") { - 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()))) + const others = otherDevices() + if (others.length) parts.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()))) + } else { + parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips()))) + } parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips()))) } const shell = terminalTarget() @@ -923,7 +1008,7 @@ async function renderChips() { function renderNotice() { const box = $("link-notice") const s = ctx.status() - const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat) + const wants = draft ? draft.kind === "agent" && draftHere() : open?.chat?.kind === "agent" && onThisComputer(open.chat) const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy" box.hidden = !show if (show) box.replaceChildren(agentBlock(s, { compact: !draft })) @@ -1128,7 +1213,7 @@ export function closeChat() { // --- A new chat ------------------------------------------------------------------------------------ -let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written +let draft = null // { kind, device, dir, mode, model, effort, agents } while a new chat is being written let lastKind = "agent" /** The kind a new chat starts as: the last one chosen (an agent chat, at first). */ @@ -1190,7 +1275,9 @@ function renderHello() { h( "p", { class: "hello__sub" }, - kind === "agent" + kind === "agent" && !draftHere() + ? t("An agent chat works in a folder on %(device)s, through the LLeMbas agent there.", { device: deviceOf(draftDevice())?.name || t("Another device") }) + : kind === "agent" ? t("An agent chat works in a folder on this computer, through the agent built into this app.") : t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }), ), @@ -1206,7 +1293,7 @@ export function newChat(kind) { const models = ctx.models() const start = models.find((m) => m.default) || models[0] lastKind = kind || lastKind - draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null } + draft = { kind: lastKind, device: "", dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null } clear($("messages")) renderCards({ id: "" }, []) alertIn("") @@ -1233,11 +1320,16 @@ async function createChat(text, attachments) { if (agents && (agents.min || agents.max)) body.agents = agents if (draft.kind === "agent") { const device = draftDevice() - if (!device || !agentReady(ctx.status())) { - renderNotice() - throw { detail: agentState(ctx.status()).sentence } + if (draftHere()) { + if (!device || !agentReady(ctx.status())) { + renderNotice() + throw { detail: agentState(ctx.status()).sentence } + } + if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") } + } else { + if (!deviceOf(device)) throw { detail: t("This device is not linked right now.") } + if (!draft.dir) throw { detail: t("Choose a folder first.") } } - if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") } Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" }) } const r = await post("/chats", body)