diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1bb19..1a13187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to LLeMbas Desktop. The format follows server says it, unless this computer's or another of the app's nine is chosen (kept in the app; the window opens again in it). The theme is the account's, and choosing one saves it there, so the web interface and the phone follow; on a server that cannot be told, it is kept in the app. +- **Pinned chats and chat folders.** The sidebar lists the pinned chats first, then the account's + folders, nested and folded as the server keeps them, then the rest by day. A chat's menu pins + it and moves it into a folder or a new one; a folder's menu renames it, makes one inside it, or + deletes it (its chats stay, back in the list). The search line still lists every match. ### Fixed diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 8c6c8d1..2d16c6c 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -471,6 +471,19 @@ KEYS = ( "Kept in this app on this computer; the account's theme stays as it is.", "The account's (%(language)s)", "Saved to your account: the web interface and the phone follow it too.", + # list.js, chat.js: pinned chats and chat folders + "Pinned chats", + "Pin chat", + "Unpin chat", + "New folder", + "Rename folder", + "Delete folder", + "Folder name", + "Move to folder…", + "No folder", + "New folder…", + "Create", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -714,6 +727,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Uloženo v této aplikaci na tomto počítači; vzhled účtu zůstává, jak je.", "The account's (%(language)s)": "Podle účtu (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Uloženo do vašeho účtu: řídí se jím i webové rozhraní a telefon.", + "Move to folder…": "Přesunout do složky…", + "No folder": "Bez složky", + "New folder…": "Nová složka…", + "Create": "Vytvořit", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a složky v ní se smažou. Jejich konverzace zůstanou, zpět v seznamu.", }, "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.", @@ -952,6 +970,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "In dieser App auf diesem Computer gespeichert; das Design des Kontos bleibt, wie es ist.", "The account's (%(language)s)": "Wie das Konto (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "In Ihrem Konto gespeichert: Die Weboberfläche und das Telefon folgen ihm auch.", + "Move to folder…": "In Ordner verschieben…", + "No folder": "Kein Ordner", + "New folder…": "Neuer Ordner…", + "Create": "Erstellen", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ und die Ordner darin werden gelöscht. Ihre Chats bleiben, wieder in der Liste.", }, "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.", @@ -1190,6 +1213,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Se guarda en esta aplicación en este equipo; el tema de la cuenta no cambia.", "The account's (%(language)s)": "El de la cuenta (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Se guarda en su cuenta: la interfaz web y el teléfono también lo siguen.", + "Move to folder…": "Mover a una carpeta…", + "No folder": "Sin carpeta", + "New folder…": "Nueva carpeta…", + "Create": "Crear", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "Se eliminan «%(name)s» y las carpetas que contiene. Sus chats se quedan, de vuelta en la lista.", }, "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.", @@ -1428,6 +1456,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Enregistré dans cette application sur cet ordinateur ; le thème du compte reste tel quel.", "The account's (%(language)s)": "Celle du compte (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Enregistré dans votre compte : l’interface web et le téléphone le suivent aussi.", + "Move to folder…": "Déplacer dans un dossier…", + "No folder": "Aucun dossier", + "New folder…": "Nouveau dossier…", + "Create": "Créer", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "« %(name)s » et les dossiers qu’il contient sont supprimés. Leurs conversations restent, de retour dans la liste.", }, "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.", @@ -1666,6 +1699,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Ebben az alkalmazásban, ezen a számítógépen tárolva; a fiók témája változatlan marad.", "The account's (%(language)s)": "A fióké (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "A fiókjába mentve: a webes felület és a telefon is ezt követi.", + "Move to folder…": "Áthelyezés mappába…", + "No folder": "Nincs mappa", + "New folder…": "Új mappa…", + "Create": "Létrehozás", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "A(z) „%(name)s” és a benne lévő mappák törlődnek. A beszélgetéseik megmaradnak, vissza a listába.", }, "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.", @@ -1904,6 +1942,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Salvato in questa app su questo computer; il tema dell’account resta com’è.", "The account's (%(language)s)": "Quella dell’account (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Salvato nel tuo account: anche l’interfaccia web e il telefono lo seguono.", + "Move to folder…": "Sposta in una cartella…", + "No folder": "Nessuna cartella", + "New folder…": "Nuova cartella…", + "Create": "Crea", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "«%(name)s» e le cartelle al suo interno vengono eliminate. Le loro chat restano, di nuovo nell’elenco.", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2142,6 +2185,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Zapisane w tej aplikacji na tym komputerze; motyw konta pozostaje bez zmian.", "The account's (%(language)s)": "Jak na koncie (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Zapisane na koncie: interfejs WWW i telefon też się do niego stosują.", + "Move to folder…": "Przenieś do folderu…", + "No folder": "Bez folderu", + "New folder…": "Nowy folder…", + "Create": "Utwórz", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s” i foldery w nim zostaną usunięte. Ich czaty zostaną, z powrotem na liście.", }, "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.", @@ -2380,6 +2428,11 @@ OWN: dict[str, dict[str, str]] = { "Kept in this app on this computer; the account's theme stays as it is.": "Uložené v tejto aplikácii na tomto počítači; vzhľad účtu zostáva, ako je.", "The account's (%(language)s)": "Podľa účtu (%(language)s)", "Saved to your account: the web interface and the phone follow it too.": "Uložené do vášho účtu: riadi sa ním aj webové rozhranie a telefón.", + "Move to folder…": "Presunúť do priečinka…", + "No folder": "Bez priečinka", + "New folder…": "Nový priečinok…", + "Create": "Vytvoriť", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.", }, } diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 72bb7ac..fb2f4b2 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -1094,6 +1094,112 @@ test("E5: at 390 px the Appearance group stays inside the window", async () => { assert.deepEqual(p.errors, []) }) +// F8: pinned chats and chat folders (fixture folders.json: Waybread, and Recipes inside it, folded). + +function f8(over = {}) { + return { + status: status(), + routes: baseRoutes({ + "GET /folders": fixture("folders"), + "GET /chats": { + chats: [ + chatRow({ id: "chat1", title: "Waybread", pinned: true }), + agentChat({ id: "chat2", title: "Tidy the build", folder_id: "fold1" }), + chatRow({ id: "chat3", title: "Seed cake", folder_id: "fold2" }), + chatRow({ id: "chat4", title: "Loose ends" }), + ], + more: false, + }, + "PATCH /folders/fold2": { folder: { ...fixture("folders").folders[1], collapsed: false } }, + "PATCH /chats/chat4": { chat: chatRow({ id: "chat4", title: "Loose ends", pinned: true }) }, + "POST /folders": fixture("folder"), + "DELETE /folders/fold1": null, + ...over, + }), + streams: {}, + } +} + +const sideText = (p) => p.$$eval("#chat-list > li", (lis) => lis.map((li) => li.textContent.replace(/\s+/g, " ").trim())) + +test("F8: the sidebar: pinned chats first, then the folders, nested and folded as kept, then the rest", async () => { + const p = await page(f8()) + await p.waitForSelector("#chat-list .folderrow") + const items = await sideText(p) + assert.equal(items[0], "Pinned chats") + assert.match(items[1], /Waybread/) + assert.match(items[2], /Waybread\s*2/, "the folder counts the chats inside, its folders' too") + assert.match(items[3], /Tidy the build/) + assert.match(items[4], /Recipes/) + assert.ok(!items.some((x) => /Seed cake/.test(x)), "a folded folder's chats are not shown") + assert.ok(items.some((x) => /Loose ends/.test(x))) + assert.equal(await p.getAttribute('.folderrow[data-folder="fold2"]', "aria-expanded"), "false") + await p.click('.folderrow[data-folder="fold2"]') + await p.waitForSelector('.chatrow[data-id="chat3"]') + const [folded] = await p.evaluate(() => window.__mock.api("PATCH", "/folders/fold2")) + assert.deepEqual(folded.args.body, { collapsed: false }) + await shot(p, "f8-folders-1280") + // Searching: every match, flat. + await p.fill("#side-search", "seed") + assert.deepEqual((await sideText(p)).slice(1), ["Seed cake"]) + assert.deepEqual(p.errors, []) +}) + +test("F8: a chat's menu pins it and moves it into a folder, or a new one", async () => { + const p = await page(f8()) + await p.waitForSelector('.chatrow[data-id="chat4"]') + await p.click('.chatrow[data-id="chat4"]', { button: "right" }) + await p.click(".menu .menu__item >> text=Pin chat") + await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 1) + assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[0].args.body, { pinned: true }) + await p.click('.chatrow[data-id="chat4"]', { button: "right" }) + await p.click(".menu .menu__item >> text=Move to folder…") + const labels = await p.$$eval(".menu .menu__item .menu__label", (l) => l.map((x) => x.textContent.trim())) + assert.deepEqual(labels, ["No folder", "Waybread", "Recipes", "New folder…"]) + await p.click(".menu .menu__item >> text=Recipes") + await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 2) + assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[1].args.body, { folder_id: "fold2" }) + // A new one: named, made, and the chat moved into it. + await p.click('.chatrow[data-id="chat4"]', { button: "right" }) + await p.click(".menu .menu__item >> text=Move to folder…") + await p.click(".menu .menu__item >> text=New folder…") + await p.fill("dialog[open] .dialog__input", "Lembas") + await p.click("dialog[open] .btn--primary") + await p.waitForFunction(() => window.__mock.api("PATCH", "/chats/chat4").length === 3) + const made = await p.evaluate(() => window.__mock.api("POST", "/folders")) + assert.deepEqual(made[0].args.body, { name: "Lembas" }) + assert.deepEqual((await p.evaluate(() => window.__mock.api("PATCH", "/chats/chat4")))[2].args.body, { folder_id: "fold1" }) + assert.deepEqual(p.errors, []) +}) + +test("F8: a folder's menu renames it and deletes it, its chats kept; a refusal is said", async () => { + const p = await page(f8({ "PATCH /folders/fold1": { __error: { status: 403, detail: "You may not manage folders." } } })) + await p.waitForSelector('.folderrow[data-folder="fold1"]') + await p.click('.folderrow[data-folder="fold1"]', { button: "right" }) + await p.click(".menu .menu__item >> text=Rename folder") + await p.fill("dialog[open] .dialog__input", "Bread") + await p.click("dialog[open] .btn--primary") + await p.waitForSelector("#thread-note:not([hidden])") + assert.match(await p.textContent("#thread-note"), /may not manage folders/) + await p.click('.list__folder:has([data-folder="fold1"]) .folderrow__more') + await p.click(".menu .menu__item >> text=Delete folder") + assert.match(await p.textContent("dialog[open]"), /Their chats stay/) + await p.click("dialog[open] .btn--danger") + await p.waitForFunction(() => window.__mock.api("DELETE", "/folders/fold1").length === 1) + assert.equal(await p.evaluate(() => window.__mock.api("DELETE", "/chats").length), 0, "no chat deleted") + assert.deepEqual(p.errors, []) +}) + +test("F8: at 390 px the folders stay inside the sidebar", async () => { + const p = await page(f8(), { width: 390, height: 844 }) + await p.evaluate(() => document.getElementById("side-toggle").click()) + await p.waitForSelector('.folderrow[data-folder="fold1"]') + await p.waitForTimeout(400) // the sidebar slides in + assert.ok(await noSideways(p, 390), "nothing scrolls sideways") + await shot(p, "f8-folders-390") + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/app.css b/ui/app.css index 221b981..c31ef21 100644 --- a/ui/app.css +++ b/ui/app.css @@ -431,6 +431,34 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co .chatrow__state { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); } .chatrow__state.is-busy { background: var(--accent); animation: pulse 1.4s var(--ease-in-out) infinite; } .chatrow__state.is-new { background: var(--leaf); } +/* A chat folder's head: chevron · folder · name · count, and its own ⋯ beside it on one track. */ +.list__folder { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; } +.folderrow { + display: grid; + grid-template-columns: 12px 16px minmax(0, 1fr) auto; + gap: var(--sp-2); + align-items: center; + height: var(--control-h-sm); + padding: 0 var(--sp-2) 0 calc(var(--sp-2) + var(--depth, 0) * var(--sp-4)); + text-align: left; + font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); + color: var(--ink-muted); + background: transparent; + border: 0; + border-radius: var(--radius-lg); + cursor: pointer; +} +.folderrow:hover { color: var(--ink); background: var(--surface-hover); } +.folderrow__chev, .folderrow__icon { display: grid; place-items: center; color: var(--ink-faint); } +.folderrow__chev .icon { width: 12px; height: 12px; } +.folderrow__icon .icon { width: 14px; height: 14px; } +.folderrow__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.folderrow__count { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); } +.folderrow__more { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); color: var(--ink-faint); background: transparent; border: 0; border-radius: var(--radius-lg); cursor: pointer; opacity: 0; } +.list__folder:hover .folderrow__more, .folderrow__more:focus-visible { opacity: 1; } +.folderrow__more:hover { color: var(--ink); background: var(--surface-hover); } +@media (hover: none) { .folderrow__more { opacity: 1; } } +.chatrow.is-filed { padding-left: calc(var(--sp-2) + var(--depth, 1) * var(--sp-4)); } @keyframes pulse { 50% { opacity: 0.35; } } @media (prefers-reduced-motion: reduce) { .chatrow__state.is-busy { animation: none; } } diff --git a/ui/i18n.js b/ui/i18n.js index c97a220..192be46 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -103,10 +103,12 @@ const LEMBAS_I18N = { "Copy": "Kopírovat", "Copy the last command and its output": "Zkopírovat poslední příkaz a jeho výstup", "Counted by the model at the last reply; the categories are estimated.": "Spočítal model při poslední odpovědi; kategorie jsou odhadnuté.", + "Create": "Vytvořit", "Cut short here; the whole of it is in the web interface.": "Zde zkráceno; celé je to ve webovém rozhraní.", "Days": "Dny", "Delete": "Smazat", "Delete chat": "Smazat konverzaci", + "Delete folder": "Smazat složku", "Delete this chat?": "Smazat tuto konverzaci?", "Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?", "Deny": "Zamítnout", @@ -135,6 +137,7 @@ const LEMBAS_I18N = { "Findings": "Zjištění", "Finished %(n)s": "Dokončeno: %(n)s", "Folder": "Složka", + "Folder name": "Název složky", "Folder on %(device)s": "Složka na zařízení %(device)s", "Free space": "Volné místo", "Hello": "Dobrý den", @@ -176,15 +179,19 @@ const LEMBAS_I18N = { "Models": "Modely", "Models for scheduled tasks": "Modely pro naplánované úlohy", "More": "Více", + "Move to folder…": "Přesunout do složky…", "Never": "Nikdy", "New agent chat": "Nová agentní konverzace", "New chat": "Nová konverzace", + "New folder": "Nová složka", + "New folder…": "Nová složka…", "New scheduled task": "Nová naplánovaná úloha", "Next run": "Další spuštění", "No archived chats.": "Žádné archivované konverzace.", "No chat yet. Start one with New chat.": "Zatím žádná konverzace. Začněte ji tlačítkem Nová konverzace.", "No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.", "No files written in this chat yet.": "V tomto chatu se zatím nezapsal žádný soubor.", + "No folder": "Bez složky", "No folder yet: add one under Settings, Project folders.": "Zatím žádná složka: přidejte ji v Nastavení, Složky projektů.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Zatím žádná složka: agent nemůže nic otevřít. Přidejte složku projektu.", "No model is available to this account.": "Tomuto účtu není dostupný žádný model.", @@ -220,6 +227,8 @@ const LEMBAS_I18N = { "Pause": "Pozastavit", "Pause %(title)s": "Pozastavit %(title)s", "Paused": "Pozastaveno", + "Pin chat": "Připnout konverzaci", + "Pinned chats": "Připnuté konverzace", "Plan": "Plán", "Plan — changes nothing": "Plan — nic nemění", "Preview": "Náhled", @@ -238,6 +247,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Odebrat %(dir)s", "Rename": "Přejmenovat", "Rename chat": "Přejmenovat konverzaci", + "Rename folder": "Přejmenovat složku", "Rename…": "Přejmenovat…", "Restore": "Obnovit", "Restore chat": "Obnovit konverzaci", @@ -360,6 +370,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Odepnout konverzaci", "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", @@ -429,6 +440,7 @@ const LEMBAS_I18N = { "waiting for a slot": "čeká na volný slot", "window unknown": "okno neznámé", "working": "pracuje", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a složky v ní se smažou. Jejich konverzace zůstanou, zpět v seznamu.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ se smaže na serveru i se svými zprávami a soubory. Nelze to vrátit.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ se zastaví a její historie zmizí. Konverzace, které napsala, zůstanou." }, @@ -523,10 +535,12 @@ const LEMBAS_I18N = { "Copy": "Kopieren", "Copy the last command and its output": "Den letzten Befehl und seine Ausgabe kopieren", "Counted by the model at the last reply; the categories are estimated.": "Vom Modell bei der letzten Antwort gezählt; die Kategorien sind geschätzt.", + "Create": "Erstellen", "Cut short here; the whole of it is in the web interface.": "Hier gekürzt; vollständig steht es in der Weboberfläche.", "Days": "Tage", "Delete": "Löschen", "Delete chat": "Chat löschen", + "Delete folder": "Ordner löschen", "Delete this chat?": "Diesen Chat löschen?", "Delete this scheduled task?": "Diese geplante Aufgabe löschen?", "Deny": "Ablehnen", @@ -555,6 +569,7 @@ const LEMBAS_I18N = { "Findings": "Erkenntnisse", "Finished %(n)s": "Fertig: %(n)s", "Folder": "Ordner", + "Folder name": "Ordnername", "Folder on %(device)s": "Ordner auf %(device)s", "Free space": "Freier Platz", "Hello": "Hallo", @@ -596,15 +611,19 @@ const LEMBAS_I18N = { "Models": "Modelle", "Models for scheduled tasks": "Modelle für geplante Aufgaben", "More": "Mehr", + "Move to folder…": "In Ordner verschieben…", "Never": "Nie", "New agent chat": "Neuer Agenten-Chat", "New chat": "Neuer Chat", + "New folder": "Neuer Ordner", + "New folder…": "Neuer Ordner…", "New scheduled task": "Neue geplante Aufgabe", "Next run": "Nächster Lauf", "No archived chats.": "Keine archivierten Chats.", "No chat yet. Start one with New chat.": "Noch kein Chat. Beginnen Sie einen mit Neuer Chat.", "No day ticked: every day.": "Kein Tag angehakt: jeden Tag.", "No files written in this chat yet.": "In diesem Chat wurde noch keine Datei geschrieben.", + "No folder": "Kein Ordner", "No folder yet: add one under Settings, Project folders.": "Noch kein Ordner: Fügen Sie einen unter Einstellungen, Projektordner hinzu.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Noch kein Ordner: Der Agent kann nichts öffnen. Fügen Sie den Ordner eines Projekts hinzu.", "No model is available to this account.": "Diesem Konto steht kein Modell zur Verfügung.", @@ -640,6 +659,8 @@ const LEMBAS_I18N = { "Pause": "Pausieren", "Pause %(title)s": "%(title)s pausieren", "Paused": "Pausiert", + "Pin chat": "Chat anheften", + "Pinned chats": "Angeheftete Chats", "Plan": "Tarif", "Plan — changes nothing": "Plan — ändert nichts", "Preview": "Vorschau", @@ -658,6 +679,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "%(dir)s entfernen", "Rename": "Umbenennen", "Rename chat": "Chat umbenennen", + "Rename folder": "Ordner umbenennen", "Rename…": "Umbenennen…", "Restore": "Wiederherstellen", "Restore chat": "Chat wiederherstellen", @@ -780,6 +802,7 @@ const LEMBAS_I18N = { "Try again": "Erneut versuchen", "Type a command or a chat's title": "Befehl oder Chat-Titel eingeben", "Type a folder…": "Ordner eingeben…", + "Unpin chat": "Chat loslösen", "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", @@ -849,6 +872,7 @@ const LEMBAS_I18N = { "waiting for a slot": "wartet auf einen Platz", "window unknown": "Fenster unbekannt", "working": "arbeitet", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ und die Ordner darin werden gelöscht. Ihre Chats bleiben, wieder in der Liste.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ wird auf dem Server gelöscht, mit seinen Nachrichten und Dateien. Das lässt sich nicht rückgängig machen.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ hört auf, und ihr Verlauf wird gelöscht. Die Chats, die sie geschrieben hat, bleiben." }, @@ -943,10 +967,12 @@ const LEMBAS_I18N = { "Copy": "Copiar", "Copy the last command and its output": "Copiar el último comando y su salida", "Counted by the model at the last reply; the categories are estimated.": "Contado por el modelo en la última respuesta; las categorías son estimaciones.", + "Create": "Crear", "Cut short here; the whole of it is in the web interface.": "Recortado aquí; completo está en la interfaz web.", "Days": "Días", "Delete": "Eliminar", "Delete chat": "Eliminar chat", + "Delete folder": "Eliminar carpeta", "Delete this chat?": "¿Eliminar este chat?", "Delete this scheduled task?": "¿Eliminar esta tarea programada?", "Deny": "Denegar", @@ -975,6 +1001,7 @@ const LEMBAS_I18N = { "Findings": "Hallazgos", "Finished %(n)s": "Terminadas: %(n)s", "Folder": "Carpeta", + "Folder name": "Nombre de la carpeta", "Folder on %(device)s": "Carpeta en %(device)s", "Free space": "Espacio libre", "Hello": "Hola", @@ -1016,15 +1043,19 @@ const LEMBAS_I18N = { "Models": "Modelos", "Models for scheduled tasks": "Modelos para tareas programadas", "More": "Más", + "Move to folder…": "Mover a una carpeta…", "Never": "Nunca", "New agent chat": "Nuevo chat de agente", "New chat": "Nuevo chat", + "New folder": "Nueva carpeta", + "New folder…": "Nueva carpeta…", "New scheduled task": "Nueva tarea programada", "Next run": "Próxima ejecución", "No archived chats.": "No hay chats archivados.", "No chat yet. Start one with New chat.": "Aún no hay chats. Empiece uno con Nuevo chat.", "No day ticked: every day.": "Ningún día marcado: todos los días.", "No files written in this chat yet.": "Todavía no se ha escrito ningún archivo en este chat.", + "No folder": "Sin carpeta", "No folder yet: add one under Settings, Project folders.": "Aún no hay carpeta: añada una en Ajustes, Carpetas de proyecto.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Aún no hay ninguna carpeta: el agente no puede abrir nada. Añada la carpeta de un proyecto.", "No model is available to this account.": "Ningún modelo está disponible para esta cuenta.", @@ -1060,6 +1091,8 @@ const LEMBAS_I18N = { "Pause": "Pausar", "Pause %(title)s": "Pausar %(title)s", "Paused": "En pausa", + "Pin chat": "Fijar chat", + "Pinned chats": "Chats fijados", "Plan": "Plan", "Plan — changes nothing": "Plan — no cambia nada", "Preview": "Vista previa", @@ -1078,6 +1111,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Quitar %(dir)s", "Rename": "Renombrar", "Rename chat": "Renombrar chat", + "Rename folder": "Renombrar carpeta", "Rename…": "Renombrar…", "Restore": "Restaurar", "Restore chat": "Restaurar chat", @@ -1200,6 +1234,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Dejar de fijar el chat", "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", @@ -1269,6 +1304,7 @@ const LEMBAS_I18N = { "waiting for a slot": "esperando una plaza", "window unknown": "ventana desconocida", "working": "trabajando", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "Se eliminan «%(name)s» y las carpetas que contiene. Sus chats se quedan, de vuelta en la lista.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» se elimina del servidor, con sus mensajes y archivos. No se puede deshacer.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» se detiene y su historial se borra. Los chats que escribió se conservan." }, @@ -1363,10 +1399,12 @@ const LEMBAS_I18N = { "Copy": "Copier", "Copy the last command and its output": "Copier la dernière commande et sa sortie", "Counted by the model at the last reply; the categories are estimated.": "Compté par le modèle lors de la dernière réponse ; les catégories sont estimées.", + "Create": "Créer", "Cut short here; the whole of it is in the web interface.": "Tronqué ici ; le tout est dans l'interface web.", "Days": "Jours", "Delete": "Supprimer", "Delete chat": "Supprimer la conversation", + "Delete folder": "Supprimer le dossier", "Delete this chat?": "Supprimer cette conversation ?", "Delete this scheduled task?": "Supprimer cette tâche planifiée ?", "Deny": "Refuser", @@ -1395,6 +1433,7 @@ const LEMBAS_I18N = { "Findings": "Constats", "Finished %(n)s": "Terminées : %(n)s", "Folder": "Dossier", + "Folder name": "Nom du dossier", "Folder on %(device)s": "Dossier sur %(device)s", "Free space": "Espace libre", "Hello": "Bonjour", @@ -1436,15 +1475,19 @@ const LEMBAS_I18N = { "Models": "Modèles", "Models for scheduled tasks": "Modèles pour les tâches planifiées", "More": "Plus", + "Move to folder…": "Déplacer dans un dossier…", "Never": "Jamais", "New agent chat": "Nouvelle discussion d'agent", "New chat": "Nouvelle conversation", + "New folder": "Nouveau dossier", + "New folder…": "Nouveau dossier…", "New scheduled task": "Nouvelle tâche planifiée", "Next run": "Prochaine exécution", "No archived chats.": "Aucune conversation archivée.", "No chat yet. Start one with New chat.": "Aucune conversation pour l’instant. Commencez-en une avec Nouvelle conversation.", "No day ticked: every day.": "Aucun jour coché : tous les jours.", "No files written in this chat yet.": "Aucun fichier n’a encore été écrit dans cette conversation.", + "No folder": "Aucun dossier", "No folder yet: add one under Settings, Project folders.": "Aucun dossier pour l'instant : ajoutez-en un dans Paramètres, Dossiers de projet.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Aucun dossier pour l'instant : l'agent ne peut rien ouvrir. Ajoutez le dossier d'un projet.", "No model is available to this account.": "Aucun modèle n'est disponible pour ce compte.", @@ -1480,6 +1523,8 @@ const LEMBAS_I18N = { "Pause": "Mettre en pause", "Pause %(title)s": "Mettre %(title)s en pause", "Paused": "En pause", + "Pin chat": "Épingler la discussion", + "Pinned chats": "Discussions épinglées", "Plan": "Forfait", "Plan — changes nothing": "Plan — ne change rien", "Preview": "Aperçu", @@ -1498,6 +1543,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Retirer %(dir)s", "Rename": "Renommer", "Rename chat": "Renommer la conversation", + "Rename folder": "Renommer le dossier", "Rename…": "Renommer…", "Restore": "Restaurer", "Restore chat": "Restaurer la conversation", @@ -1620,6 +1666,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Désépingler la discussion", "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", @@ -1689,6 +1736,7 @@ const LEMBAS_I18N = { "waiting for a slot": "attend une place", "window unknown": "fenêtre inconnue", "working": "au travail", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "« %(name)s » et les dossiers qu’il contient sont supprimés. Leurs conversations restent, de retour dans la liste.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "« %(title)s » est supprimée du serveur, avec ses messages et ses fichiers. C'est définitif.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "« %(title)s » s’arrête et son historique disparaît. Les conversations qu’elle a écrites restent." }, @@ -1783,10 +1831,12 @@ const LEMBAS_I18N = { "Copy": "Másolás", "Copy the last command and its output": "Az utolsó parancs és kimenetének másolása", "Counted by the model at the last reply; the categories are estimated.": "A modell számolta az utolsó válasznál; a kategóriák becslések.", + "Create": "Létrehozás", "Cut short here; the whole of it is in the web interface.": "Itt rövidítve; a teljes a webes felületen van.", "Days": "Napok", "Delete": "Törlés", "Delete chat": "Csevegés törlése", + "Delete folder": "Mappa törlése", "Delete this chat?": "Törli ezt a csevegést?", "Delete this scheduled task?": "Törli ezt az ütemezett feladatot?", "Deny": "Elutasítás", @@ -1815,6 +1865,7 @@ const LEMBAS_I18N = { "Findings": "Megállapítások", "Finished %(n)s": "Kész: %(n)s", "Folder": "Mappa", + "Folder name": "Mappa neve", "Folder on %(device)s": "Mappa ezen: %(device)s", "Free space": "Szabad hely", "Hello": "Üdv", @@ -1856,15 +1907,19 @@ const LEMBAS_I18N = { "Models": "Modellek", "Models for scheduled tasks": "Modellek az ütemezett feladatokhoz", "More": "Több", + "Move to folder…": "Áthelyezés mappába…", "Never": "Soha", "New agent chat": "Új ügynökcsevegés", "New chat": "Új csevegés", + "New folder": "Új mappa", + "New folder…": "Új mappa…", "New scheduled task": "Új ütemezett feladat", "Next run": "Következő futás", "No archived chats.": "Nincs archivált beszélgetés.", "No chat yet. Start one with New chat.": "Még nincs beszélgetés. Indítson egyet az Új beszélgetés gombbal.", "No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.", "No files written in this chat yet.": "Ebben a csevegésben még nem íródott fájl.", + "No folder": "Nincs mappa", "No folder yet: add one under Settings, Project folders.": "Még nincs mappa: adjon hozzá egyet a Beállítások, Projektmappák alatt.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Még nincs mappa: az ügynök semmit nem nyithat meg. Adja hozzá egy projekt mappáját.", "No model is available to this account.": "Ennek a fióknak nem érhető el modell.", @@ -1900,6 +1955,8 @@ const LEMBAS_I18N = { "Pause": "Szüneteltetés", "Pause %(title)s": "%(title)s szüneteltetése", "Paused": "Szüneteltetve", + "Pin chat": "Beszélgetés kitűzése", + "Pinned chats": "Kitűzött beszélgetések", "Plan": "Csomag", "Plan — changes nothing": "Plan — semmit nem változtat", "Preview": "Előnézet", @@ -1918,6 +1975,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "%(dir)s eltávolítása", "Rename": "Átnevezés", "Rename chat": "Csevegés átnevezése", + "Rename folder": "Mappa átnevezése", "Rename…": "Átnevezés…", "Restore": "Visszaállítás", "Restore chat": "Csevegés visszaállítása", @@ -2040,6 +2098,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Kitűzés megszüntetése", "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", @@ -2109,6 +2168,7 @@ const LEMBAS_I18N = { "waiting for a slot": "szabad helyre vár", "window unknown": "az ablak ismeretlen", "working": "dolgozik", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "A(z) „%(name)s” és a benne lévő mappák törlődnek. A beszélgetéseik megmaradnak, vissza a listába.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” törlődik a szerverről, az üzeneteivel és fájljaival együtt. Ez nem vonható vissza.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” leáll, és az előzményei törlődnek. Az általa írt beszélgetések megmaradnak." }, @@ -2203,10 +2263,12 @@ const LEMBAS_I18N = { "Copy": "Copia", "Copy the last command and its output": "Copia l'ultimo comando e il suo output", "Counted by the model at the last reply; the categories are estimated.": "Contato dal modello all’ultima risposta; le categorie sono stime.", + "Create": "Crea", "Cut short here; the whole of it is in the web interface.": "Troncato qui; il resto è nell'interfaccia web.", "Days": "Giorni", "Delete": "Elimina", "Delete chat": "Elimina chat", + "Delete folder": "Elimina cartella", "Delete this chat?": "Eliminare questa chat?", "Delete this scheduled task?": "Eliminare questa attività pianificata?", "Deny": "Nega", @@ -2235,6 +2297,7 @@ const LEMBAS_I18N = { "Findings": "Risultati", "Finished %(n)s": "Terminate: %(n)s", "Folder": "Cartella", + "Folder name": "Nome della cartella", "Folder on %(device)s": "Cartella su %(device)s", "Free space": "Spazio libero", "Hello": "Ciao", @@ -2276,15 +2339,19 @@ const LEMBAS_I18N = { "Models": "Modelli", "Models for scheduled tasks": "Modelli per le attività pianificate", "More": "Altro", + "Move to folder…": "Sposta in una cartella…", "Never": "Mai", "New agent chat": "Nuova chat con agente", "New chat": "Nuova chat", + "New folder": "Nuova cartella", + "New folder…": "Nuova cartella…", "New scheduled task": "Nuova attività pianificata", "Next run": "Prossima esecuzione", "No archived chats.": "Nessuna chat archiviata.", "No chat yet. Start one with New chat.": "Ancora nessuna chat. Iniziane una con Nuova chat.", "No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.", "No files written in this chat yet.": "In questa chat non è ancora stato scritto alcun file.", + "No folder": "Nessuna cartella", "No folder yet: add one under Settings, Project folders.": "Nessuna cartella ancora: ne aggiunga una in Impostazioni, Cartelle di progetto.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Ancora nessuna cartella: l'agente non può aprire nulla. Aggiungi la cartella di un progetto.", "No model is available to this account.": "Nessun modello è disponibile per questo account.", @@ -2320,6 +2387,8 @@ const LEMBAS_I18N = { "Pause": "Metti in pausa", "Pause %(title)s": "Metti in pausa %(title)s", "Paused": "In pausa", + "Pin chat": "Fissa la chat", + "Pinned chats": "Chat fissate", "Plan": "Piano", "Plan — changes nothing": "Plan — non cambia nulla", "Preview": "Anteprima", @@ -2338,6 +2407,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Rimuovi %(dir)s", "Rename": "Rinomina", "Rename chat": "Rinomina chat", + "Rename folder": "Rinomina cartella", "Rename…": "Rinomina…", "Restore": "Ripristina", "Restore chat": "Ripristina chat", @@ -2460,6 +2530,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Togli la chat dalle fissate", "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", @@ -2529,6 +2600,7 @@ const LEMBAS_I18N = { "waiting for a slot": "in attesa di un posto", "window unknown": "finestra sconosciuta", "working": "al lavoro", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "«%(name)s» e le cartelle al suo interno vengono eliminate. Le loro chat restano, di nuovo nell’elenco.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "«%(title)s» viene eliminata dal server, con i suoi messaggi e file. Non si può annullare.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» si ferma e la sua cronologia viene eliminata. Le chat che ha scritto restano." }, @@ -2623,10 +2695,12 @@ const LEMBAS_I18N = { "Copy": "Kopiuj", "Copy the last command and its output": "Kopiuj ostatnie polecenie i jego wynik", "Counted by the model at the last reply; the categories are estimated.": "Policzone przez model przy ostatniej odpowiedzi; kategorie są szacunkowe.", + "Create": "Utwórz", "Cut short here; the whole of it is in the web interface.": "Tu skrócone; całość jest w interfejsie WWW.", "Days": "Dni", "Delete": "Usuń", "Delete chat": "Usuń czat", + "Delete folder": "Usuń folder", "Delete this chat?": "Usunąć ten czat?", "Delete this scheduled task?": "Usunąć to zaplanowane zadanie?", "Deny": "Odrzuć", @@ -2655,6 +2729,7 @@ const LEMBAS_I18N = { "Findings": "Ustalenia", "Finished %(n)s": "Zakończone: %(n)s", "Folder": "Folder", + "Folder name": "Nazwa folderu", "Folder on %(device)s": "Folder na %(device)s", "Free space": "Wolne miejsce", "Hello": "Cześć", @@ -2696,15 +2771,19 @@ const LEMBAS_I18N = { "Models": "Modele", "Models for scheduled tasks": "Modele dla zaplanowanych zadań", "More": "Więcej", + "Move to folder…": "Przenieś do folderu…", "Never": "Nigdy", "New agent chat": "Nowy czat z agentem", "New chat": "Nowy czat", + "New folder": "Nowy folder", + "New folder…": "Nowy folder…", "New scheduled task": "Nowe zaplanowane zadanie", "Next run": "Następne uruchomienie", "No archived chats.": "Brak zarchiwizowanych czatów.", "No chat yet. Start one with New chat.": "Jeszcze nie ma czatu. Zacznij go przyciskiem Nowy czat.", "No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.", "No files written in this chat yet.": "W tym czacie nie zapisano jeszcze żadnego pliku.", + "No folder": "Bez folderu", "No folder yet: add one under Settings, Project folders.": "Jeszcze nie ma folderu: dodaj go w Ustawieniach, Foldery projektów.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Nie ma jeszcze folderu: agent nie może nic otworzyć. Dodaj folder projektu.", "No model is available to this account.": "Dla tego konta nie jest dostępny żaden model.", @@ -2740,6 +2819,8 @@ const LEMBAS_I18N = { "Pause": "Wstrzymaj", "Pause %(title)s": "Wstrzymaj %(title)s", "Paused": "Wstrzymany", + "Pin chat": "Przypnij czat", + "Pinned chats": "Przypięte czaty", "Plan": "Plan", "Plan — changes nothing": "Plan — niczego nie zmienia", "Preview": "Podgląd", @@ -2758,6 +2839,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Usuń %(dir)s", "Rename": "Zmień nazwę", "Rename chat": "Zmień nazwę czatu", + "Rename folder": "Zmień nazwę folderu", "Rename…": "Zmień nazwę…", "Restore": "Przywróć", "Restore chat": "Przywróć czat", @@ -2880,6 +2962,7 @@ const LEMBAS_I18N = { "Try again": "Spróbuj ponownie", "Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu", "Type a folder…": "Wpisz folder…", + "Unpin chat": "Odepnij czat", "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", @@ -2949,6 +3032,7 @@ const LEMBAS_I18N = { "waiting for a slot": "czeka na wolne miejsce", "window unknown": "okno nieznane", "working": "pracuje", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s” i foldery w nim zostaną usunięte. Ich czaty zostaną, z powrotem na liście.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s” zostanie usunięty z serwera wraz z wiadomościami i plikami. Tego nie można cofnąć.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” zatrzyma się, a jego historia zniknie. Czaty, które napisało, zostaną." }, @@ -3043,10 +3127,12 @@ const LEMBAS_I18N = { "Copy": "Kopírovať", "Copy the last command and its output": "Skopírovať posledný príkaz a jeho výstup", "Counted by the model at the last reply; the categories are estimated.": "Spočítal model pri poslednej odpovedi; kategórie sú odhadnuté.", + "Create": "Vytvoriť", "Cut short here; the whole of it is in the web interface.": "Tu skrátené; celé je to vo webovom rozhraní.", "Days": "Dni", "Delete": "Zmazať", "Delete chat": "Zmazať konverzáciu", + "Delete folder": "Zmazať zložku", "Delete this chat?": "Zmazať túto konverzáciu?", "Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?", "Deny": "Odmietnuť", @@ -3075,6 +3161,7 @@ const LEMBAS_I18N = { "Findings": "Zistenia", "Finished %(n)s": "Dokončené: %(n)s", "Folder": "Priečinok", + "Folder name": "Názov zložky", "Folder on %(device)s": "Priečinok na zariadení %(device)s", "Free space": "Voľné miesto", "Hello": "Dobrý deň", @@ -3116,15 +3203,19 @@ const LEMBAS_I18N = { "Models": "Modely", "Models for scheduled tasks": "Modely pre naplánované úlohy", "More": "Viac", + "Move to folder…": "Presunúť do priečinka…", "Never": "Nikdy", "New agent chat": "Nová agentská konverzácia", "New chat": "Nová konverzácia", + "New folder": "Nová zložka", + "New folder…": "Nový priečinok…", "New scheduled task": "Nová naplánovaná úloha", "Next run": "Ďalšie spustenie", "No archived chats.": "Žiadne archivované konverzácie.", "No chat yet. Start one with New chat.": "Zatiaľ žiadna konverzácia. Začnite ju tlačidlom Nová konverzácia.", "No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.", "No files written in this chat yet.": "V tomto chate sa zatiaľ nezapísal žiadny súbor.", + "No folder": "Bez zložky", "No folder yet: add one under Settings, Project folders.": "Zatiaľ žiadny priečinok: pridajte ho v Nastaveniach, Priečinky projektov.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Zatiaľ žiadny priečinok: agent nemôže nič otvoriť. Pridajte priečinok projektu.", "No model is available to this account.": "Tomuto účtu nie je dostupný žiadny model.", @@ -3160,6 +3251,8 @@ const LEMBAS_I18N = { "Pause": "Pozastaviť", "Pause %(title)s": "Pozastaviť %(title)s", "Paused": "Pozastavené", + "Pin chat": "Pripnúť konverzáciu", + "Pinned chats": "Pripnuté konverzácie", "Plan": "Plán", "Plan — changes nothing": "Plan — nič nemení", "Preview": "Náhľad", @@ -3178,6 +3271,7 @@ const LEMBAS_I18N = { "Remove %(dir)s": "Odstrániť %(dir)s", "Rename": "Premenovať", "Rename chat": "Premenovať konverzáciu", + "Rename folder": "Premenovať zložku", "Rename…": "Premenovať…", "Restore": "Obnoviť", "Restore chat": "Obnoviť konverzáciu", @@ -3300,6 +3394,7 @@ const LEMBAS_I18N = { "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…", + "Unpin chat": "Odopnúť konverzáciu", "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", @@ -3369,6 +3464,7 @@ const LEMBAS_I18N = { "waiting for a slot": "čaká na voľný slot", "window unknown": "okno neznáme", "working": "pracuje", + "“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.": "„%(name)s“ a priečinky v ňom sa zmažú. Ich konverzácie zostanú, späť v zozname.", "“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.": "„%(title)s“ sa zmaže na serveri aj so svojimi správami a súbormi. Nedá sa to vrátiť.", "“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ sa zastaví a jej história zmizne. Konverzácie, ktoré napísala, zostanú." } diff --git a/ui/js/chat.js b/ui/js/chat.js index 16b1869..8f3b138 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, listed } from "./list.js" +import { folderName, listed, folderTree, newFolder } from "./list.js" import { unarchive } from "./archived.js" import { isProgress, progressLine, progressTicks } from "./progress.js" import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js" @@ -1033,6 +1033,10 @@ export function chatMenu(anchor, chat) { const base = ctx.status()?.server const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false const items = [{ label: t("Rename…"), run: () => renameChat(chat) }] + if (!chat.archived) { + items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) }) + items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) }) + } if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) }) if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) }) items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) }) @@ -1068,6 +1072,34 @@ export async function continueInTerminal(chat) { }) } +/** Where a chat can go: no folder, any of the account's, or a new one. */ +function moveMenu(anchor, chat) { + const here = chat.folder_id || "" + menu(anchor, [ + { label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) }, + ...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })), + null, + { + label: t("New folder…"), + run: async () => { + const id = await newFolder() + if (id) setChat(chat, { folder_id: id }) + }, + }, + ]) +} + +/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */ +async function setChat(chat, body) { + try { + const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body) + if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat + ctx.changed(r?.chat) + } catch (err) { + alertIn(why(err)) + } +} + async function renameChat(chat) { const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") }) if (!title || title === chat.title) return diff --git a/ui/js/list.js b/ui/js/list.js index 03435c7..b16fe2a 100644 --- a/ui/js/list.js +++ b/ui/js/list.js @@ -1,15 +1,23 @@ -// The sidebar's chats: agent chats and chats on the server in one list, newest first, under -// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works -// on another device of the account says so in its title. The search line above narrows the list -// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything. +// The sidebar's chats: agent chats and chats on the server in one list, newest first. The +// pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the +// server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark +// and its project folder's name; one that works on another device of the account says so in its +// title. The search line above narrows the list by title (and an agent chat by its folder) as it +// is typed, flat; Ctrl+K searches everything. +// +// Folders are made, renamed and deleted here (`POST/PATCH/DELETE /folders`); a chat is pinned and +// moved from its own menu (`PATCH /chats/{id}` `pinned`, `folder_id`). Deleting a folder never +// deletes a chat: its chats go back to the list. import { $, h, clear, append, icon, ICONS } from "./dom.js" -import { get, why } from "./bridge.js" +import { get, post, patch, del, why } from "./bridge.js" +import { menu, ask } from "./popup.js" -let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) } +let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat), note(text) } let rows = [] let more = false let query = "" +let folders = [] // { id, name, parent_id, collapsed, position } /** Lower case, without diacritics: "Príliš" is found by "prilis". */ function fold(text) { @@ -22,9 +30,10 @@ function fold(text) { /** The list again, from the server. */ export async function refreshList() { try { - const r = await get("/chats?limit=50") + const [r, f] = await Promise.all([get("/chats?limit=50"), get("/folders").catch(() => ({ folders: [] }))]) rows = r.chats || [] more = !!r.more + folders = f.folders || [] render() } catch (err) { clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) })) @@ -77,6 +86,108 @@ function row(c) { return h("li", null, b) } +/** A folder's head: it folds and unfolds; its menu renames it, makes one inside, deletes it. */ +function folderHead(f, depth, count) { + const open = !f.collapsed + const head = h( + "button", + { + type: "button", + class: "folderrow", + dataset: { folder: f.id }, + style: depth ? `--depth: ${depth}` : null, + "aria-expanded": String(open), + title: f.name, + onclick: () => foldFolder(f, !f.collapsed), + }, + h("span", { class: "folderrow__chev" }, icon(open ? ICONS.chevronDown : ICONS.chevron)), + h("span", { class: "folderrow__icon" }, icon(ICONS.folder)), + h("span", { class: "folderrow__name", text: f.name }), + h("span", { class: "folderrow__count", text: count ? String(count) : "" }), + ) + head.addEventListener("contextmenu", (e) => { + e.preventDefault() + folderMenu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : head, f) + }) + const more = h("button", { type: "button", class: "folderrow__more", "aria-label": t("More"), title: t("More"), onclick: (e) => folderMenu(e.currentTarget, f) }, icon(ICONS.more)) + return h("li", { class: "list__folder" }, head, more) +} + +async function foldFolder(f, collapsed) { + f.collapsed = collapsed + render() + try { + await patch(`/folders/${encodeURIComponent(f.id)}`, { collapsed }) + } catch { + // Folding is the sidebar's own business: drawn as asked even where the server did not keep it. + } +} + +function folderMenu(anchor, f) { + menu(anchor, [ + { label: t("Rename folder"), run: () => renameFolder(f) }, + { label: t("New folder"), run: () => newFolder(f.id) }, + null, + { label: t("Delete folder"), danger: true, run: () => deleteFolder(f) }, + ]) +} + +/** A new folder, at the top level or inside `parent`: its id once made, else "". */ +export async function newFolder(parent = "") { + const name = await ask({ title: t("New folder"), text: t("Folder name"), value: "", ok: t("Create"), cancel: t("Cancel") }) + if (!name) return "" + try { + const r = await post("/folders", parent ? { name, parent_id: parent } : { name }) + await refreshList() + return r.folder?.id || "" + } catch (err) { + ctx.note?.(why(err)) + return "" + } +} + +async function renameFolder(f) { + const name = await ask({ title: t("Rename folder"), text: t("Folder name"), value: f.name, ok: t("Rename"), cancel: t("Cancel") }) + if (!name || name === f.name) return + try { + await patch(`/folders/${encodeURIComponent(f.id)}`, { name }) + await refreshList() + } catch (err) { + ctx.note?.(why(err)) + } +} + +async function deleteFolder(f) { + const ok = await ask({ title: t("Delete folder"), text: t("“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", { name: f.name }), ok: t("Delete"), cancel: t("Cancel"), danger: true }) + if (!ok) return + try { + await del(`/folders/${encodeURIComponent(f.id)}`) + await refreshList() + } catch (err) { + ctx.note?.(why(err)) + } +} + +/** The account's chat folders, as the list last read them. */ +export function listedFolders() { + return folders +} + +/** The folders as a tree, depth first, each with its depth: what a menu of them lists. */ +export function folderTree() { + const out = [] + const known = new Set(folders.map((f) => f.id)) + const walk = (parent, depth) => { + const kids = folders.filter((f) => (f.parent_id && known.has(f.parent_id) ? f.parent_id : null) === parent).sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name)) + for (const f of kids) { + out.push({ folder: f, depth }) + walk(f.id, depth + 1) + } + } + walk(null, 0) + return out +} + function isToday(stamp) { const d = new Date(stamp) if (Number.isNaN(d.getTime())) return false @@ -98,8 +209,44 @@ function render() { list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") })) return } - const today = visible.filter((r) => isToday(r.updated_at)) - const earlier = visible.filter((r) => !isToday(r.updated_at)) + // Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest. + let rest = visible + if (!words.length) { + const pinned = visible.filter((r) => r.pinned) + if (pinned.length) append(list, [h("li", { class: "list__head", text: t("Pinned chats") }), pinned.map(row)]) + const known = new Set(folders.map((f) => f.id)) + const filed = (r) => !r.pinned && r.folder_id && known.has(r.folder_id) + const tree = folderTree() + if (tree.length) { + const inside = (id) => { + // A folder's count: its own chats and those of the folders inside it. + const ids = new Set([id]) + for (const { folder } of tree) if (folder.parent_id && ids.has(folder.parent_id)) ids.add(folder.id) + return visible.filter((r) => filed(r) && ids.has(r.folder_id)).length + } + const hidden = new Set() // folders under a folded one + for (const { folder, depth } of tree) { + if (folder.parent_id && hidden.has(folder.parent_id)) { + hidden.add(folder.id) + continue + } + list.append(folderHead(folder, depth, inside(folder.id))) + if (folder.collapsed) { + hidden.add(folder.id) + continue + } + for (const r of visible.filter((x) => filed(x) && x.folder_id === folder.id)) { + const li = row(r) + li.firstChild.style.setProperty("--depth", String(depth + 1)) + li.firstChild.classList.add("is-filed") + list.append(li) + } + } + } + rest = visible.filter((r) => !r.pinned && !filed(r)) + } + const today = rest.filter((r) => isToday(r.updated_at)) + const earlier = rest.filter((r) => !isToday(r.updated_at)) if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)]) if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)]) if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore }))) diff --git a/ui/js/main.js b/ui/js/main.js index edaf79b..29da112 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -267,6 +267,10 @@ async function boot() { showChat(id) }, menu: (anchor, c) => chatMenu(anchor, c), + note: (text) => { + $("thread-note").textContent = text + $("thread-note").hidden = !text + }, }) initChat({ status: () => status,