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>
230 lines
10 KiB
JavaScript
230 lines
10 KiB
JavaScript
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
|
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
|
|
// sign-in), the agent and the folders it may work in, the language and theme, notifications, and
|
|
// this release.
|
|
|
|
import { $, h, clear, icon, ICONS } from "./dom.js"
|
|
import { invoke, listen } from "./bridge.js"
|
|
import { agentBlock, onAgentChange, link } from "./agent.js"
|
|
import { localTheme, chooseTheme, accountTheme, accountThemes, chosenLanguage, chooseLanguage, accountLanguage } from "./look.js"
|
|
import { why } from "./bridge.js"
|
|
|
|
let folderError = ""
|
|
let current = () => null
|
|
|
|
const SECTIONS = [
|
|
["account", ICONS.user, () => t("Account")],
|
|
["agent", ICONS.terminal, () => t("Agent")],
|
|
["folders", ICONS.folder, () => t("Project folders")],
|
|
["look", ICONS.contrast, () => t("Appearance")],
|
|
["notify", ICONS.bell, () => t("Notifications")],
|
|
["about", ICONS.info, () => t("About")],
|
|
]
|
|
|
|
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
|
|
export function scrollToSection(id) {
|
|
const el = $(`set-${id}`)
|
|
if (!el) return
|
|
el.scrollIntoView({ block: "start" })
|
|
markNav(id)
|
|
el.querySelector("button, select, input")?.focus({ preventScroll: true })
|
|
}
|
|
|
|
function markNav(id) {
|
|
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
|
|
}
|
|
|
|
function section(id, title, ...body) {
|
|
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
|
|
}
|
|
|
|
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
|
|
function line(label, hint, control) {
|
|
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
|
|
}
|
|
|
|
export function renderSettings(s) {
|
|
if (!s) return
|
|
const nav = clear($("settings-nav"))
|
|
for (const [id, ico, title] of SECTIONS) {
|
|
if (id === "folders" && !(s.linked && s.remote)) continue
|
|
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
|
|
}
|
|
const box = clear($("settings-body"))
|
|
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
|
box.append(
|
|
section(
|
|
"account",
|
|
t("Account"),
|
|
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
|
|
line(
|
|
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
|
|
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
|
|
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
|
|
),
|
|
),
|
|
)
|
|
|
|
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
|
|
box.append(
|
|
section(
|
|
"agent",
|
|
t("Agent"),
|
|
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
|
|
agentBlock(s),
|
|
h(
|
|
"div",
|
|
{ class: "set__buttons" },
|
|
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
|
|
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
|
|
),
|
|
),
|
|
)
|
|
|
|
// The folders.
|
|
const r = s.remote
|
|
if (s.linked && r) {
|
|
const list = h("ul", { class: "folders" })
|
|
if (!r.enabled || !r.roots.length) list.append(h("li", { class: "empty", text: t("No folder yet: the agent can open nothing. Add the folder of a project.") }))
|
|
for (const dir of r.enabled ? r.roots : []) {
|
|
list.append(
|
|
h(
|
|
"li",
|
|
null,
|
|
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
|
|
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
|
|
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
|
),
|
|
)
|
|
}
|
|
const mode = h(
|
|
"select",
|
|
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
|
|
[
|
|
["manual", t("Manual — asks first")],
|
|
["edit", t("Edit — asks before commands")],
|
|
["auto", t("Auto — asks nothing")],
|
|
["plan", t("Plan — changes nothing")],
|
|
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
|
|
)
|
|
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
|
box.append(
|
|
section(
|
|
"folders",
|
|
t("Project folders"),
|
|
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
|
|
list,
|
|
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
|
|
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
|
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
|
|
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
|
|
),
|
|
)
|
|
}
|
|
|
|
box.append(lookSection())
|
|
|
|
box.append(
|
|
section(
|
|
"notify",
|
|
t("Notifications"),
|
|
line(
|
|
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
|
|
t("Scheduled tasks tell you of a result, too, while the app runs."),
|
|
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
|
|
),
|
|
),
|
|
)
|
|
|
|
box.append(
|
|
section(
|
|
"about",
|
|
t("About"),
|
|
line(
|
|
`LLeMbas Desktop ${s.version}`,
|
|
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
|
|
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
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) },
|
|
languages.map(([v, label]) => h("option", { value: v, text: label, selected: chosenLanguage() === v, lang: v && v !== "system" ? v : undefined })),
|
|
)
|
|
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: 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"), 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,
|
|
)
|
|
}
|
|
|
|
export function initSettings(getStatus) {
|
|
current = getStatus
|
|
listen("remote-error", (e) => {
|
|
folderError = String(e.payload)
|
|
if (!$("settings").hidden) renderSettings(current())
|
|
})
|
|
listen("status", () => {
|
|
folderError = ""
|
|
})
|
|
onAgentChange(() => {
|
|
if (!$("settings").hidden) renderSettings(current())
|
|
})
|
|
$("settings-body").addEventListener("scroll", () => {
|
|
// The group at the top is the one marked beside them.
|
|
const top = $("settings-body").getBoundingClientRect().top
|
|
let at = ""
|
|
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
|
|
if (at) markNav(at)
|
|
})
|
|
}
|