- Sidebar: New chat, search, Scheduled, Files, the chats of both kinds by day (Today, Earlier), Settings with the account at the foot; folds away (Ctrl+B), a drawer on a narrow window. - A new chat is the chat page empty: a greeting, Agent or Chat, and the composer. - The composer is a card: the text, then attach, the chips (folder and mode for an agent chat, model with its picture, effort as the model offers it, the team's size), and a round Send that Stop replaces while a reply is written. An open chat's chips change the chat. - Messages: the person's in a bubble on the right with attached pictures as thumbnails, a reply full width; thinking and tool calls as compact rows that unfold. - Activity: a chip above the composer for every open chat, the panel on the right as before. - The agent: a chip in the title bar opening its state and the one thing to do about it; "This computer is not linked yet" with Link this computer right above the composer of an agent chat, and "refused" only with the server's reason. - Files: a page with the two trees as tabs. Settings: grouped, the groups listed beside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
7.5 KiB
JavaScript
166 lines
7.5 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, 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"
|
|
|
|
let folderError = ""
|
|
let current = () => null
|
|
|
|
const SECTIONS = [
|
|
["account", ICONS.user, () => t("Account")],
|
|
["agent", ICONS.terminal, () => t("Agent")],
|
|
["folders", ICONS.folder, () => t("Project folders")],
|
|
["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(
|
|
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") }),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|