Files
LLeMbas-Desktop/ui/js/main.js
T
HomerandClaude Opus 5.5 4b4e63f622 The app's own interface: agent chats first, two explorers, a viewer
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>
2026-10-28 04:55:03 +00:00

243 lines
7.3 KiB
JavaScript

// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
// `api` (the server's /api/v2, as the signed-in account).
import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen, get, why } from "./bridge.js"
import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, renderServer } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
let status = null
let view = "" // "setup" | "app"
let me = null
let models = []
let devices = [] // this account's linked devices
let kind = "agent"
let started = false
let currentChat = null
const remembered = (() => {
try {
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
} catch {
return []
}
})()
/**
* The device ids that are this computer: a linked device whose host is this computer's name (the
* CLI sends it at login). Remembered, so agent chats here are still known as here while the
* agent is off and the device is not linked right now.
*/
function thisDevices() {
let changed = false
for (const d of devices) {
if (status?.host && d.host === status.host && !remembered.includes(d.id)) {
remembered.push(d.id)
changed = true
}
}
if (changed) {
remembered.splice(0, Math.max(0, remembered.length - 8))
try {
localStorage.setItem("lembas.thisDevices", JSON.stringify(remembered))
} catch {}
}
return remembered.slice()
}
function applyTheme(name) {
if (name !== "moria" && name !== "shire") name = "moria"
const root = document.documentElement
root.dataset.theme = name
root.dataset.base = name
}
// --- Views ------------------------------------------------------------------------------------
function route() {
const ready = status.checked && status.discovery && !status.refused && status.account
const want = ready ? "app" : "setup"
if (want !== view) {
view = want
$("setup").hidden = want !== "setup"
$("shell").hidden = want !== "app"
if (want === "app") startApp()
else stopApp()
}
if (view === "setup") renderSetup(status)
if (view === "app") {
if (!$("settings").hidden) renderSettings(status)
localPane()
}
}
async function startApp() {
started = true
try {
me = await get("/me")
applyTheme(me.theme)
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
} catch (err) {
$("thread-note").textContent = why(err)
$("thread-note").hidden = false
}
await Promise.all([loadModels(), loadDevices()])
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
setKind(kind)
renderServer()
invoke("remote_refresh")
}
function stopApp() {
started = false
closeChat()
closeViewer()
}
async function loadModels() {
try {
models = (await get("/models")).models || []
} catch {
models = []
}
}
async function loadDevices() {
try {
devices = (await get("/devices")).devices || []
} catch {
devices = []
}
}
// --- The sidebar --------------------------------------------------------------------------------
function setKind(k) {
kind = k
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
clear($("chat-list"))
refreshList()
// With no chat open, the main view is where a chat of this kind starts.
if (!openChatId() && $("settings").hidden) {
currentChat = null
newChat(k)
localPane()
}
}
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
function localPane(chat) {
const roots = status?.remote?.enabled ? status.remote.roots : []
const c = chat || currentChat
const here = c?.device && thisDevices().includes(c.device.token_id)
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
else showLocal({ roots, root: "", fixed: false })
}
function narrow() {
return window.matchMedia("(max-width: 48rem)").matches
}
function sideOpen(on) {
$("shell").dataset.side = String(on)
$("side-toggle").setAttribute("aria-expanded", String(on))
}
// --- Settings -----------------------------------------------------------------------------------
function showSettings(on) {
$("settings").hidden = !on
$("main").hidden = on
if (on) {
renderSettings(status)
invoke("remote_refresh")
$("settings-close").focus()
}
}
// --- Start --------------------------------------------------------------------------------------
async function boot() {
initBar()
initViewer()
initExplorer()
initSetup(() => status)
initSettings(() => status)
initList({
kind: () => kind,
thisDevices,
current: () => openChatId(),
open: (id) => {
showSettings(false)
if (narrow()) sideOpen(false)
openChat(id).then(markCurrent)
},
})
initChat({
status: () => status,
thisDevices,
models: () => models,
me: () => me,
changed: () => refreshList(),
opened: (chat) => {
currentChat = chat
localPane(chat)
markCurrent()
},
})
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
$("new-chat-btn").prepend(icon(ICONS.plus))
$("new-chat-btn").addEventListener("click", () => {
showSettings(false)
currentChat = null
if (narrow()) sideOpen(false)
newChat(kind)
markCurrent()
localPane()
})
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
$("settings-close").replaceChildren(icon(ICONS.close))
$("settings-close").addEventListener("click", () => showSettings(false))
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
$("scrim").addEventListener("click", () => sideOpen(false))
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
listen("status", (e) => {
const before = status
status = e.payload
renderBar(status)
route()
// The agent came up or went: which devices are linked changes with it.
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
})
listen("open-settings", () => {
if (view === "app") showSettings(true)
})
status = await invoke("get_status")
applyTheme(status.theme)
renderBar(status)
route()
// The list keeps up with replies finished elsewhere (the web interface, the phone).
setInterval(() => {
if (view === "app" && document.visibilityState === "visible") refreshList()
}, 30000)
}
boot()