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>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-28 04:55:03 +00:00
1 parent 96a2abe7e0
commit 4b4e63f622
20 files changed
+4275 -758

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
// makes no request of its own -- the content security policy allows it none but the bridge. The
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
const tauri = window.__TAURI__
export const invoke = tauri.core.invoke
export const listen = tauri.event.listen
export const win = tauri.window.getCurrentWindow()
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
export function api(method, path, body) {
return invoke("api", { method, path, body: body === undefined ? null : body })
}
export const get = (path) => api("GET", path)
export const post = (path, body) => api("POST", path, body ?? {})
export const patch = (path, body) => api("PATCH", path, body)
export const del = (path) => api("DELETE", path)
/** An error's words, whatever shape it came in. */
export function why(err) {
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
return String(err)
}
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
const followers = new Map()
const early = new Map()
listen("stream", (e) => {
const { id, event, data } = e.payload
const f = followers.get(id)
if (f) deliver(f, id, event, data)
else {
if (!early.has(id)) early.set(id, [])
early.get(id).push([event, data])
}
})
function deliver(f, id, event, data) {
if (event === "end") {
followers.delete(id)
f.onEnd(data)
} else f.onFrame(event, data)
}
/**
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
* `close()` stops it (onEnd still comes).
*/
export async function follow(path, { onFrame, onEnd }) {
const id = await invoke("stream_open", { path })
const f = { onFrame, onEnd }
followers.set(id, f)
for (const [event, data] of early.get(id) || []) {
if (!followers.has(id)) break
deliver(f, id, event, data)
}
early.delete(id)
return {
id,
close() {
invoke("stream_close", { id })
},
}
}
/** A web link, in the system's browser. */
export function openLink(url) {
return invoke("open_external", { url }).catch(() => {})
}
+659
View File
@@ -0,0 +1,659 @@
// One chat: its transcript, the reply being written (followed as the server streams it), the
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
//
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
// repository is the contract): a person's message is their text as typed; a reply is its steps --
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
import { $, h, clear, append } from "./dom.js"
import { api, get, post, patch, follow, why, invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal, readServer } from "./explorer.js"
const MODES = ["manual", "edit", "auto", "plan"]
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
return open?.chat?.id || null
}
// --- Reading -----------------------------------------------------------------------------------
function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
function nearBottom() {
const th = $("thread")
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
}
function toBottom(force) {
const th = $("thread")
if (force || nearBottom()) th.scrollTop = th.scrollHeight
}
function userMessage(m) {
return h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
h("div", { class: "msg__text", text: m.text || "" }),
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
}
function metricsLine(x) {
if (!x) return null
const parts = []
if (x.total_tokens) parts.push(t("%(n)s tokens", { n: x.total_tokens.toLocaleString() }))
if (x.tok_s) parts.push(`${x.tok_s} tok/s`)
if (x.elapsed_ms) parts.push(`${(x.elapsed_ms / 1000).toFixed(1)} s`)
if (x.context_limit && x.percent !== undefined) parts.push(`${x.percent} % ctx`)
return parts.length ? h("p", { class: "msg__metrics", text: parts.join(" · ") }) : null
}
function assistantMessage(m, chat) {
const el = h("article", { class: "msg msg--assistant", dataset: { id: m.id } })
const steps = h("div", { class: "msg__steps" })
renderSteps(steps, m.steps || [], chat)
el.append(steps)
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
const meta = metricsLine(m.metrics)
if (meta || m.model) {
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
}
return el
}
function renderSteps(box, steps, chat) {
clear(box)
for (const s of steps) box.append(step(s, chat))
}
function step(s, chat) {
switch (s.kind) {
case "thinking":
return h(
"details",
{ class: "step step--thinking", open: s.open || null },
h("summary", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
h("div", { class: "step__pre", text: s.text || "" }),
)
case "text":
return h("div", { class: "step step--text md" }, markdown(s.markdown || ""))
case "tools":
return h("div", { class: "step step--tools" }, (s.calls || []).map((c) => toolCall(c, chat)))
case "steer":
return h(
"div",
{ class: "step step--steer" },
h("p", { class: "muted", text: t("Taken in while it ran:") }),
(s.messages || []).map((m) => h("p", { class: "steer__text", text: m.text })),
)
default:
return h("div", { class: "step" })
}
}
function shownPath(call) {
return call.show?.path || call.args?.path || call.path || ""
}
function toolCall(c, chat) {
const block = c.block || {}
const failed = c.status === "error" || !!c.error
const body = h("div", { class: "call__body" })
if (c.purpose) body.append(h("p", { class: "muted", text: c.purpose }))
if (c.args?.command) body.append(h("pre", { class: "call__pre", text: c.args.command }))
if (c.diff) body.append(h("pre", { class: "call__pre call__diff", text: typeof c.diff === "string" ? c.diff : JSON.stringify(c.diff, null, 2) }))
if (c.text) body.append(h("pre", { class: "call__pre", text: c.text }))
if (c.error) body.append(h("p", { class: "msg__note is-error", text: typeof c.error === "string" ? c.error : JSON.stringify(c.error) }))
if (c.truncated) body.append(h("p", { class: "muted", text: t("Cut short here; the whole of it is in the web interface.") }))
const isShow = c.name === "show" && !failed && shownPath(c)
const summary = h(
"summary",
{ class: "call__head" },
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title || c.name || "" }),
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
)
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
if (isShow) {
el.append(h("button", { type: "button", class: "btn btn--sm call__open", text: t("Open in the viewer"), onclick: () => openShown(c, chat) }))
}
return el
}
/** A file the agent showed: from this computer when the chat runs here, else from the server. */
async function openShown(call, chat) {
const path = shownPath(call)
const kind = call.show?.kind
const { language } = showKind(path)
const line = call.show?.line || call.args?.line || 0
const base = { name: path.split("/").pop(), path, kind, language: call.show?.language || language, line }
try {
if (onThisComputer(chat) && chat.device.directory) {
const dir = chat.device.directory.replace(/\/+$/, "")
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
const f = await readLocal(dir, rel)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("This computer")}`, text: f.text, withheld: f.withheld, size: f.size })
} else if (call.show?.shown_id) {
// The copy a device sent (server: `GET /api/chats/{chat}/shown/{id}` is the web UI's; the
// app's door does not have it yet -- see SERVER_FILES in explorer.js).
const f = await get(`/chats/${encodeURIComponent(chat.id)}/shown/${encodeURIComponent(call.show.shown_id)}`)
openViewer({ ...base, where: t("Shown by the agent"), text: f.text })
} else {
const f = await readServer(path)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text: f.text, withheld: f.withheld, size: f.size })
}
} catch (err) {
openViewer({ ...base, where: t("Shown by the agent"), error: why(err) })
}
}
// --- The reply being written ---------------------------------------------------------------------
class Live {
constructor(chat, messageId) {
this.chat = chat
this.id = messageId
this.steps = h("div", { class: "msg__steps" })
this.tail = h("div", { class: "step step--text md msg__tail" })
this.thinking = h("p", { class: "msg__thinking muted" })
this.status = h("p", { class: "msg__status muted", text: t("Writing…") })
this.jobs = h("ul", { class: "msg__jobs", hidden: true })
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
this.done = false
this.handle = null
}
async start() {
try {
this.handle = await follow(`/chats/${encodeURIComponent(this.chat.id)}/messages/${encodeURIComponent(this.id)}/stream`, {
onFrame: (event, data) => this.frame(event, data),
onEnd: (err) => this.end(err),
})
} catch (err) {
this.end(err)
}
}
stop() {
this.done = true
this.handle?.close()
}
frame(event, data) {
if (this.done) return
switch (event) {
case "steps":
renderSteps(this.steps, data.steps || [], this.chat)
this.tailed(data.tail)
break
case "tail":
this.tailed(data)
break
case "status":
this.status.textContent = data.text || t("Writing…")
break
case "ask":
renderCards(this.chat, data.cards || [])
break
case "jobs":
this.jobs.hidden = !(data.jobs || []).length
append(
clear(this.jobs),
(data.jobs || []).map((j) =>
h("li", { class: `job job--${j.state}` }, h("span", { class: "chip", text: j.state }), h("span", { text: j.description || j.command || j.agent || "" })),
),
)
break
case "steered":
for (const id of data.ids || []) document.querySelector(`.msg--user[data-id="${CSS.escape(id)}"]`)?.classList.remove("is-queued")
break
case "done":
this.done = true
this.el.replaceWith(assistantMessage(data.message, this.chat))
renderCards(this.chat, [])
if (data.chat?.title) setTitle(data.chat.title)
ctx.changed(this.chat)
if (open?.live === this) open.live = null
setRunning(false)
// A message queued behind this reply has one of its own now: read the chat again.
if (data.next) reload()
break
default:
break
}
toBottom(false)
}
tailed(tail) {
if (!tail) return
this.thinking.textContent = tail.thinking ? `${t("Thinking")} ${tail.label || ""}…` : ""
this.thinking.hidden = !tail.thinking
clear(this.tail).append(markdown(tail.text || ""))
}
end(err) {
if (this.done) return
this.done = true
if (open?.live === this) open.live = null
setRunning(false)
if (err) {
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
this.status.classList.add("is-error")
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
} else reload()
}
}
function setRunning(on) {
$("send").hidden = on && !$("composer-text").value.trim()
$("stop").hidden = !on
$("composer").dataset.running = String(on)
}
function follower(chat, messageId) {
if (open?.live) open.live.stop()
const live = new Live(chat, messageId)
open.live = live
$("messages").append(live.el)
setRunning(true)
live.start()
toBottom(true)
}
// --- Cards ----------------------------------------------------------------------------------------
function renderCards(chat, cards) {
const box = clear($("cards"))
box.hidden = !cards.length
for (const card of cards) box.append(cardOf(chat, card))
}
function cardOf(chat, card) {
const answers = { choices: {}, texts: {} }
const decides = card.items.some((i) => i.kind === "approval" || i.kind === "plan")
const el = h("section", { class: `ask ask--${card.kind}`, "aria-label": t("Waiting for your answer") })
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
for (const item of card.items) {
const part = h("div", { class: "ask__item" })
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
if (item.kind === "approval") {
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
if (item.editable) {
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
edit.addEventListener("input", () => {
if (edit.value !== item.title) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else part.append(h("pre", { class: "call__pre", text: item.title }))
} else if (item.kind === "plan" && item.plan) {
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
} else {
part.append(h("p", { class: "ask__title", text: item.title }))
}
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
if (item.options?.length) {
const picked = new Set()
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
for (const o of item.options) {
const b = h(
"button",
{ type: "button", class: "opt", "aria-pressed": "false" },
h("span", { class: "opt__label", text: o.label }),
o.description ? h("span", { class: "opt__desc", text: o.description }) : null,
o.recommended ? h("span", { class: "chip", text: t("Recommended") }) : null,
)
b.addEventListener("click", () => {
if (!item.multiple) {
picked.clear()
for (const x of opts.children) x.setAttribute("aria-pressed", "false")
}
if (picked.has(o.label)) picked.delete(o.label)
else picked.add(o.label)
b.setAttribute("aria-pressed", String(picked.has(o.label)))
answers.choices[item.key] = [...picked]
})
opts.append(b)
}
part.append(opts)
}
if (item.free_text && item.kind === "question") {
const typed = h("input", { type: "text", class: "ask__text", placeholder: t("Or type an answer"), "aria-label": t("Your answer") })
typed.addEventListener("input", () => {
answers.texts[item.key] = typed.value
})
part.append(typed)
}
el.append(part)
}
const said = h("p", { class: "ask__said", "aria-live": "polite" })
const send = async (verdict) => {
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = true
try {
await post(`/chats/${encodeURIComponent(chat.id)}/interactions/${encodeURIComponent(card.id)}`, {
verdict,
choices: answers.choices,
texts: answers.texts,
})
el.remove()
if (!$("cards").children.length) $("cards").hidden = true
} catch (err) {
if (err?.status === 409) {
el.remove()
reload()
return
}
said.textContent = why(err)
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = false
}
}
const actions = h("div", { class: "ask__actions" })
if (decides) {
const isPlan = card.items.some((i) => i.kind === "plan")
append(
actions,
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
)
} else {
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
}
el.append(actions, said)
return el
}
function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
$("chat-title").textContent = title || t("New chat")
$("chat-title").title = title || ""
}
function renderHead(chat) {
setTitle(chat.title)
const place = clear($("chat-place"))
const tools = clear($("chat-tools"))
if (chat.kind === "agent" && chat.device) {
const here = onThisComputer(chat)
place.append(
h("span", { class: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
)
const mode = h(
"select",
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
)
tools.append(mode)
} else if (chat.model) {
place.append(h("span", { class: "chip", text: chat.model }))
}
}
function modeName(m) {
return { manual: "Manual", edit: "Edit", auto: "Auto", plan: "Plan" }[m] || m
}
function alertIn(text) {
const box = $("thread-note")
box.textContent = text
box.hidden = !text
}
/** Open a chat by id: its newest page, and the reply being written, if one is. */
export async function openChat(id) {
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
invoke("set_open_chat", { chat: id })
showComposer(true)
$("new-chat").hidden = true
const list = clear($("messages"))
renderCards({ id }, [])
alertIn("")
list.append(h("p", { class: "muted thread__loading", text: t("Loading…") }))
let r
try {
r = await get(`/chats/${encodeURIComponent(id)}`)
} catch (err) {
clear(list)
alertIn(why(err))
return
}
if (open?.chat?.id !== id) return
open.chat = r.chat
open.more = r.more
renderHead(r.chat)
clear(list)
if (r.more) list.append(earlier())
for (const m of r.messages) {
if (m.id === r.live) continue
list.append(m.role === "user" ? userMessage(m) : assistantMessage(m, r.chat))
}
ctx.opened(r.chat)
if (r.live) follower(r.chat, r.live)
else setRunning(false)
toBottom(true)
$("composer-text").focus()
}
function earlier() {
return h("button", {
type: "button",
class: "btn btn--sm thread__earlier",
text: t("Earlier messages"),
onclick: async (e) => {
const first = $("messages").querySelector(".msg")?.dataset.id
if (!first || !open) return
e.target.disabled = true
try {
const r = await get(`/chats/${encodeURIComponent(open.chat.id)}/messages?before=${encodeURIComponent(first)}`)
const th = $("thread")
const from = th.scrollHeight
const frag = document.createDocumentFragment()
if (r.more) frag.append(earlier())
for (const m of r.messages) frag.append(m.role === "user" ? userMessage(m) : assistantMessage(m, open.chat))
e.target.replaceWith(frag)
th.scrollTop += th.scrollHeight - from
} catch (err) {
e.target.disabled = false
alertIn(why(err))
}
},
})
}
function reload() {
if (open?.chat?.id) openChat(open.chat.id)
}
export function closeChat() {
if (open?.live) open.live.stop()
open = null
invoke("set_open_chat", { chat: null })
}
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind }
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
export function newChat(kind) {
closeChat()
draft = { kind }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
clear($("chat-place"))
clear($("chat-tools"))
const form = clear($("new-chat"))
form.hidden = false
const s = ctx.status()
const models = ctx.models()
const modelField = field(
t("Model"),
h(
"select",
{ id: "new-model" },
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
),
models.length ? "" : t("No model is available to this account."),
)
if (kind === "agent") {
const roots = s.remote?.enabled ? s.remote.roots : []
const devices = ctx.thisDevices()
form.append(
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
)
if (!devices.length) {
form.append(
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
)
}
form.append(
h(
"div",
{ class: "fields" },
field(
t("Folder"),
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
),
field(
t("Mode"),
h(
"select",
{ id: "new-mode" },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
),
t("A chat asking for more than the folder allows is lowered to it."),
),
modelField,
),
)
} else {
form.append(h("div", { class: "fields" }, modelField))
}
showComposer(true)
setRunning(false)
invoke("set_open_chat", { chat: null })
$("composer-text").focus()
}
/** A field: label, control, hint -- always all three, so a row of them lines up. */
function field(label, control, hint) {
const id = control.id || `f${Math.random().toString(36).slice(2)}`
control.id = id
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
}
async function createChat(text) {
const body = { text }
const model = $("new-model")?.value
if (model) body.model = model
if (draft.kind === "agent") {
const device = ctx.thisDevices()[0]
const dir = $("new-dir")?.value
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
}
const r = await post("/chats", body)
draft = null
$("new-chat").hidden = true
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
renderHead(r.chat)
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))
ctx.changed(r.chat)
ctx.opened(r.chat)
if (r.reply) follower(r.chat, r.reply.id)
}
// --- The composer ------------------------------------------------------------------------------------
function showComposer(on) {
$("composer").hidden = !on
}
async function submit() {
const box = $("composer-text")
const text = box.value.trim()
if (!text) return
$("send").disabled = true
alertIn("")
try {
if (draft) await createChat(text)
else if (open?.chat?.id) {
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
}
box.value = ""
grow()
toBottom(true)
} catch (err) {
alertIn(why(err))
} finally {
$("send").disabled = false
setRunning(!!open?.live)
}
}
function grow() {
const box = $("composer-text")
box.style.height = "auto"
box.style.height = `${Math.min(box.scrollHeight, window.innerHeight * 0.4)}px`
}
export function initChat(context) {
ctx = context
$("composer").addEventListener("submit", (e) => {
e.preventDefault()
submit()
})
$("composer-text").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault()
submit()
}
})
$("composer-text").addEventListener("input", () => {
grow()
if (open?.live) $("send").hidden = !$("composer-text").value.trim()
})
$("stop").addEventListener("click", async () => {
const live = open?.live
if (!live) return
try {
await api("POST", `/chats/${encodeURIComponent(live.chat.id)}/messages/${encodeURIComponent(live.id)}/stop`)
} catch (err) {
alertIn(why(err))
}
})
showComposer(false)
}
+104
View File
@@ -0,0 +1,104 @@
// Source in the viewer: CodeMirror, read-only, from the server's own vendored build (ui/vendor,
// copied and pinned by scripts/fetch-vendor.sh). The look is the server's editor's: colours from
// the theme's tokens, never CodeMirror's own. Loaded the first time a file is shown.
let cm = null
async function load() {
if (cm) return cm
const [state, view, language, search, commands, highlight] = await Promise.all([
import("../vendor/cm-state.mjs"),
import("../vendor/cm-view.mjs"),
import("../vendor/cm-language.mjs"),
import("../vendor/cm-search.mjs"),
import("../vendor/cm-commands.mjs"),
import("../vendor/lezer-highlight.mjs"),
])
const { EditorView } = view
const { HighlightStyle, syntaxHighlighting } = language
const { tags } = highlight
// As the server's static/js/editor.js has them.
const look = EditorView.theme({
"&": { color: "var(--ink)", backgroundColor: "var(--code-bg)", fontSize: "var(--text-sm)", height: "100%" },
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "var(--leading-normal)" },
".cm-content": { caretColor: "var(--accent)" },
".cm-gutters": { backgroundColor: "var(--code-bg)", color: "var(--ink-faint)", border: "none" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--surface-hover)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--selection)" },
"&.cm-focused": { outline: "none" },
".cm-panels": { backgroundColor: "var(--surface)", color: "var(--ink)" },
".cm-searchMatch": { backgroundColor: "var(--accent-soft)" },
})
const colours = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: "var(--accent)" },
{ tag: [tags.string, tags.special(tags.string)], color: "var(--leaf)" },
{ tag: [tags.comment], color: "var(--ink-faint)", fontStyle: "italic" },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--warning)" },
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: "var(--ink)", fontWeight: "600" },
{ tag: [tags.heading], color: "var(--accent)", fontWeight: "700" },
{ tag: [tags.link, tags.url], color: "var(--accent)", textDecoration: "underline" },
{ tag: [tags.tagName, tags.typeName, tags.className], color: "var(--accent-hover)" },
{ tag: [tags.invalid], color: "var(--danger)" },
])
cm = {
state,
view,
base: [
view.lineNumbers(),
view.highlightActiveLine(),
view.highlightActiveLineGutter(),
view.drawSelection(),
view.keymap.of([...search.searchKeymap, ...commands.defaultKeymap]),
search.highlightSelectionMatches(),
state.EditorState.readOnly.of(true),
view.EditorView.editable.of(false),
view.EditorView.lineWrapping,
look,
syntaxHighlighting(colours),
],
}
return cm
}
/** The language support for a name as `show` gives it; nothing for one without support. */
async function language(name) {
try {
switch (name) {
case "markdown":
return (await import("../vendor/cm-lang-markdown.mjs")).markdown()
case "python":
return (await import("../vendor/cm-lang-python.mjs")).python()
case "javascript":
case "jsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ jsx: name === "jsx" })
case "typescript":
case "tsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ typescript: true, jsx: name === "tsx" })
case "html":
return (await import("../vendor/cm-lang-html.mjs")).html()
case "css":
case "scss":
return (await import("../vendor/cm-lang-css.mjs")).css()
case "json":
return (await import("../vendor/cm-lang-json.mjs")).json()
default:
return []
}
} catch {
return []
}
}
/** Show `text` in `parent`, read-only, at `line` when given. Returns something to `destroy()`. */
export async function codeView(parent, text, { lang = "", line = 0 } = {}) {
const { state, view, base } = await load()
const ed = new view.EditorView({
parent,
state: state.EditorState.create({ doc: text, extensions: [base, await language(lang)] }),
})
if (line > 0 && line <= ed.state.doc.lines) {
const at = ed.state.doc.line(line).from
ed.dispatch({ selection: { anchor: at }, effects: view.EditorView.scrollIntoView(at, { y: "center" }) })
}
return ed
}
+114
View File
@@ -0,0 +1,114 @@
// Building the page: elements made in code, text always as text. Nothing here ever parses a
// string as HTML -- what the server sends (a reply, a file, a title) is model output or the
// person's own words, and is set with textContent, never innerHTML.
export const $ = (id) => document.getElementById(id)
/**
* h("button", { class: "btn", onclick: fn, "aria-label": "…" }, "text", child, [more])
* Attributes: `class`, `text`, `hidden`, `on<event>` (a function), `dataset` (an object), any
* other as an attribute (false/null/undefined leave it out, true sets it empty).
*/
export function h(tag, attrs, ...children) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(attrs || {})) {
if (value === null || value === undefined || value === false) continue
if (key === "class") el.className = value
else if (key === "text") el.textContent = value
else if (key === "dataset") Object.assign(el.dataset, value)
else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value)
else if (key === "hidden") el.hidden = !!value
else if (key === "value") el.value = value
else if (key === "checked") el.checked = !!value
else if (key === "disabled") el.disabled = !!value
else el.setAttribute(key, value === true ? "" : String(value))
}
append(el, children)
return el
}
export function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
}
return el
}
export function clear(el) {
el.replaceChildren()
return el
}
/** An inline SVG icon from a path list, drawn in currentColor. */
export function icon(d, label) {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("viewBox", "0 0 16 16")
svg.setAttribute("aria-hidden", "true")
svg.classList.add("icon")
for (const one of [].concat(d)) {
const p = document.createElementNS(ns, "path")
p.setAttribute("d", one)
p.setAttribute("fill-rule", "evenodd")
svg.append(p)
}
if (label) {
svg.removeAttribute("aria-hidden")
svg.setAttribute("role", "img")
svg.setAttribute("aria-label", label)
}
return svg
}
export const ICONS = {
plus: "M7.25 3h1.5v4.25H13v1.5H8.75V13h-1.5V8.75H3v-1.5h4.25z",
close: "M4.5 3.4L8 6.9l3.5-3.5 1.1 1.1L9.1 8l3.5 3.5-1.1 1.1L8 9.1l-3.5 3.5-1.1-1.1L6.9 8 3.4 4.5z",
folder: "M1.5 3h5l1.5 1.5h6.5v8.5h-13zM3 6v5.5h10V6z",
file: "M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z",
chevron: "M6 3.5L10.5 8 6 12.5 4.9 11.4 8.3 8 4.9 4.6z",
menu: "M3 4h10v1.5H3zM3 7.25h10v1.5H3zM3 10.5h10V12H3z",
side: "M2 3h12v10H2zM3.5 4.5v7H6v-7z",
gear: "M7 1.5h2l.4 1.8 1.1.5 1.6-1 1.4 1.4-1 1.6.5 1.1 1.8.4v2l-1.8.4-.5 1.1 1 1.6-1.4 1.4-1.6-1-1.1.5L9 14.5H7l-.4-1.8-1.1-.5-1.6 1-1.4-1.4 1-1.6-.5-1.1-1.8-.4v-2l1.8-.4.5-1.1-1-1.6 1.4-1.4 1.6 1 1.1-.5zM8 5.75a2.25 2.25 0 100 4.5 2.25 2.25 0 000-4.5z",
refresh: "M8 2.5a5.5 5.5 0 015.2 3.7H11.5v1.5H15V4.2h-1.5v1A7 7 0 001 8h1.5A5.5 5.5 0 018 2.5zM2.5 11.8v-1h1.7A5.5 5.5 0 0013.5 8H15A7 7 0 012.5 11.8z",
min: "M4 10h8v1.5H4z",
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
}
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
export function when(stamp) {
if (!stamp) return ""
const then = new Date(stamp)
if (Number.isNaN(then.getTime())) return ""
const seconds = Math.round((then.getTime() - Date.now()) / 1000)
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
const steps = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.35, "week"],
[12, "month"],
[Infinity, "year"],
]
let value = seconds
for (const [size, unit] of steps) {
if (Math.abs(value) < size) return rtf.format(Math.round(value), unit)
value /= size
}
return ""
}
/** A size in bytes, as a person reads it. */
export function bytes(n) {
if (!Number.isFinite(n)) return ""
const units = ["B", "KB", "MB", "GB"]
let i = 0
let v = n
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
+168
View File
@@ -0,0 +1,168 @@
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the server's
// tree, for the chats, read through /api/v2. A file opens in the viewer either way.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, why } from "./bridge.js"
import { openViewer } from "./viewer.js"
// The server's tree as the app's door gives it. `/api/v2` has no file routes yet (the web UI's
// /files pages take the session cookie and answer HTML); these are the routes this pane is written
// against, and a 404 says "not on this server yet" instead of failing:
// GET /files?path=/a -> { path, entries: [{ name, path, kind: "dir"|"file", size, mime }] }
// GET /files/content?path= -> { path, name, size, text | null, withheld?: "binary"|"too_big" }
export const SERVER_FILES = {
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
}
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const level = async (parent, path, depth) => {
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
return
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": e.kind === "dir" ? "false" : null },
h("span", { class: "tree__chevron" }, e.kind === "dir" ? icon(ICONS.chevron) : null),
icon(e.kind === "dir" ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: e.kind === "dir" ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
if (e.kind === "dir") {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
kids?.remove()
kids = null
return
}
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
}
// --- This computer ------------------------------------------------------------------------------
let local = { root: "", roots: [], fixed: false }
/**
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
*/
export function showLocal({ roots, root, fixed }) {
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
if (changed) renderLocal()
}
function renderLocal() {
const head = clear($("local-where"))
const box = $("local-tree")
if (!local.roots.length) {
clear(box).append(
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
)
return
}
if (local.fixed) {
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
} else {
const pick = h(
"select",
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
)
head.append(pick)
}
const root = local.root
tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
const where = t("This computer")
try {
const f = await invoke("local_read", { root, path: e.path })
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
export async function readLocal(root, path) {
return invoke("local_read", { root, path })
}
// --- The server ---------------------------------------------------------------------------------
export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
rootPath: "/",
list: async (path) => {
try {
const r = await get(SERVER_FILES.list(path))
return r.entries || []
} catch (err) {
if (err && (err.status === 404 || err.status === 405)) {
throw { detail: t("This server does not offer its files to the app yet.") }
}
throw err
}
},
open: async (e) => {
const where = t("Server")
try {
const f = await readServer(e.path)
openViewer({ name: f.name || e.name, path: f.path || e.path, where, text: f.text, withheld: f.withheld, size: f.size })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
export function initExplorer() {
for (const pane of document.querySelectorAll(".pane")) {
const toggle = pane.querySelector(".pane__toggle")
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
toggle.addEventListener("click", () => {
const open = pane.dataset.open !== "true"
pane.dataset.open = String(open)
toggle.setAttribute("aria-expanded", String(open))
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
})
}
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}
+81
View File
@@ -0,0 +1,81 @@
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
// for) or chats on the server. Agent chats on this computer come first; those on another device
// of the account follow under their own heading.
import { $, h, clear, when, append } from "./dom.js"
import { get, why } from "./bridge.js"
let ctx = null // { kind(), thisDevices(), open(id), current() }
let rows = []
let more = false
/** The list again, from the server. */
export async function refreshList() {
const kind = ctx.kind()
try {
const r = await get(`/chats?kind=${kind}&limit=50`)
if (ctx.kind() !== kind) return
rows = r.chats || []
more = !!r.more
render()
} catch (err) {
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
}
}
async function loadMore() {
const last = rows[rows.length - 1]
if (!last) return
const kind = ctx.kind()
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
if (ctx.kind() !== kind) return
rows = rows.concat(r.chats || [])
more = !!r.more
render()
}
export function markCurrent() {
const id = ctx.current()
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
function row(c) {
const b = h(
"button",
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
h("span", { class: "row__title", text: c.title || t("New chat") }),
h("span", { class: "row__when", text: when(c.updated_at) }),
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
)
b.addEventListener("click", () => ctx.open(c.id))
return h("li", null, b)
}
function render() {
const list = clear($("chat-list"))
const kind = ctx.kind()
const visible = rows.filter((r) => !r.archived && !r.temporary)
if (!visible.length) {
list.append(
h("li", {
class: "list__note",
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
}),
)
return
}
if (kind === "agent") {
const mine = ctx.thisDevices()
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
} else append(list, visible.map(row))
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
markCurrent()
}
export function initList(context) {
ctx = context
}
+242
View File
@@ -0,0 +1,242 @@
// 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()
+347
View File
@@ -0,0 +1,347 @@
// Markdown, as a model writes it, to elements -- never to an HTML string. A reply is model
// output and untrusted: no raw HTML passes (it shows as the text it is), no image is fetched
// (an image is its link), and a link opens in the system's browser only when it is http, https
// or mail. What it does draw: headings, paragraphs, emphasis, strike-through, inline code, fenced
// code, block quotes, lists (nested), tables, rules, links and line breaks. A reply still being
// written renders the same way; an unclosed `*` is simply a star.
import { h } from "./dom.js"
import { openLink } from "./bridge.js"
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?\s*#*\s*$/
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
const QUOTE = /^ {0,3}> ?/
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
const SAFE_LINK = /^(https?:|mailto:)/i
/** The rendered fragment of `source`. */
export function markdown(source) {
const out = document.createDocumentFragment()
blocks(String(source ?? "").replace(/\r\n?/g, "\n").split("\n"), out)
return out
}
function indentOf(line) {
const m = /^\s*/.exec(line)
return m ? m[0].replace(/\t/g, " ").length : 0
}
function startsBlock(line, next) {
return (
FENCE.test(line) ||
HEADING.test(line) ||
RULE.test(line) ||
QUOTE.test(line) ||
ITEM.test(line) ||
(line.includes("|") && next !== undefined && TABLE_RULE.test(next) && next.includes("-"))
)
}
function blocks(lines, parent) {
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) {
i += 1
continue
}
const fence = FENCE.exec(line)
if (fence) {
const mark = fence[1]
const body = []
i += 1
while (i < lines.length && !new RegExp(`^ {0,3}${mark[0]}{${mark.length},}\\s*$`).test(lines[i])) {
body.push(lines[i])
i += 1
}
i += 1 // the closing fence (or the end)
parent.append(codeBlock(body.join("\n"), fence[2]))
continue
}
const heading = HEADING.exec(line)
if (heading) {
parent.append(h(`h${heading[1].length}`, null, inline(heading[2] || "")))
i += 1
continue
}
if (RULE.test(line)) {
parent.append(h("hr"))
i += 1
continue
}
if (QUOTE.test(line)) {
const inner = []
while (i < lines.length && lines[i].trim() && (QUOTE.test(lines[i]) || !startsBlock(lines[i], lines[i + 1]))) {
inner.push(lines[i].replace(QUOTE, ""))
i += 1
}
const quote = h("blockquote")
blocks(inner, quote)
parent.append(quote)
continue
}
if (ITEM.test(line)) {
i = list(lines, i, parent)
continue
}
if (line.includes("|") && TABLE_RULE.test(lines[i + 1] || "") && (lines[i + 1] || "").includes("-")) {
i = table(lines, i, parent)
continue
}
const para = [line]
i += 1
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
para.push(lines[i])
i += 1
}
parent.append(h("p", null, inline(para.join("\n"))))
}
}
function codeBlock(text, lang) {
const code = h("code", { text })
const copy = h("button", {
type: "button",
class: "md-copy",
text: t("Copy"),
onclick: () => navigator.clipboard?.writeText(text).catch(() => {}),
})
return h("div", { class: "md-code" }, lang ? h("span", { class: "md-lang", text: lang }) : null, copy, h("pre", null, code))
}
function list(lines, start, parent) {
const first = ITEM.exec(lines[start])
const base = indentOf(first[1])
const ordered = /\d/.test(first[2])
const el = h(ordered ? "ol" : "ul")
if (ordered) {
const n = parseInt(first[2], 10)
if (n !== 1) el.setAttribute("start", String(n))
}
let i = start
while (i < lines.length) {
const m = ITEM.exec(lines[i])
if (!m || indentOf(m[1]) !== base || /\d/.test(m[2]) !== ordered) break
const content = [m[3]]
const width = indentOf(m[1]) + m[2].length + 1
i += 1
while (i < lines.length) {
const l = lines[i]
if (!l.trim()) {
// A blank line inside an item, when the item goes on indented after it.
if (i + 1 < lines.length && indentOf(lines[i + 1]) > base && lines[i + 1].trim()) {
content.push("")
i += 1
continue
}
break
}
const sub = ITEM.exec(l)
if (sub && indentOf(sub[1]) <= base) break
if (indentOf(l) <= base && startsBlock(l, lines[i + 1])) break
content.push(l.slice(Math.min(width, indentOf(l))))
i += 1
}
const li = h("li")
const task = /^\[([ xX])\]\s+/.exec(content[0])
if (task) {
content[0] = content[0].slice(task[0].length)
li.classList.add("md-task")
li.append(h("input", { type: "checkbox", disabled: true, checked: task[1] !== " " }))
}
// A tight item is its text; one with blocks in it is blocks.
if (content.length === 1 || !content.slice(1).some((c) => startsBlock(c) || !c.trim())) {
li.append(...inline(content.join("\n")))
} else blocks(content, li)
el.append(li)
while (i < lines.length && !lines[i].trim()) {
const next = ITEM.exec(lines[i + 1] || "")
if (next && indentOf(next[1]) === base) i += 1
else break
}
}
parent.append(el)
return i
}
function cells(line) {
let s = line.trim()
if (s.startsWith("|")) s = s.slice(1)
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"))
}
function table(lines, start, parent) {
const head = cells(lines[start])
const aligns = cells(lines[start + 1]).map((c) =>
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "",
)
let i = start + 2
const rows = []
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
rows.push(cells(lines[i]))
i += 1
}
const cell = (tag, text, n) => {
const c = h(tag, null, inline(text ?? ""))
if (aligns[n]) c.dataset.align = aligns[n]
return c
}
const tbl = h(
"table",
null,
h("thead", null, h("tr", null, head.map((c, n) => cell("th", c, n)))),
h("tbody", null, rows.map((r) => h("tr", null, head.map((_, n) => cell("td", r[n], n))))),
)
// A wide table scrolls by itself; the page does not.
parent.append(h("div", { class: "md-table" }, tbl))
return i
}
function link(label, url) {
const safe = SAFE_LINK.test(url)
const a = h("a", { href: safe ? url : null, title: url, class: safe ? null : "md-unsafe" }, label)
if (safe) {
a.addEventListener("click", (e) => {
e.preventDefault()
openLink(url)
})
}
return a
}
/** The closing `mark` for an opening at `from`, or -1. */
function closing(text, mark, from) {
let at = text.indexOf(mark, from)
while (at !== -1) {
const before = text[at - 1]
if (at > from && before !== " " && before !== "\\") {
if (mark !== "_" || !/\w/.test(text[at + 1] || "")) return at
}
at = text.indexOf(mark, at + 1)
}
return -1
}
/** Inline markdown to nodes. */
export function inline(text) {
const out = []
let plain = ""
const flush = () => {
if (plain) out.push(document.createTextNode(plain))
plain = ""
}
let i = 0
while (i < text.length) {
const c = text[i]
const rest = text.slice(i)
if (c === "\\" && i + 1 < text.length) {
if (text[i + 1] === "\n") {
flush()
out.push(h("br"))
i += 2
continue
}
if (/[\\`*_{}[\]()#+\-.!|~<>]/.test(text[i + 1])) {
plain += text[i + 1]
i += 2
continue
}
}
if (c === "`") {
const run = /^`+/.exec(rest)[0]
const end = text.indexOf(run, i + run.length)
if (end !== -1) {
flush()
out.push(h("code", { text: text.slice(i + run.length, end).replace(/^ (.+) $/, "$1") }))
i = end + run.length
continue
}
plain += run
i += run.length
continue
}
const img = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (img) {
flush()
out.push(link(img[1] || img[2], img[2]))
i += img[0].length
continue
}
if (c === "[") {
const m = /^\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (m) {
flush()
out.push(link(inline(m[1]), m[2]))
i += m[0].length
continue
}
}
if (c === "<") {
const m = /^<((?:https?:\/\/|mailto:)[^\s>]+)>/i.exec(rest)
if (m) {
flush()
out.push(link(m[1], m[1]))
i += m[0].length
continue
}
}
if ((c === "h" || c === "H") && !/\w/.test(text[i - 1] || "")) {
const m = /^https?:\/\/[^\s<]+/i.exec(rest)
if (m) {
let url = m[0]
// Trailing punctuation belongs to the sentence, and an unmatched ")" to a parenthesis.
while (/[.,;:!?'"]$/.test(url) || (url.endsWith(")") && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length)) {
url = url.slice(0, -1)
}
flush()
out.push(link(url, url))
i += url.length
continue
}
}
const strong = rest.startsWith("**") ? "**" : rest.startsWith("__") && !/\w/.test(text[i - 1] || "") ? "__" : ""
if (strong && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, strong, i + 2)
if (end !== -1) {
flush()
out.push(h("strong", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if (rest.startsWith("~~") && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, "~~", i + 2)
if (end !== -1) {
flush()
out.push(h("del", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if ((c === "*" || (c === "_" && !/\w/.test(text[i - 1] || ""))) && text[i + 1] && !/\s/.test(text[i + 1]) && text[i + 1] !== c) {
const end = closing(text, c, i + 1)
if (end !== -1) {
flush()
out.push(h("em", null, inline(text.slice(i + 1, end))))
i = end + 1
continue
}
}
if (c === "\n") {
if (plain.endsWith(" ")) {
plain = plain.replace(/ +$/, "")
flush()
out.push(h("br"))
} else plain += " "
i += 1
continue
}
plain += c
i += 1
}
flush()
return out
}
+178
View File
@@ -0,0 +1,178 @@
// 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())
})
}
+112
View File
@@ -0,0 +1,112 @@
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
// server's device login: the approval page opens in the system's browser; this page shows the
// code to compare and waits.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
let code = null // the code being approved, while signing in
let failed = ""
export function renderSetup(s) {
const box = clear($("setup-body"))
$("setup-version").textContent = s.version
const server = h("section", { "aria-labelledby": "setup-server-h" }, h("h2", { id: "setup-server-h", text: t("Server") }))
const address = h("input", {
id: "address",
name: "address",
type: "text",
inputmode: "url",
autocomplete: "url",
placeholder: "https://ai.example",
spellcheck: "false",
value: s.server || "",
})
const hint = h("p", { class: "hint", id: "server-hint", "aria-live": "polite" })
if (!s.checked) hint.textContent = t("Looking for a LLeMbas server there…")
else if (s.refused) {
hint.textContent = s.refused
hint.classList.add("is-error")
} else if (s.discovery) hint.textContent = t("%(name)s, LLeMbas %(version)s.", { name: s.discovery.name, version: s.discovery.version })
else hint.textContent = t("LLeMbas Desktop needs a LLeMbas 2.0 server or later.")
const connect = h("button", { type: "submit", class: "btn btn--primary", text: t("Connect") })
const form = h(
"form",
{ class: "field", novalidate: true },
h("label", { for: "address", text: t("Address of your LLeMbas server") }),
h("div", { class: "row" }, address, connect),
hint,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
connect.disabled = true
hint.className = "hint"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: address.value })
} catch (err) {
hint.className = "hint is-error"
hint.textContent = why(err)
} finally {
connect.disabled = false
}
})
server.append(form)
if (s.refused && s.server) {
server.append(h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Try again"), onclick: () => invoke("recheck_server") })))
}
box.append(server)
if (s.discovery && !s.account) {
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
sign.append(h("p", { class: "muted", text: t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
if (s.signing_in && code) {
sign.append(
h("p", null, t("Approve this code in the page that opened in your browser:")),
h("p", { class: "code", text: code.user_code }),
h(
"div",
{ class: "row--buttons" },
h("button", { type: "button", class: "btn", text: t("Open the page again"), onclick: () => invoke("open_external", { url: code.verification_uri_complete }) }),
h("button", { type: "button", class: "btn", text: t("Cancel"), onclick: () => invoke("sign_in_cancel") }),
),
)
} else {
sign.append(
h(
"div",
{ class: "row--buttons" },
h("button", {
type: "button",
class: "btn btn--primary",
text: s.signing_in ? t("Waiting for the browser…") : t("Sign in"),
disabled: s.signing_in,
onclick: () => {
failed = ""
invoke("sign_in").catch((err) => {
failed = why(err)
renderSetup(s)
})
},
}),
),
)
}
if (failed) sign.append(h("p", { class: "status is-error", text: failed }))
box.append(sign)
}
}
export function initSetup(current) {
listen("signin", (e) => {
const p = e.payload
if (p.state === "code") code = p.code
if (p.state === "done") code = null
if (p.state === "failed") {
code = null
failed = p.message
}
renderSetup(current())
})
}
+62
View File
@@ -0,0 +1,62 @@
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
// does that for an undecorated one); the menu and the agent pill open the app's native menu.
import { $, icon, ICONS } from "./dom.js"
import { invoke, listen, win } from "./bridge.js"
const WORDS = {
off: () => t("agent off"),
not_linked: () => t("not linked"),
starting: () => t("agent starting…"),
running: () => t("agent running"),
elsewhere: () => t("agent running (service)"),
failed: () => t("agent stopped"),
no_folders: () => t("agent: no folders"),
}
export function renderBar(s) {
let host = ""
try {
host = s.server ? new URL(s.server).host : ""
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
// Connected but allowed into no folder: a green light would say it works when it cannot.
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
$("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
$("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
}
function maximized(on) {
const b = $("maximize")
b.replaceChildren(icon(on ? ICONS.restore : ICONS.max))
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
b.title = on ? t("Restore") : t("Maximise")
}
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
function menuAt(button) {
const r = button.getBoundingClientRect()
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
}
export function initBar() {
$("minimize").replaceChildren(icon(ICONS.min))
$("close").replaceChildren(icon(ICONS.close))
$("menu").replaceChildren(icon(ICONS.menu))
$("side-toggle").replaceChildren(icon(ICONS.side))
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
listen("maximized", (e) => maximized(!!e.payload))
win.isMaximized().then(maximized).catch(() => maximized(false))
}
+119
View File
@@ -0,0 +1,119 @@
// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
// out. What a file says is never part of the app's page.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { codeView } from "./code.js"
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
const LANGUAGES = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
export function showKind(path) {
const name = String(path).split("/").pop() || ""
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
return language ? { kind: "code", language } : { kind: "text", language: "" }
}
let current = null // { editor, sealed }
let onChange = () => {}
/** Called when the viewer opens or closes (the layout makes room for it). */
export function onViewer(fn) {
onChange = fn
}
function teardown() {
if (!current) return
current.editor?.destroy()
if (current.sealed) invoke("sealed_close", { url: current.sealed })
current = null
}
export function closeViewer() {
teardown()
$("viewer").hidden = true
clear($("viewer-body"))
onChange(false)
}
/**
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
* withheld?: "binary" | "too_big", size?, error? }.
*/
export async function openViewer(file) {
teardown()
const kind = file.kind || showKind(file.path || file.name).kind
const lang = file.language ?? showKind(file.path || file.name).language
$("viewer").hidden = false
onChange(true)
$("viewer-name").textContent = file.name || file.path || ""
$("viewer-name").title = file.path || ""
$("viewer-where").textContent = file.where || ""
const body = clear($("viewer-body"))
const modes = clear($("viewer-modes"))
current = {}
if (file.error) {
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
return
}
if (file.withheld) {
body.append(
h("p", {
class: "viewer__note",
text:
file.withheld === "too_big"
? t("This file is too big to show here (%(size)s).", { size: bytes(file.size) })
: t("This is not a text file; it cannot be shown here."),
}),
)
return
}
const text = file.text ?? ""
// Markdown and HTML can be seen drawn or as their source.
const drawn = kind === "markdown" || kind === "html"
const show = async (asSource) => {
teardown()
current = {}
clear(body)
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
if (asSource || !drawn) {
const holder = h("div", { class: "viewer__code" })
body.append(holder)
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
} else if (kind === "markdown") {
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
} else {
const url = await invoke("sealed_open", { html: text })
current.sealed = url
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
// scripts and nothing else. The address's own policy says the same again.
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
}
}
if (drawn) {
modes.append(
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
)
}
await show(false)
}
export function initViewer() {
$("viewer-close").replaceChildren(icon(ICONS.close))
$("viewer-close").addEventListener("click", closeViewer)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
})
}