Language and theme follow the account: PATCH /me for the theme, /me's language by default
ci / ui (push) Waiting to run
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s

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 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:19:44 +00:00
1 parent 151fc5fcca
commit 04736db057
23 files changed
+783 -84

No files matched your search

+4 -3
View File
@@ -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 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 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. before the first message opens there too.
- **Language and theme in Settings.** Under Appearance: the language (this computer's, or any of - **Language and theme in Settings.** Under Appearance. The language is the account's, as the
the app's nine; the window opens again in it) and the theme (the account's, or Moria or Shire). server says it, unless this computer's or another of the app's nine is chosen (kept in the app;
Both are kept in the app on this computer; the account's own stay as they are. 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 ### Fixed
+3 -1
View File
@@ -14,7 +14,9 @@ SERVER="${LEMBAS_SERVER:-../LLeMbas}"
FROM="tests/fixtures/api_v2" FROM="tests/fixtures/api_v2"
OUT="tests/ui/fixtures" OUT="tests/ui/fixtures"
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json 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 <server-ref>" >&2; exit 2; } [ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \ commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
+37 -7
View File
@@ -469,6 +469,8 @@ KEYS = (
"The account's (%(theme)s)", "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 window opens again in the language chosen.",
"Kept in this app on this computer; the account's theme stays as it is.", "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 # 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)", "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 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.", "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": { "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.", "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)", "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 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.", "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": { "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.", "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)", "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 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.", "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": { "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.", "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)", "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 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.", "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": { "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.", "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)", "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 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.", "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": { "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.", "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)", "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 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’è.", "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": { "pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", "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)", "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 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.", "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": { "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.", "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)", "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 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.", "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" "// script's own table. Do not edit; run the script.\n"
"//\n" "//\n"
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\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" "// missing entry is the English. The language is the account's (kept from /me as\n"
"// localStorage as `lembas.language`), else the system's (navigator.language), English\n" "// `lembas.account-language`) unless Settings chose this computer's or another\n"
"// unless it is one of these. Elements marked `data-i18n` (their text) and\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" "// `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" "// are translated once the page is parsed; this script is deferred, before the page's.\n"
'"use strict"\n' '"use strict"\n'
@@ -2443,12 +2462,23 @@ def main() -> None:
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n' ' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n' ' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
"})()\n" "})()\n"
"const LEMBAS_CHOSEN_LANG = (() => {\n" "// Settings' choice: \"\" for the account's language (as /me last said it), \"system\", or a code.\n"
' let code = ""\n' "const LEMBAS_CHOICE = (() => {\n"
" try {\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" " } catch {}\n"
' return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : ""\n' ' return has(account) ? account : ""\n'
"})()\n" "})()\n"
"const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n" "const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG\n"
"document.documentElement.lang = LEMBAS_LANG\n" "document.documentElement.lang = LEMBAS_LANG\n"
+1 -1
View File
@@ -1 +1 @@
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2 LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2
+28
View File
@@ -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"
}
+29
View File
@@ -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
}
+109
View File
@@ -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"
}
]
}
+22
View File
@@ -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"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"folder": {
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
}
}
+20
View File
@@ -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
}
]
}
+57
View File
@@ -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"
}
]
}
+21
View File
@@ -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"
}
]
}
+13
View File
@@ -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"
}
}
+34
View File
@@ -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"
}
@@ -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"
}
+52
View File
@@ -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*."
}
+26
View File
@@ -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 *"
]
}
}
+69
View File
@@ -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
}
]
}
]
}
+54 -22
View File
@@ -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, []) 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) { async function openSettings(p) {
await p.click("#settings-btn") await p.click("#settings-btn")
await p.waitForSelector("#set-look") 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: {} }) 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 p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
await openSettings(p) await openSettings(p)
assert.equal(await p.inputValue("#set-theme"), "") assert.equal(await p.inputValue("#set-theme"), "")
assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/) assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/)
await p.selectOption("#set-theme", "moria") await p.selectOption("#set-theme", "moria")
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") await p.waitForFunction(() => 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(await p.evaluate(() => window.__mock.api("PATCH", "/me").length), 0, "nothing sent")
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
// Opened again: still Moria, over the account's Shire.
await p.reload() await p.reload()
await p.waitForSelector("#shell:not([hidden])") await p.waitForSelector("#shell:not([hidden])")
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme")) await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme"))
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria") assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
await openSettings(p) await openSettings(p)
assert.equal(await p.inputValue("#set-theme"), "moria")
await p.selectOption("#set-theme", "") 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, []) 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 () => { 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()) const p = await page({ ...b3(), routes: { ...b3().routes, "GET /me": meAs({ language: "de", theme: "moria" }) } })
assert.equal(await p.evaluate(() => document.documentElement.lang), "en") // 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 openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]') await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => window.__mock.terminals().length === 1) await p.waitForFunction(() => window.__mock.terminals().length === 1)
// What the page let go of before it loaded again, kept across the load. // 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 p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd)))))
await openSettings(p) await openSettings(p)
assert.match(await p.textContent("#set-language option[value='']"), /English/) assert.equal(await p.inputValue("#set-language"), "")
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "de")]) assert.match(await p.textContent("#set-language option[value='']"), /Deutsch/)
await p.waitForSelector("#shell:not([hidden])") await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "system")])
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", "")])
await p.waitForSelector("#shell:not([hidden])") await p.waitForSelector("#shell:not([hidden])")
assert.equal(await p.evaluate(() => document.documentElement.lang), "en") 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, []) assert.deepEqual(p.errors, [])
}) })
+35 -7
View File
@@ -3,9 +3,10 @@
// script's own table. Do not edit; run the script. // 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 // `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 // missing entry is the English. The language is the account's (kept from /me as
// localStorage as `lembas.language`), else the system's (navigator.language), English // `lembas.account-language`) unless Settings chose this computer's or another
// unless it is one of these. Elements marked `data-i18n` (their text) and // (`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) // `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. // are translated once the page is parsed; this script is deferred, before the page's.
"use strict" "use strict"
@@ -253,6 +254,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}", "Running {{tool}}: {{what}}": "Spouští {{tool}}: {{what}}",
"Runs": "Spuštění", "Runs": "Spuštění",
"Save": "Uložit", "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í.", "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.", "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", "Scheduled": "Naplánováno",
@@ -298,6 +300,7 @@ const LEMBAS_I18N = {
"Tell me": "Upozornit mě", "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", "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", "Terminal": "Terminál",
"The account's (%(language)s)": "Podle účtu (%(language)s)",
"The account's (%(theme)s)": "Podle účtu (%(theme)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 to run %(tool)s:": "Agent žádá o spuštění %(tool)s:",
"The agent asks:": "Agent se ptá:", "The agent asks:": "Agent se ptá:",
@@ -671,6 +674,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}", "Running {{tool}}: {{what}}": "Führe {{tool}} aus: {{what}}",
"Runs": "Läufe", "Runs": "Läufe",
"Save": "Speichern", "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.", "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.", "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", "Scheduled": "Geplant",
@@ -716,6 +720,7 @@ const LEMBAS_I18N = {
"Tell me": "Benachrichtigen", "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", "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", "Terminal": "Terminal",
"The account's (%(language)s)": "Wie das Konto (%(language)s)",
"The account's (%(theme)s)": "Wie das Konto (%(theme)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 to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:",
"The agent asks:": "Der Agent fragt:", "The agent asks:": "Der Agent fragt:",
@@ -1089,6 +1094,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}", "Running {{tool}}: {{what}}": "Ejecutando {{tool}}: {{what}}",
"Runs": "Ejecuciones", "Runs": "Ejecuciones",
"Save": "Guardar", "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.", "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.", "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", "Scheduled": "Programado",
@@ -1134,6 +1140,7 @@ const LEMBAS_I18N = {
"Tell me": "Avisarme", "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", "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", "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 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 to run %(tool)s:": "El agente pide ejecutar %(tool)s:",
"The agent asks:": "El agente pregunta:", "The agent asks:": "El agente pregunta:",
@@ -1507,6 +1514,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}", "Running {{tool}}: {{what}}": "Exécution de {{tool}} : {{what}}",
"Runs": "Exécutions", "Runs": "Exécutions",
"Save": "Enregistrer", "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é.", "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.", "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é", "Scheduled": "Planifié",
@@ -1552,6 +1560,7 @@ const LEMBAS_I18N = {
"Tell me": "Me prévenir", "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", "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", "Terminal": "Terminal",
"The account's (%(language)s)": "Celle du compte (%(language)s)",
"The account's (%(theme)s)": "Celui du compte (%(theme)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 to run %(tool)s:": "L'agent demande à exécuter %(tool)s :",
"The agent asks:": "L'agent demande :", "The agent asks:": "L'agent demande :",
@@ -1925,6 +1934,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}", "Running {{tool}}: {{what}}": "{{tool}} futtatása: {{what}}",
"Runs": "Futások", "Runs": "Futások",
"Save": "Mentés", "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.", "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.", "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", "Scheduled": "Ütemezett",
@@ -1970,6 +1980,7 @@ const LEMBAS_I18N = {
"Tell me": "Értesítés", "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", "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", "Terminal": "Terminál",
"The account's (%(language)s)": "A fióké (%(language)s)",
"The account's (%(theme)s)": "A fióké (%(theme)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 to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:",
"The agent asks:": "Az ügynök kérdezi:", "The agent asks:": "Az ügynök kérdezi:",
@@ -2343,6 +2354,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}", "Running {{tool}}: {{what}}": "Esecuzione di {{tool}}: {{what}}",
"Runs": "Esecuzioni", "Runs": "Esecuzioni",
"Save": "Salva", "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.", "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.", "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", "Scheduled": "Pianificato",
@@ -2388,6 +2400,7 @@ const LEMBAS_I18N = {
"Tell me": "Avvisami", "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", "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", "Terminal": "Terminale",
"The account's (%(language)s)": "Quella dell’account (%(language)s)",
"The account's (%(theme)s)": "Quello dell’account (%(theme)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 to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:",
"The agent asks:": "L'agente chiede:", "The agent asks:": "L'agente chiede:",
@@ -2761,6 +2774,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}", "Running {{tool}}: {{what}}": "Uruchamianie {{tool}}: {{what}}",
"Runs": "Uruchomienia", "Runs": "Uruchomienia",
"Save": "Zapisz", "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.", "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.", "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", "Scheduled": "Zaplanowane",
@@ -2806,6 +2820,7 @@ const LEMBAS_I18N = {
"Tell me": "Powiadamiaj", "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", "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", "Terminal": "Terminal",
"The account's (%(language)s)": "Jak na koncie (%(language)s)",
"The account's (%(theme)s)": "Jak na koncie (%(theme)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 to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:",
"The agent asks:": "Agent pyta:", "The agent asks:": "Agent pyta:",
@@ -3179,6 +3194,7 @@ const LEMBAS_I18N = {
"Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}", "Running {{tool}}: {{what}}": "Spúšťa {{tool}}: {{what}}",
"Runs": "Spustenia", "Runs": "Spustenia",
"Save": "Uložiť", "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í.", "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.", "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é", "Scheduled": "Naplánované",
@@ -3224,6 +3240,7 @@ const LEMBAS_I18N = {
"Tell me": "Upozorniť ma", "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", "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", "Terminal": "Terminál",
"The account's (%(language)s)": "Podľa účtu (%(language)s)",
"The account's (%(theme)s)": "Podľa účtu (%(theme)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 to run %(tool)s:": "Agent žiada spustiť %(tool)s:",
"The agent asks:": "Agent sa pýta:", "The agent asks:": "Agent sa pýta:",
@@ -3365,12 +3382,23 @@ const LEMBAS_SYSTEM_LANG = (() => {
const code = String(navigator.language || "").slice(0, 2).toLowerCase() const code = String(navigator.language || "").slice(0, 2).toLowerCase()
return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en" return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"
})() })()
const LEMBAS_CHOSEN_LANG = (() => { // Settings' choice: "" for the account's language (as /me last said it), "system", or a code.
let code = "" const LEMBAS_CHOICE = (() => {
try { 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 {} } catch {}
return Object.prototype.hasOwnProperty.call(LEMBAS_LANGS, code) ? code : "" return has(account) ? account : ""
})() })()
const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG const LEMBAS_LANG = LEMBAS_CHOSEN_LANG || LEMBAS_SYSTEM_LANG
document.documentElement.lang = LEMBAS_LANG document.documentElement.lang = LEMBAS_LANG
+76 -32
View File
@@ -1,16 +1,18 @@
// The app's language and theme, as chosen in Settings: kept in this app (localStorage), over the // The app's language and theme, as Settings chooses them.
// 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 language is read once, as the page loads (ui/i18n.js, `lembas.language`): every sentence is // The language is the account's (`/me`'s `language`) unless Settings chose this computer's or
// drawn through `t()` as it is needed, so a new one opens the window again. The theme is applied // another one; that choice is kept in this app (localStorage `lembas.language`), as the phone
// at once. // 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" import { terminalThemeChanged } from "./terminal.js"
const THEMES = ["moria", "shire"] const LOOKS = ["moria", "shire"] // the two looks tokens.css draws; any other theme draws as Moria
let account = "moria" // the account's theme, as /me says (a custom one counts as Moria) let me = null // /me, as last read
function read(key) { function read(key) {
try { try {
@@ -27,21 +29,35 @@ function write(key, value) {
} catch {} } catch {}
} }
/** The theme chosen in this app ("" for the account's). */ /** The app's code for a server's language (`pt_BR` -> `pt`), or "" for one the app lacks. */
export function chosenTheme() { 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") 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. */ /** The account's theme, as the server says it. */
export function accountTheme() { 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) { export function applyTheme(name) {
name = chosenTheme() || name name = localTheme() || name
if (!THEMES.includes(name)) name = "moria" if (!LOOKS.includes(name)) name = "moria"
const root = document.documentElement const root = document.documentElement
root.dataset.theme = name root.dataset.theme = name
root.dataset.base = name root.dataset.base = name
@@ -49,29 +65,57 @@ export function applyTheme(name) {
return name return name
} }
/** The account's theme is known (from /me): drawn in it, unless one is chosen here. */ function paint() {
export function noteAccountTheme(name) { invoke("set_theme", { name: applyTheme(accountTheme()) })
account = THEMES.includes(name) ? name : "moria"
invoke("set_theme", { name: applyTheme(account) })
} }
/** Choose a theme here ("" goes back to the account's): drawn at once, and the window painted in it. */ /**
export function chooseTheme(name) { * `/me` was read: drawn in the account's theme, and, where the language follows the account and
write("lembas.theme", THEMES.includes(name) ? name : "") * it is now another, the window opens again in it.
invoke("set_theme", { name: applyTheme(account) }) */
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() { 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. */ /** The account's language, as /me last said it ("" if the app lacks it or the server says none). */
export async function chooseLanguage(code) { export function accountLanguage() {
if (code === chosenLanguage()) return return me?.language ? known(me.language) : ""
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. /** 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() await letGoAll()
location.reload() location.reload()
} }
+2 -2
View File
@@ -14,7 +14,7 @@ import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js" import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.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 { 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 { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js" import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js" import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
@@ -92,7 +92,7 @@ async function startApp() {
started = true started = true
try { try {
me = await get("/me") me = await get("/me")
noteAccountTheme(me.theme) noteMe(me)
} catch (err) { } catch (err) {
$("thread-note").textContent = why(err) $("thread-note").textContent = why(err)
$("thread-note").hidden = false $("thread-note").hidden = false
+44 -9
View File
@@ -6,7 +6,8 @@
import { $, h, clear, icon, ICONS } from "./dom.js" import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen } from "./bridge.js" import { invoke, listen } from "./bridge.js"
import { agentBlock, onAgentChange, link } from "./agent.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 folderError = ""
let current = () => null 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() { function lookSection() {
const named = (code) => LEMBAS_LANGS[code] || code 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( const language = h(
"select", "select",
{ id: "set-language", "aria-label": t("Language"), onchange: (e) => chooseLanguage(e.target.value) }, { 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]) => languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: 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( const theme = h(
"select", "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( return section(
"look", "look",
t("Appearance"), 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("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,
) )
} }