Plain HTML, CSS and ES modules in ui/, no build step. The sidebar opens on agent chats (this computer first), with chats on the server as the second tab; the transcript draws the /api/v2 steps, follows a reply as it is written, answers cards, and starts agent chats in one of the agent's folders. Two explorers kept apart (this computer's, the server's) and a viewer: Markdown drawn by a safe renderer that never parses HTML, code in read-only CodeMirror with the server's token colours, HTML in the sealed frame, show calls opened in it. Settings for the sign-in, the agent and its folders, notifications and updates; no admin pages. Layout on fixed and shared tracks; the sidebar is a drawer below 48rem, the viewer goes over the chat below 64rem. All 124 sentences in the server's nine languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
|
|
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
|
|
// notifications, and this release.
|
|
|
|
import { $, h, clear } from "./dom.js"
|
|
import { invoke, listen, why } from "./bridge.js"
|
|
|
|
const AGENT = {
|
|
off: () => [t("The agent is off."), ""],
|
|
not_linked: () => [t("This computer is not linked to the server yet."), ""],
|
|
starting: () => [t("The agent is starting…"), ""],
|
|
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
|
|
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
|
|
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
|
|
}
|
|
|
|
let folderError = ""
|
|
let linking = null // { user_code, uri } while the computer is being linked
|
|
let linkError = ""
|
|
|
|
export function agentWords(s) {
|
|
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
|
|
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
|
|
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
|
|
}
|
|
|
|
function section(id, title, ...body) {
|
|
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
|
|
}
|
|
|
|
export function renderSettings(s) {
|
|
const box = clear($("settings-body"))
|
|
// The server and the account.
|
|
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
|
|
box.append(
|
|
section(
|
|
"set-server",
|
|
t("Server"),
|
|
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
|
|
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
|
|
h("p", { class: "muted", text: 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("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
|
|
),
|
|
)
|
|
|
|
// The agent.
|
|
const [text, cls] = agentWords(s)
|
|
const agent = section(
|
|
"set-agent",
|
|
t("Agent"),
|
|
h("p", { class: "muted", 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.") }),
|
|
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
|
|
)
|
|
if (linking) {
|
|
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
|
|
}
|
|
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
|
|
agent.append(
|
|
h(
|
|
"div",
|
|
{ class: "row--buttons" },
|
|
h("button", {
|
|
type: "button",
|
|
class: "btn",
|
|
text: s.linked ? t("Link again") : t("Link this computer"),
|
|
onclick: () => {
|
|
linkError = ""
|
|
invoke("link_computer").catch((err) => {
|
|
linkError = why(err)
|
|
renderSettings(s)
|
|
})
|
|
},
|
|
}),
|
|
h("button", {
|
|
type: "button",
|
|
class: "btn",
|
|
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 }),
|
|
}),
|
|
),
|
|
)
|
|
box.append(agent)
|
|
|
|
// 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: "path", title: dir }, h("bdi", { text: dir })),
|
|
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
|
|
),
|
|
)
|
|
}
|
|
const mode = h(
|
|
"select",
|
|
{ id: "max-mode", 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", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
|
|
box.append(
|
|
section(
|
|
"set-folders",
|
|
t("Project folders"),
|
|
h("p", { class: "muted", 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: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
|
|
h(
|
|
"div",
|
|
{ class: "fields" },
|
|
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
|
|
h(
|
|
"div",
|
|
{ class: "field" },
|
|
h("span", { class: "label", text: t("Terminal") }),
|
|
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
|
|
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
// Notifications.
|
|
box.append(
|
|
section(
|
|
"set-notify",
|
|
t("Notifications"),
|
|
h(
|
|
"label",
|
|
{ class: "check" },
|
|
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
|
|
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
|
|
),
|
|
),
|
|
)
|
|
|
|
// This release.
|
|
box.append(
|
|
section(
|
|
"set-about",
|
|
t("About"),
|
|
h("p", null, `LLeMbas Desktop ${s.version}`),
|
|
h(
|
|
"div",
|
|
{ class: "row--buttons" },
|
|
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
export function initSettings(current) {
|
|
listen("remote-error", (e) => {
|
|
folderError = String(e.payload)
|
|
renderSettings(current())
|
|
})
|
|
listen("status", () => {
|
|
folderError = ""
|
|
})
|
|
listen("linking", (e) => {
|
|
const p = e.payload
|
|
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
|
|
linkError = p.state === "failed" ? p.message : ""
|
|
renderSettings(current())
|
|
})
|
|
}
|