From 04736db05704b2f8aff3417d642ae0f28b1e8df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 13:19:44 +0000 Subject: [PATCH] Language and theme follow the account: PATCH /me for the theme, /me's language by default The server's /api/v2 now has PATCH /me {language, theme} and /me lists its languages and themes. The theme is the account's and choosing one saves it there (a refusal is said); a server whose /me lists no themes keeps the earlier choice in the app. The language is the account's unless Settings chose this computer's or another (kept in the app, as the phone does); a change of the account's opens the window again in it, once. Fixtures re-pinned to the server's dev (f7f2ff2), with the new /api/v2 parity ones. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- scripts/fetch-fixtures.sh | 4 +- scripts/gen-i18n.py | 44 ++++++-- tests/ui/fixtures/SOURCE | 2 +- tests/ui/fixtures/chat.json | 28 +++++ tests/ui/fixtures/chats_listed.json | 29 ++++++ tests/ui/fixtures/event_schedules.json | 109 ++++++++++++++++++++ tests/ui/fixtures/files_listing.json | 22 ++++ tests/ui/fixtures/folder.json | 10 ++ tests/ui/fixtures/folders.json | 20 ++++ tests/ui/fixtures/me.json | 57 ++++++++++ tests/ui/fixtures/memories.json | 21 ++++ tests/ui/fixtures/memory.json | 13 +++ tests/ui/fixtures/push_schedule.json | 34 ++++++ tests/ui/fixtures/push_schedule_failed.json | 37 +++++++ tests/ui/fixtures/schedule_run.json | 52 ++++++++++ tests/ui/fixtures/scope.json | 26 +++++ tests/ui/fixtures/search.json | 69 +++++++++++++ tests/ui/run.mjs | 76 ++++++++++---- ui/i18n.js | 42 ++++++-- ui/js/look.js | 108 +++++++++++++------ ui/js/main.js | 4 +- ui/js/settings.js | 53 ++++++++-- 23 files changed, 783 insertions(+), 84 deletions(-) create mode 100644 tests/ui/fixtures/chat.json create mode 100644 tests/ui/fixtures/chats_listed.json create mode 100644 tests/ui/fixtures/event_schedules.json create mode 100644 tests/ui/fixtures/files_listing.json create mode 100644 tests/ui/fixtures/folder.json create mode 100644 tests/ui/fixtures/folders.json create mode 100644 tests/ui/fixtures/me.json create mode 100644 tests/ui/fixtures/memories.json create mode 100644 tests/ui/fixtures/memory.json create mode 100644 tests/ui/fixtures/push_schedule.json create mode 100644 tests/ui/fixtures/push_schedule_failed.json create mode 100644 tests/ui/fixtures/schedule_run.json create mode 100644 tests/ui/fixtures/scope.json create mode 100644 tests/ui/fixtures/search.json diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9b86a..9d1bb19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ All notable changes to LLeMbas Desktop. The format follows 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. -- **Language and theme in Settings.** Under Appearance: the language (this computer's, or any of - the app's nine; the window opens again in it) and the theme (the account's, or Moria or Shire). - Both are kept in the app on this computer; the account's own stay as they are. +- **Language and theme in Settings.** Under Appearance. The language is the account's, as the + 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. ### Fixed diff --git a/scripts/fetch-fixtures.sh b/scripts/fetch-fixtures.sh index 5468f93..75e0e82 100755 --- a/scripts/fetch-fixtures.sh +++ b/scripts/fetch-fixtures.sh @@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}" FROM="tests/fixtures/api_v2" OUT="tests/ui/fixtures" FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json -event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json" +event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json +me.json folders.json folder.json search.json memories.json memory.json scope.json push_schedule.json push_schedule_failed.json +event_schedules.json schedule_run.json chat.json chats_listed.json files_listing.json" [ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh " >&2; exit 2; } commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \ diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 6d37155..8c6c8d1 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -469,6 +469,8 @@ KEYS = ( "The account's (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.", "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.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -710,6 +712,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Podle účtu (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Uloženo v této aplikaci na tomto počítači. Okno se znovu otevře ve zvoleném jazyce.", "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.", }, "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.", @@ -946,6 +950,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Wie das Konto (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "In dieser App auf diesem Computer gespeichert. Das Fenster öffnet sich in der gewählten Sprache neu.", "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.", }, "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.", @@ -1182,6 +1188,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "El de la cuenta (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Se guarda en esta aplicación en este equipo. La ventana se vuelve a abrir en el idioma elegido.", "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.", }, "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.", @@ -1418,6 +1426,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Celui du compte (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Enregistré dans cette application sur cet ordinateur. La fenêtre se rouvre dans la langue choisie.", "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.", }, "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.", @@ -1654,6 +1664,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "A fióké (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Ebben az alkalmazásban, ezen a számítógépen tárolva. Az ablak a választott nyelven nyílik meg újra.", "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.", }, "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.", @@ -1890,6 +1902,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Quello dell’account (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Salvato in questa app su questo computer. La finestra si riapre nella lingua scelta.", "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.", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2126,6 +2140,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Jak na koncie (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Zapisane w tej aplikacji na tym komputerze. Okno otworzy się ponownie w wybranym języku.", "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ą.", }, "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.", @@ -2362,6 +2378,8 @@ OWN: dict[str, dict[str, str]] = { "The account's (%(theme)s)": "Podľa účtu (%(theme)s)", "Kept in this app on this computer. The window opens again in the language chosen.": "Uložené v tejto aplikácii na tomto počítači. Okno sa znova otvorí vo zvolenom jazyku.", "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.", }, } @@ -2426,9 +2444,10 @@ def main() -> None: "// script's own table. Do not edit; run the script.\n" "//\n" "// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n" - "// missing entry is the English. The language is the one chosen in Settings (kept in\n" - "// localStorage as `lembas.language`), else the system's (navigator.language), English\n" - "// unless it is one of these. Elements marked `data-i18n` (their text) and\n" + "// missing entry is the English. The language is the account's (kept from /me as\n" + "// `lembas.account-language`) unless Settings chose this computer's or another\n" + "// (`lembas.language`); the system's is navigator.language. English unless it is one of\n" + "// these. Elements marked `data-i18n` (their text) and\n" "// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n" "// are translated once the page is parsed; this script is deferred, before the page's.\n" '"use strict"\n' @@ -2443,12 +2462,23 @@ def main() -> None: ' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n' ' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n' "})()\n" - "const LEMBAS_CHOSEN_LANG = (() => {\n" - ' let code = ""\n' + "// Settings' choice: \"\" for the account's language (as /me last said it), \"system\", or a code.\n" + "const LEMBAS_CHOICE = (() => {\n" " try {\n" - ' code = localStorage.getItem("lembas.language") || ""\n' + ' return localStorage.getItem("lembas.language") || ""\n' + " } catch {\n" + ' return ""\n' + " }\n" + "})()\n" + "const LEMBAS_CHOSEN_LANG = (() => {\n" + " const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code)\n" + ' if (LEMBAS_CHOICE === "system") return ""\n' + " if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE\n" + ' let account = ""\n' + " try {\n" + ' account = localStorage.getItem("lembas.account-language") || ""\n' " } catch {}\n" - ' return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""\n' + ' return has(account) ? account : ""\n' "})()\n" "const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n" "document.documentElement.lang = LEMBAS_LANG\n" diff --git a/tests/ui/fixtures/SOURCE b/tests/ui/fixtures/SOURCE index 1a8662f..0d68d14 100644 --- a/tests/ui/fixtures/SOURCE +++ b/tests/ui/fixtures/SOURCE @@ -1 +1 @@ -LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2 +LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2 diff --git a/tests/ui/fixtures/chat.json b/tests/ui/fixtures/chat.json new file mode 100644 index 0000000..ecb2e02 --- /dev/null +++ b/tests/ui/fixtures/chat.json @@ -0,0 +1,28 @@ +{ + "id": "chat1", + "kind": "chat", + "title": "Waybread", + "model": "qwen", + "folder_id": null, + "pinned": false, + "archived": false, + "temporary": false, + "unread": true, + "running": false, + "mode": "", + "effort": "low", + "device": null, + "device_session_id": null, + "parent_chat_id": null, + "agents": { + "min": 1, + "max": 4 + }, + "subagents": { + "offered": true, + "named_only": false, + "cap": 32 + }, + "created_at": "2026-01-02T03:04:05+00:00", + "updated_at": "2026-01-02T03:04:05+00:00" +} diff --git a/tests/ui/fixtures/chats_listed.json b/tests/ui/fixtures/chats_listed.json new file mode 100644 index 0000000..e1e6467 --- /dev/null +++ b/tests/ui/fixtures/chats_listed.json @@ -0,0 +1,29 @@ +{ + "chats": [ + { + "id": "chat1", + "kind": "chat", + "title": "Waybread", + "model": "qwen", + "folder_id": null, + "pinned": false, + "archived": false, + "temporary": false, + "unread": true, + "running": false, + "mode": "", + "effort": "low", + "device": null, + "device_session_id": null, + "parent_chat_id": null, + "agents": { + "min": 1, + "max": 4 + }, + "subagents": null, + "created_at": "2026-01-02T03:04:05+00:00", + "updated_at": "2026-01-02T03:04:05+00:00" + } + ], + "more": false +} diff --git a/tests/ui/fixtures/event_schedules.json b/tests/ui/fixtures/event_schedules.json new file mode 100644 index 0000000..19b480d --- /dev/null +++ b/tests/ui/fixtures/event_schedules.json @@ -0,0 +1,109 @@ +{ + "version": "d2f307bf1c7b8d6b", + "schedules": [ + { + "id": "sched1", + "title": "Waybread check", + "instruction": "Check the waybread is wrapped.", + "request": "every second Monday at 7:30", + "rule": { + "at": { + "nth": [ + { + "n": 2, + "weekday": 0 + } + ], + "times": [ + "07:30" + ] + } + }, + "description": "On the second Monday of each month at 07:30", + "timezone": "UTC", + "enabled": true, + "next_run": "2026-01-12T07:30:00+00:00", + "next_runs": [ + "2026-01-12T07:30:00+00:00", + "2026-02-09T07:30:00+00:00", + "2026-03-09T07:30:00+00:00", + "2026-04-13T07:30:00+00:00", + "2026-05-11T07:30:00+00:00" + ], + "target": "chat", + "mode": "fresh", + "notify": "matters", + "catch_up": "one", + "grace_minutes": null, + "model": "qwen", + "effort": "high", + "agents": null, + "tools": [ + "files", + "recall", + "search", + "fetch", + "memory", + "skills", + "plan" + ], + "origin": "user", + "chat_id": "chat1", + "fired_count": 3, + "failure_streak": 0, + "watch": null, + "last_error": null, + "last_run": { + "id": "run1", + "schedule_id": "sched1", + "status": "ok", + "timing": "late", + "missed": 0, + "due_at": "2026-01-02T03:04:05+00:00", + "started_at": "2026-01-02T03:04:05+00:00", + "finished_at": "2026-01-02T03:04:05+00:00", + "waiting_for": null, + "retry_at": null, + "progress": null, + "attempts": 1, + "model": { + "id": "gpt-oss", + "name": "GPT-OSS", + "reason": "switched", + "switched_from": "Qwen" + }, + "tokens": { + "prompt": 1200, + "completion": 340 + }, + "cost": null, + "chat_id": "chat1", + "message_id": "msg2", + "report_path": null, + "preview": "All the waybread is wrapped.", + "error": null, + "notified": true, + "notification": { + "title": "Waybread check", + "body": "All the waybread is wrapped.", + "public": { + "title": "Waybread check", + "body": "Finished" + }, + "actions": [ + "open", + "run_again" + ], + "labels": { + "open": "Open", + "run_again": "Run again" + }, + "ask": "" + }, + "watch": null + }, + "running": false, + "created_at": "2026-01-02T03:04:05+00:00" + } + ] +} diff --git a/tests/ui/fixtures/files_listing.json b/tests/ui/fixtures/files_listing.json new file mode 100644 index 0000000..c9f0f7d --- /dev/null +++ b/tests/ui/fixtures/files_listing.json @@ -0,0 +1,22 @@ +{ + "path": "/home/frodo", + "parent": "/home", + "entries": [ + { + "name": "recipes", + "path": "/home/frodo/recipes", + "kind": "dir", + "size": 0, + "mime": "", + "updated_at": "2026-01-02T03:04:05+00:00" + }, + { + "name": "lembas.md", + "path": "/home/frodo/lembas.md", + "kind": "file", + "size": 27, + "mime": "text/markdown", + "updated_at": "2026-01-02T03:04:05+00:00" + } + ] +} diff --git a/tests/ui/fixtures/folder.json b/tests/ui/fixtures/folder.json new file mode 100644 index 0000000..996a8b2 --- /dev/null +++ b/tests/ui/fixtures/folder.json @@ -0,0 +1,10 @@ +{ + "folder": { + "id": "fold1", + "name": "Waybread", + "parent_id": null, + "kind": "", + "position": 0, + "collapsed": false + } +} diff --git a/tests/ui/fixtures/folders.json b/tests/ui/fixtures/folders.json new file mode 100644 index 0000000..1200d9d --- /dev/null +++ b/tests/ui/fixtures/folders.json @@ -0,0 +1,20 @@ +{ + "folders": [ + { + "id": "fold1", + "name": "Waybread", + "parent_id": null, + "kind": "", + "position": 0, + "collapsed": false + }, + { + "id": "fold2", + "name": "Recipes", + "parent_id": "fold1", + "kind": "chat", + "position": 1, + "collapsed": true + } + ] +} diff --git a/tests/ui/fixtures/me.json b/tests/ui/fixtures/me.json new file mode 100644 index 0000000..aaa535b --- /dev/null +++ b/tests/ui/fixtures/me.json @@ -0,0 +1,57 @@ +{ + "api": 2, + "version": "2.0.0", + "account": { + "id": "user1", + "name": "Frodo", + "email": "frodo@shire.test" + }, + "language": "sk", + "theme": "shire", + "default_model": "qwen", + "permissions": { + "chat.create": true, + "chat.delete": false, + "chat.model_select": true, + "chat.system_prompt": true, + "chat.params": true, + "files.upload": true, + "tools.agent": true, + "audio.transcribe": true, + "audio.listen": true + }, + "services": { + "stt": true, + "tts": false, + "search": true, + "fetch": true, + "images": false + }, + "scopes": [ + "app", + "models" + ], + "language_setting": "sk", + "languages": [ + { + "id": "en", + "name": "English" + }, + { + "id": "sk", + "name": "Slovenčina" + } + ], + "themes": [ + { + "id": "moria", + "label": "Moria", + "scheme": "dark" + }, + { + "id": "shire", + "label": "Shire", + "scheme": "light" + } + ] +} diff --git a/tests/ui/fixtures/memories.json b/tests/ui/fixtures/memories.json new file mode 100644 index 0000000..c4c08b2 --- /dev/null +++ b/tests/ui/fixtures/memories.json @@ -0,0 +1,21 @@ +{ + "memories": [ + { + "id": "mem1", + "content": "Prefers lembas warm.", + "author": "user", + "data_group": { + "id": "default", + "name": "Default" + }, + "created_at": "2026-01-02T03:04:05+00:00", + "updated_at": "2026-01-02T03:04:05+00:00" + } + ], + "groups": [ + { + "id": "default", + "name": "Default" + } + ] +} diff --git a/tests/ui/fixtures/memory.json b/tests/ui/fixtures/memory.json new file mode 100644 index 0000000..9206c0a --- /dev/null +++ b/tests/ui/fixtures/memory.json @@ -0,0 +1,13 @@ +{ + "memory": { + "id": "mem1", + "content": "Prefers lembas warm.", + "author": "user", + "data_group": { + "id": "default", + "name": "Default" + }, + "created_at": "2026-01-02T03:04:05+00:00", + "updated_at": "2026-01-02T03:04:05+00:00" + } +} diff --git a/tests/ui/fixtures/push_schedule.json b/tests/ui/fixtures/push_schedule.json new file mode 100644 index 0000000..085dfab --- /dev/null +++ b/tests/ui/fixtures/push_schedule.json @@ -0,0 +1,34 @@ +{ + "schedule_id": "sched1", + "run_id": "run1", + "chat_id": "chat1", + "status": "ok", + "visibility": "private", + "actions": [ + "open", + "run_again" + ], + "ask": "", + "notification": { + "title": "Waybread check", + "body": "All the waybread is wrapped.", + "public": { + "title": "Waybread check", + "body": "Finished" + }, + "actions": [ + "open", + "run_again" + ], + "labels": { + "open": "Open", + "run_again": "Run again" + }, + "ask": "" + }, + "pause_label": "Pause", + "title": "LLeMbas", + "body": "A scheduled task has finished.", + "url": "/scheduled/sched1?run=run1", + "kind": "schedule" +} diff --git a/tests/ui/fixtures/push_schedule_failed.json b/tests/ui/fixtures/push_schedule_failed.json new file mode 100644 index 0000000..3f8bbd3 --- /dev/null +++ b/tests/ui/fixtures/push_schedule_failed.json @@ -0,0 +1,37 @@ +{ + "schedule_id": "sched1", + "run_id": "run3", + "chat_id": "chat1", + "status": "failed", + "visibility": "private", + "actions": [ + "open", + "run_again", + "pause" + ], + "ask": "pause", + "notification": { + "title": "Waybread check", + "body": "Failed 3 times in a row. Pause it?", + "public": { + "title": "Waybread check", + "body": "Failed" + }, + "actions": [ + "open", + "run_again", + "pause" + ], + "labels": { + "open": "Open", + "run_again": "Run again", + "pause": "Pause" + }, + "ask": "pause" + }, + "pause_label": "Pause", + "title": "LLeMbas", + "body": "A scheduled task failed 3 times in a row. Pause it?", + "url": "/scheduled/sched1?run=run3", + "kind": "schedule_failed" +} diff --git a/tests/ui/fixtures/schedule_run.json b/tests/ui/fixtures/schedule_run.json new file mode 100644 index 0000000..c9051ec --- /dev/null +++ b/tests/ui/fixtures/schedule_run.json @@ -0,0 +1,52 @@ +{ + "run": { + "id": "run1", + "schedule_id": "sched1", + "status": "ok", + "timing": "late", + "missed": 0, + "due_at": "2026-01-02T03:04:05+00:00", + "started_at": "2026-01-02T03:04:05+00:00", + "finished_at": "2026-01-02T03:04:05+00:00", + "waiting_for": null, + "retry_at": null, + "progress": null, + "attempts": 1, + "model": { + "id": "gpt-oss", + "name": "GPT-OSS", + "reason": "switched", + "switched_from": "Qwen" + }, + "tokens": { + "prompt": 1200, + "completion": 340 + }, + "cost": null, + "chat_id": "chat1", + "message_id": "msg2", + "report_path": null, + "preview": "All the waybread is wrapped.", + "error": null, + "notified": true, + "notification": { + "title": "Waybread check", + "body": "All the waybread is wrapped.", + "public": { + "title": "Waybread check", + "body": "Finished" + }, + "actions": [ + "open", + "run_again" + ], + "labels": { + "open": "Open", + "run_again": "Run again" + }, + "ask": "" + }, + "watch": null + }, + "text": "All the waybread is *wrapped*." +} diff --git a/tests/ui/fixtures/scope.json b/tests/ui/fixtures/scope.json new file mode 100644 index 0000000..a599e64 --- /dev/null +++ b/tests/ui/fixtures/scope.json @@ -0,0 +1,26 @@ +{ + "scope": { + "families": [ + { + "gate": "notes", + "label": "Notes", + "on": true + }, + { + "gate": "web", + "label": "Web search", + "on": false + } + ], + "skills": [ + { + "name": "baking", + "description": "Bake waybread.", + "on": true + } + ], + "allow": [ + "bash:ls *" + ] + } +} diff --git a/tests/ui/fixtures/search.json b/tests/ui/fixtures/search.json new file mode 100644 index 0000000..9c4e46e --- /dev/null +++ b/tests/ui/fixtures/search.json @@ -0,0 +1,69 @@ +{ + "q": "lembas", + "in": "all", + "offered": [ + "chats", + "files", + "memories" + ], + "results": [ + { + "kind": "chats", + "more": true, + "hits": [ + { + "kind": "chat", + "title": "Waybread", + "snippet": "…how long does lembas keep & travel?", + "marks": [ + [ + 15, + 21 + ] + ], + "archived": false, + "id": "chat1", + "agent": false + } + ] + }, + { + "kind": "files", + "more": false, + "hits": [ + { + "kind": "file", + "title": "bread.md", + "snippet": "/home/frodo/bread.md — Lembas recipe", + "marks": [ + [ + 23, + 29 + ] + ], + "archived": false, + "path": "/home/frodo/bread.md" + } + ] + }, + { + "kind": "memories", + "more": false, + "hits": [ + { + "kind": "memory", + "id": "mem1", + "title": "", + "snippet": "Prefers lembas warm.", + "marks": [ + [ + 8, + 14 + ] + ], + "archived": false + } + ] + } + ] +} diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 765cbfc..72bb7ac 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -994,57 +994,89 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th assert.deepEqual(p.errors, []) }) -// E5: the language and the theme, chosen in Settings and kept in this app. +// E5: the language and the theme. The server's /me (fixture me.json) offers its themes and takes +// one on PATCH /me; its language is the app's unless Settings chose this computer's or another. + +const ME = fixture("me") +const meAs = (over = {}) => ({ ...ME, account: { id: "u1", name: "Ada Example", email: "ada@example.org" }, permissions: {}, ...over }) async function openSettings(p) { await p.click("#settings-btn") await p.waitForSelector("#set-look") } -test("E5: the theme is the account's until one is chosen here; chosen, it is drawn at once and kept", async () => { +test("E5: the theme is the account's, as /me offers them; chosen, PATCH /me saves it and it is drawn at once", async () => { + const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": meAs({ language: "en", theme: "moria" }) }), streams: {} }) + await p.waitForFunction(() => document.documentElement.dataset.theme === "shire") + await openSettings(p) + assert.equal(await p.inputValue("#set-theme"), "shire") + assert.deepEqual(await p.$$eval("#set-theme option", (o) => o.map((x) => x.textContent)), ["Moria — dark", "Shire — light"]) + await p.selectOption("#set-theme", "moria") + await p.waitForFunction(() => document.documentElement.dataset.theme === "moria") + const [saved] = await p.evaluate(() => window.__mock.api("PATCH", "/me")) + assert.deepEqual(saved.args.body, { theme: "moria" }) + const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name)) + assert.equal(painted.at(-1), "moria", "the window is painted in it too") + assert.equal(await p.inputValue("#set-theme"), "moria") + assert.deepEqual(p.errors, []) +}) + +test("E5: a theme the server refuses is said, and the look stays", async () => { + const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": { __error: { status: 400, detail: "That theme is not offered." } } }), streams: {} }) + await openSettings(p) + await p.selectOption("#set-theme", "moria") + await p.waitForSelector("#set-look [role=alert]") + assert.match(await p.textContent("#set-look [role=alert]"), /not offered/) + assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire") + assert.equal(await p.inputValue("#set-theme"), "shire") + assert.deepEqual(p.errors, []) +}) + +test("E5: a server that cannot be told: the theme chosen is kept in the app, over the account's", async () => { const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} }) await p.waitForFunction(() => document.documentElement.dataset.theme === "shire") await openSettings(p) assert.equal(await p.inputValue("#set-theme"), "") assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/) await p.selectOption("#set-theme", "moria") - assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") - const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name)) - assert.equal(painted.at(-1), "moria", "the window is painted in it too") - // Opened again: still Moria, over the account's Shire. + await p.waitForFunction(() => document.documentElement.dataset.theme === "moria") + assert.equal(await p.evaluate(() => window.__mock.api("PATCH", "/me").length), 0, "nothing sent") await p.reload() await p.waitForSelector("#shell:not([hidden])") await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme")) assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") await openSettings(p) - assert.equal(await p.inputValue("#set-theme"), "moria") await p.selectOption("#set-theme", "") - assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire", "back to the account's") + await p.waitForFunction(() => document.documentElement.dataset.theme === "shire") assert.deepEqual(p.errors, []) }) -test("E5: a language chosen here opens the window again in it, the shells let go first; the system's again undoes it", async () => { - const p = await page(b3()) - assert.equal(await p.evaluate(() => document.documentElement.lang), "en") +test("E5: the account's language is the app's; this computer's or another, chosen, opens the window again in it, the shells let go first", async () => { + const p = await page({ ...b3(), routes: { ...b3().routes, "GET /me": meAs({ language: "de", theme: "moria" }) } }) + // The account says German: the window opens again in it, once. + await p.waitForFunction(() => document.documentElement.lang === "de", null, { timeout: 15000 }) + await p.waitForSelector("#shell:not([hidden])") + assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen") await openChatRow(p, "chat2") await p.click('#chat-tools [data-terminal-toggle]') await p.waitForFunction(() => window.__mock.terminals().length === 1) // What the page let go of before it loaded again, kept across the load. await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd))))) await openSettings(p) - assert.match(await p.textContent("#set-language option[value='']"), /English/) - await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "de")]) - await p.waitForSelector("#shell:not([hidden])") - assert.equal(await p.evaluate(() => document.documentElement.lang), "de") - assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen") - const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]")) - assert.ok(before.includes("terminal_close"), "the shell was let go") - await openSettings(p) - assert.equal(await p.inputValue("#set-language"), "de") - assert.match(await p.textContent("#set-look"), /Sprache/) - await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "")]) + assert.equal(await p.inputValue("#set-language"), "") + assert.match(await p.textContent("#set-language option[value='']"), /Deutsch/) + await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "system")]) await p.waitForSelector("#shell:not([hidden])") assert.equal(await p.evaluate(() => document.documentElement.lang), "en") + const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]")) + assert.ok(before.includes("terminal_close"), "the shell was let go") + await p.waitForTimeout(300) + assert.equal(await p.evaluate(() => document.documentElement.lang), "en", "the account's German does not take it back") + await openSettings(p) + assert.equal(await p.inputValue("#set-language"), "system") + await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "fr")]) + await p.waitForSelector("#shell:not([hidden])") + assert.equal(await p.evaluate(() => document.documentElement.lang), "fr") assert.deepEqual(p.errors, []) }) diff --git a/ui/i18n.js b/ui/i18n.js index b4ca9a5..c97a220 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -3,9 +3,10 @@ // script's own table. Do not edit; run the script. // // `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a -// missing entry is the English. The language is the one chosen in Settings (kept in -// localStorage as `lembas.language`), else the system's (navigator.language), English -// unless it is one of these. Elements marked `data-i18n` (their text) and +// missing entry is the English. The language is the account's (kept from /me as +// `lembas.account-language`) unless Settings chose this computer's or another +// (`lembas.language`); the system's is navigator.language. English unless it is one of +// these. Elements marked `data-i18n` (their text) and // `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute) // are translated once the page is parsed; this script is deferred, before the page's. "use strict" @@ -253,6 +254,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}", "Runs": "Spuštění", "Save": "Uložit", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Uložením se odpověď spustí znovu; vše po této zprávě se zahodí.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Řekněte, co se má stát a kdy — „každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení“ — a server to udělá za vás.", "Scheduled": "Naplánováno", @@ -298,6 +300,7 @@ const LEMBAS_I18N = { "Tell me": "Upozornit mě", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde", "Terminal": "Terminál", + "The account's (%(language)s)": "Podle účtu (%(language)s)", "The account's (%(theme)s)": "Podle účtu (%(theme)s)", "The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:", "The agent asks:": "Agent se ptá:", @@ -671,6 +674,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}", "Runs": "Läufe", "Save": "Speichern", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Speichern startet die Antwort neu; alles nach dieser Nachricht wird verworfen.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Sagen Sie, was wann geschehen soll — „jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen“ — und der Server erledigt es für Sie.", "Scheduled": "Geplant", @@ -716,6 +720,7 @@ const LEMBAS_I18N = { "Tell me": "Benachrichtigen", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin", "Terminal": "Terminal", + "The account's (%(language)s)": "Wie das Konto (%(language)s)", "The account's (%(theme)s)": "Wie das Konto (%(theme)s)", "The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:", "The agent asks:": "Der Agent fragt:", @@ -1089,6 +1094,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}", "Runs": "Ejecuciones", "Save": "Guardar", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Al guardar, la respuesta empieza de nuevo; se descarta todo lo que hay después de este mensaje.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Diga qué debe ocurrir y cuándo —«cada día laborable a las 8:00, resume las noticias de mi lista de lectura»— y el servidor lo hará por usted.", "Scheduled": "Programado", @@ -1134,6 +1140,7 @@ const LEMBAS_I18N = { "Tell me": "Avisarme", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte", "Terminal": "Terminal", + "The account's (%(language)s)": "El de la cuenta (%(language)s)", "The account's (%(theme)s)": "El de la cuenta (%(theme)s)", "The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:", "The agent asks:": "El agente pregunta:", @@ -1507,6 +1514,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}", "Runs": "Exécutions", "Save": "Enregistrer", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Enregistrer relance la réponse ; tout ce qui suit ce message est supprimé.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Dites ce qui doit se passer et quand — « chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture » — et le serveur s’en charge pour vous.", "Scheduled": "Planifié", @@ -1552,6 +1560,7 @@ const LEMBAS_I18N = { "Tell me": "Me prévenir", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs", "Terminal": "Terminal", + "The account's (%(language)s)": "Celle du compte (%(language)s)", "The account's (%(theme)s)": "Celui du compte (%(theme)s)", "The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :", "The agent asks:": "L'agent demande :", @@ -1925,6 +1934,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}", "Runs": "Futások", "Save": "Menté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.", "Saving starts the reply again; everything after this message is dropped.": "A mentés újraindítja a választ; minden, ami ez után az üzenet után jön, elvész.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Mondja el, mi történjen és mikor — „minden hétköznap 8:00-kor foglald össze az olvasólistám híreit” —, és a szerver elvégzi Ön helyett.", "Scheduled": "Ütemezett", @@ -1970,6 +1980,7 @@ const LEMBAS_I18N = { "Tell me": "Értesítés", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok", "Terminal": "Terminál", + "The account's (%(language)s)": "A fióké (%(language)s)", "The account's (%(theme)s)": "A fióké (%(theme)s)", "The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:", "The agent asks:": "Az ügynök kérdezi:", @@ -2343,6 +2354,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}", "Runs": "Esecuzioni", "Save": "Salva", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Salvando, la risposta riparte; tutto ciò che segue questo messaggio viene scartato.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Di’ cosa deve succedere e quando — «ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura» — e il server lo fa per te.", "Scheduled": "Pianificato", @@ -2388,6 +2400,7 @@ const LEMBAS_I18N = { "Tell me": "Avvisami", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove", "Terminal": "Terminale", + "The account's (%(language)s)": "Quella dell’account (%(language)s)", "The account's (%(theme)s)": "Quello dell’account (%(theme)s)", "The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:", "The agent asks:": "L'agente chiede:", @@ -2761,6 +2774,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}", "Runs": "Uruchomienia", "Save": "Zapisz", + "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ą.", "Saving starts the reply again; everything after this message is dropped.": "Zapisanie uruchamia odpowiedź od nowa; wszystko po tej wiadomości zostanie odrzucone.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Powiedz, co ma się stać i kiedy — „w każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania” — a serwer zrobi to za Ciebie.", "Scheduled": "Zaplanowane", @@ -2806,6 +2820,7 @@ const LEMBAS_I18N = { "Tell me": "Powiadamiaj", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej", "Terminal": "Terminal", + "The account's (%(language)s)": "Jak na koncie (%(language)s)", "The account's (%(theme)s)": "Jak na koncie (%(theme)s)", "The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:", "The agent asks:": "Agent pyta:", @@ -3179,6 +3194,7 @@ const LEMBAS_I18N = { "Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}", "Runs": "Spustenia", "Save": "Uložiť", + "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.", "Saving starts the reply again; everything after this message is dropped.": "Uložením sa odpoveď spustí znova; všetko po tejto správe sa zahodí.", "Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.": "Povedzte, čo sa má stať a kedy — „každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie“ — a server to urobí za vás.", "Scheduled": "Naplánované", @@ -3224,6 +3240,7 @@ const LEMBAS_I18N = { "Tell me": "Upozorniť ma", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde", "Terminal": "Terminál", + "The account's (%(language)s)": "Podľa účtu (%(language)s)", "The account's (%(theme)s)": "Podľa účtu (%(theme)s)", "The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:", "The agent asks:": "Agent sa pýta:", @@ -3365,12 +3382,23 @@ const LEMBAS_SYSTEM_LANG = (() => { const code = String(navigator.language || "").slice(0, 2).toLowerCase() return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en" })() -const LEMBAS_CHOSEN_LANG = (() => { - let code = "" +// Settings' choice: "" for the account's language (as /me last said it), "system", or a code. +const LEMBAS_CHOICE = (() => { try { - code = localStorage.getItem("lembas.language") || "" + return localStorage.getItem("lembas.language") || "" + } catch { + return "" + } +})() +const LEMBAS_CHOSEN_LANG = (() => { + const has = (code) => Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) + if (LEMBAS_CHOICE === "system") return "" + if (has(LEMBAS_CHOICE)) return LEMBAS_CHOICE + let account = "" + try { + account = localStorage.getItem("lembas.account-language") || "" } catch {} - return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "" + return has(account) ? account : "" })() const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG document.documentElement.lang = LEMBAS_LANG diff --git a/ui/js/look.js b/ui/js/look.js index a8d0ca9..07b9d9d 100644 --- a/ui/js/look.js +++ b/ui/js/look.js @@ -1,16 +1,18 @@ -// The app's language and theme, as chosen in Settings: kept in this app (localStorage), over the -// system's language and the account's theme. The account's own language and theme are the -// server's to change (`PATCH /me` has neither yet), so a choice here stays on this computer. +// The app's language and theme, as Settings chooses them. // -// The language is read once, as the page loads (ui/i18n.js, `lembas.language`): every sentence is -// drawn through `t()` as it is needed, so a new one opens the window again. The theme is applied -// at once. +// The language is the account's (`/me`'s `language`) unless Settings chose this computer's or +// another one; that choice is kept in this app (localStorage `lembas.language`), as the phone +// keeps its own. It is read once, as the page loads (ui/i18n.js): every sentence is drawn through +// `t()` as it is needed, so a new language opens the window again. +// +// The theme is the account's, changed with `PATCH /me` so the web interface and the phone follow +// it. A server whose `/me` lists no `themes` cannot be told: there the choice is kept in this app. -import { invoke, letGoAll } from "./bridge.js" +import { invoke, patch, letGoAll } from "./bridge.js" import { terminalThemeChanged } from "./terminal.js" -const THEMES = ["moria", "shire"] -let account = "moria" // the account's theme, as /me says (a custom one counts as Moria) +const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria +let me = null // /me, as last read function read(key) { try { @@ -27,21 +29,35 @@ function write(key, value) { } catch {} } -/** The theme chosen in this app ("" for the account's). */ -export function chosenTheme() { +/** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */ +function known(code) { + const c = String(code || "") + .trim() + .toLowerCase() + .split(/[-_]/)[0] + return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, c) ? c : "" +} + +/** Whether the server takes the theme on `PATCH /me`: its `/me` lists the themes it offers. */ +export function accountThemes() { + return Array.isArray(me?.themes) && me.themes.length ? me.themes : null +} + +/** The theme chosen in this app, where the server cannot be told ("" for the account's). */ +export function localTheme() { const name = read("lembas.theme") - return THEMES.includes(name) ? name : "" + return !accountThemes() && LOOKS.includes(name) ? name : "" } /** The account's theme, as the server says it. */ export function accountTheme() { - return account + return me?.theme || "moria" } -/** The look the window is drawn in: the one chosen here, else `name` (the account's, or Rust's last). */ +/** The look the window is drawn in: `name` (the account's, or Rust's last), or the one kept here. */ export function applyTheme(name) { - name = chosenTheme() || name - if (!THEMES.includes(name)) name = "moria" + name = localTheme() || name + if (!LOOKS.includes(name)) name = "moria" const root = document.documentElement root.dataset.theme = name root.dataset.base = name @@ -49,29 +65,57 @@ export function applyTheme(name) { return name } -/** The account's theme is known (from /me): drawn in it, unless one is chosen here. */ -export function noteAccountTheme(name) { - account = THEMES.includes(name) ? name : "moria" - invoke("set_theme", { name: applyTheme(account) }) +function paint() { + invoke("set_theme", { name: applyTheme(accountTheme()) }) } -/** Choose a theme here ("" goes back to the account's): drawn at once, and the window painted in it. */ -export function chooseTheme(name) { - write("lembas.theme", THEMES.includes(name) ? name : "") - invoke("set_theme", { name: applyTheme(account) }) +/** + * `/me` was read: drawn in the account's theme, and, where the language follows the account and + * it is now another, the window opens again in it. + */ +export function noteMe(next) { + me = next || null + paint() + if (!me || !me.language) return + const account = known(me.language) + const before = read("lembas.account-language") + write("lembas.account-language", account) + // Only once it is kept: where it cannot be, opening again would find it unknown again, and again. + if (read("lembas.account-language") !== account) return + if (LEMBAS_CHOICE === "" && account !== before && (account || LEMBAS_SYSTEM_LANG) !== LEMBAS_LANG) reopen() } -/** The language chosen in this app ("" for the system's). */ +/** + * Choose a theme: the account's, through `PATCH /me` (rejects with the server's words); where the + * server cannot be told, kept here ("" goes back to the account's). + */ +export async function chooseTheme(name) { + if (accountThemes()) { + const r = await patch("/me", { theme: name }) + me = r && r.theme ? r : { ...me, theme: name } + } else write("lembas.theme", LOOKS.includes(name) ? name : "") + paint() +} + +/** Settings' language: "" the account's, "system" this computer's, or a code the app has. */ export function chosenLanguage() { - return typeof LEMBAS_CHOSEN_LANG === "string" ? LEMBAS_CHOSEN_LANG : "" + return LEMBAS_CHOICE === "system" || known(LEMBAS_CHOICE) ? LEMBAS_CHOICE : "" } -/** Choose a language here ("" goes back to the system's): the window opens again in it. */ -export async function chooseLanguage(code) { - if (code === chosenLanguage()) return - write("lembas.language", code && Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "") - // The replies followed and the shells open are let go first: the page that opens again has - // no handle on them. +/** The account's language, as /me last said it ("" if the app lacks it or the server says none). */ +export function accountLanguage() { + return me?.language ? known(me.language) : "" +} + +/** Choose the language; the window opens again in it. */ +export function chooseLanguage(choice) { + if (choice === chosenLanguage()) return + write("lembas.language", choice === "system" || known(choice) ? choice : "") + return reopen() +} + +/** Open the window again: the replies followed and the shells open let go first. */ +async function reopen() { await letGoAll() location.reload() } diff --git a/ui/js/main.js b/ui/js/main.js index 4501cc9..edaf79b 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -14,7 +14,7 @@ import { initExplorer, showLocal, showFilesTab } from "./explorer.js" import { initList, refreshList, markCurrent } from "./list.js" import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js" import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal } from "./terminal.js" -import { applyTheme, noteAccountTheme } from "./look.js" +import { applyTheme, noteMe } from "./look.js" import { initAttach, allowAttach } from "./attach.js" import { initPalette, openPalette } from "./palette.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" @@ -92,7 +92,7 @@ async function startApp() { started = true try { me = await get("/me") - noteAccountTheme(me.theme) + noteMe(me) } catch (err) { $("thread-note").textContent = why(err) $("thread-note").hidden = false diff --git a/ui/js/settings.js b/ui/js/settings.js index 91a3214..77d87f0 100644 --- a/ui/js/settings.js +++ b/ui/js/settings.js @@ -6,7 +6,8 @@ import { $, h, clear, icon, ICONS } from "./dom.js" import { invoke, listen } from "./bridge.js" import { agentBlock, onAgentChange, link } from "./agent.js" -import { chosenTheme, chooseTheme, accountTheme, chosenLanguage, chooseLanguage } from "./look.js" +import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js" +import { why } from "./bridge.js" let folderError = "" let current = () => null @@ -148,27 +149,61 @@ export function renderSettings(s) { ) } -/** The language and the theme: this app's choice over the system's language and the account's theme. */ +let lookError = "" + +/** + * The language and the theme. The language: the account's (the default), this computer's, or one + * the app has, kept here. The theme: the account's, saved to it; kept here where the server + * cannot be told. + */ function lookSection() { const named = (code) => LEMBAS_LANGS[code] || code + const account = accountLanguage() + const languages = [ + ["", t("The account's (%(language)s)", { language: named(account || LEMBAS_SYSTEM_LANG) })], + ["system", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })], + ...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)]), + ] const language = h( "select", { id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) }, - [["", t("This computer's (%(language)s)", { language: named(LEMBAS_SYSTEM_LANG) })], ...Object.keys(LEMBAS_LANGS).map((code) => [code, named(code)])].map(([v, label]) => - h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v || undefined }), - ), + languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })), ) - const label = (name) => `${name === "shire" ? "Shire" : "Moria"} — ${name === "shire" ? t("light") : t("dark")}` + const scheme = (s) => (s === "light" ? t("light") : t("dark")) + const offered = accountThemes() + const themes = offered + ? offered.map((th) => [th.id, `${th.label || th.id} — ${scheme(th.scheme)}`]) + : [ + ["", t("The account's (%(theme)s)", { theme: accountTheme() === "shire" ? `Shire — ${t("light")}` : `Moria — ${t("dark")}` })], + ["moria", `Moria — ${t("dark")}`], + ["shire", `Shire — ${t("light")}`], + ] + const chosen = offered ? accountTheme() : localTheme() const theme = h( "select", - { id: "set-theme", "aria-label": t("Theme"), onchange: (e) => chooseTheme(e.target.value) }, - [["", t("The account's (%(theme)s)", { theme: label(accountTheme()) })], ["moria", label("moria")], ["shire", label("shire")]].map(([v, text]) => h("option", { value: v, text, selected: chosenTheme() === v })), + { + id: "set-theme", + "aria-label": t("Theme"), + onchange: async (e) => { + const el = e.target + el.disabled = true + try { + await chooseTheme(el.value) + lookError = "" + } catch (err) { + lookError = why(err) + } + renderSettings(current()) + }, + }, + themes.map(([v, text]) => h("option", { value: v, text, selected: chosen === v })), ) return section( "look", t("Appearance"), line(t("Language"), `${t("The interface only. Models answer in the language you write to them in, whatever this says.")} ${t("Kept in this app on this computer. The window opens again in the language chosen.")}`, language), - line(t("Theme"), t("Kept in this app on this computer; the account's theme stays as it is."), theme), + line(t("Theme"), offered ? t("Saved to your account: the web interface and the phone follow it too.") : t("Kept in this app on this computer; the account's theme stays as it is."), theme), + lookError ? h("p", { class: "status is-error", role: "alert", text: lookError }) : null, ) }