Files
LLeMbas-Desktop/ui/js/chat.js
T
HomerandClaude Opus 5.5 3baae016e1
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
Progress: the reply's line is the 3.4.0 progress object, {{text}} · {{elapsed}}
ui/js/progress.js draws harness spec 3.4.0's `progress` (progress.md) with the
spec's texts as the page's sentences: the thirteen phases, their variants and
field words, the line with its clock in whole seconds (5s, 1m 05s, 1h 02m),
none for stopped and failed, retrying counted down. A live reply takes
`progress` from any stream frame that carries it and ticks once a second; with
no progress (an older server, or null) it draws the status text as before.

The sentences are the server's to translate: gen-i18n.py takes them from its
catalogues once they are there (SERVER_PENDING), English until then, without
failing.

tests/ui: every case of the spec's conformance/progress.json (copied, 3.4.0),
and a live stream's progress, clock, fallback and stopped line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:10:20 +00:00

1271 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// One chat: its transcript, the reply being written (followed as the server streams it), the
// cards that wait for an answer, and the composer with the chat's settings as chips under the
// text (folder, mode, model, effort, agents). And a new chat, which is the same page empty: a
// greeting, the choice of an agent chat on this computer or a chat on the server, and the composer.
//
// 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, icon, ICONS, bytes } from "./dom.js"
import { api, get, getText, post, patch, del, follow, why, invoke, openLink } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList, activityButton } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
import { folderName } from "./list.js"
import { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
const MODES = ["manual", "edit", "auto", "plan"]
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
return open?.chat?.id || null
}
// --- Reading -----------------------------------------------------------------------------------
export function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
function deviceOf(id) {
return (ctx.devices?.() || []).find((d) => d.id === id) || null
}
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
function draftDevice() {
const mine = ctx.thisDevices()
return mine.find((id) => deviceOf(id)) || mine[0] || ""
}
/**
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
* are chosen (the shell before the first message), an open agent chat on a device that opens
* terminals, or nothing.
*/
function terminalTarget() {
if (draft) {
if (draft.kind !== "agent" || !draft.dir) return null
const device = deviceOf(draftDevice())
if (!device?.terminal) return null
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
}
const chat = open?.chat
if (chat?.kind !== "agent" || !chat.device) return null
const device = deviceOf(chat.device.token_id)
if (!device?.terminal) return null
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
}
/** Text into the message box, after what is there, for the person to read before sending. */
export function intoComposer(text) {
const box = $("composer-text")
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
box.dispatchEvent(new Event("input"))
box.focus()
}
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
}
/** A small icon button under a message. */
function action(ico, label, onclick, cls = "") {
return h("button", { type: "button", class: `msg__act ${cls}`, "aria-label": label, title: label, onclick }, icon(ico))
}
function copyText(text) {
navigator.clipboard?.writeText(text).catch(() => {})
}
/**
* A file a message carries, as a tile: a picture as its thumbnail (read through the bridge, as
* a `data:` URL), anything else as its icon, name and size.
*/
export function fileTile(a) {
const tile = h(
"li",
{ class: `tile${a.image ? " tile--image" : ""}`, title: a.name },
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: a.name })),
h("span", { class: "tile__size", text: a.size ? bytes(a.size) : "" }),
)
if (a.image && a.id) {
picture(`/attachments/${a.id}`).then((url) => {
if (!url || !url.startsWith("data:image/")) return
tile.classList.add("has-thumb")
tile.prepend(h("img", { class: "tile__thumb", src: url, alt: "", draggable: "false" }))
})
}
return tile
}
function userMessage(m) {
const el = h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
m.attachments?.length ? h("ul", { class: "tiles msg__files" }, m.attachments.map(fileTile)) : null,
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
// A background job's report is not the person's to rewrite (the server refuses it too).
if (!m.machine) {
el.append(
h(
"div",
{ class: "msg__actions" },
action(ICONS.edit, t("Edit message"), () => startEdit(el, m)),
action(ICONS.copy, t("Copy"), () => copyText(m.text || "")),
),
)
}
return el
}
/**
* Edit one of the person's messages in place. Saving rewrites it, drops everything after it and
* starts the reply again (on the device too, for an agent chat): the server's rule, said first.
*/
function startEdit(el, m) {
if (!open?.chat?.id || el.querySelector(".msg__edit")) return
const box = h("textarea", { class: "msg__edit", rows: "3", value: m.text || "", "aria-label": t("Edit message") })
const note = h("p", { class: "hint", text: t("Saving starts the reply again; everything after this message is dropped.") })
const said = h("p", { class: "msg__note is-error", hidden: true })
const save = h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save") })
const cancel = h("button", { type: "button", class: "btn btn--sm", text: t("Cancel") })
const form = h("div", { class: "msg__editor" }, box, note, said, h("div", { class: "msg__editbar" }, cancel, save))
const parts = [...el.children]
for (const c of parts) c.hidden = true
el.append(form)
const close = () => {
form.remove()
for (const c of parts) c.hidden = false
}
cancel.addEventListener("click", close)
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") close()
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) save.click()
})
save.addEventListener("click", async () => {
const text = box.value.trim()
if (!text && !m.attachments?.length) return box.focus()
save.disabled = cancel.disabled = true
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/edit`, { text })
reload()
} catch (err) {
said.textContent = why(err)
said.hidden = false
save.disabled = cancel.disabled = false
}
})
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/** The reply's words, as Markdown: what Copy puts on the clipboard. */
function replyText(m) {
return (m.steps || [])
.filter((s) => s.kind === "text")
.map((s) => s.markdown || "")
.join("\n\n")
}
async function regenerate(m) {
if (!open?.chat?.id) return
alertIn("")
try {
await post(`/chats/${encodeURIComponent(open.chat.id)}/messages/${encodeURIComponent(m.id)}/regenerate`)
reload()
} catch (err) {
alertIn(why(err))
}
}
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)
const model = m.model ? ctx.models().find((x) => x.id === m.model) || { id: m.model, name: m.model } : null
el.append(
h(
"footer",
{ class: "msg__foot" },
h(
"div",
{ class: "msg__actions" },
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
),
model ? h("span", { class: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : 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", { class: "step__row" }, h("span", { class: "step__icon" }, icon(ICONS.bulb)), h("span", { class: "step__label" }, t("Thought"), s.label ? h("span", { class: "step__meta", text: ` · ${s.label}` }) : null), h("span", { class: "step__chev" }, icon(ICONS.chevron))),
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" })
}
}
/**
* Where the text of a file the agent showed comes from, or null when there is none to fetch:
* - an agent chat on this computer: the file itself, read here (the card's path, or the call's
* own `args.path` for a step the card does not describe);
* - otherwise the server, through the card (`show`): `?node=&version=` for a file in the tree,
* `?shown=` for the copy a device sent. The server checks the chat's access again then.
*/
function shownSource(call, chat) {
if (onThisComputer(chat) && chat.device.directory) {
const path = call.show?.path || call.args?.path || ""
return path ? { local: true, path } : null
}
const s = call.show
if (!s || !chat?.id) return null
const q = s.shown_id
? `shown=${encodeURIComponent(s.shown_id)}`
: s.node_id && s.version_id
? `node=${encodeURIComponent(s.node_id)}&version=${encodeURIComponent(s.version_id)}`
: ""
return q ? { local: false, path: s.path || "", url: `/chats/${encodeURIComponent(chat.id)}/show?${q}` } : null
}
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 && shownSource(c, chat)
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__name", text: c.name || "" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title && block.title !== c.name ? block.title : "" }),
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
h("span", { class: "step__chev" }, icon(ICONS.chevron)),
)
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 src = shownSource(call, chat)
if (!src) return
const path = src.path
const { language } = showKind(path)
const line = call.show?.line || (src.local ? call.args?.line : 0) || 0
const base = { name: call.show?.name || path.split("/").pop(), path, kind: call.show?.kind, language: call.show?.language || language, line }
try {
if (src.local) {
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 {
// Plain text, at most 1 MB; the server's 404 (no longer the chat's), 413 (too large) and
// 415 (not text) come back as its own words. HTML is its source: the viewer seals it.
const text = await getText(src.url)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text })
}
} 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
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
this.said = "" // the last `status` text, for a server that sends no progress
this.clock = 0
}
/** The status line: the progress object's line where the server sends one, else its text. */
drawStatus() {
const p = this.progress
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
this.status.dataset.phase = isProgress(p) ? p.phase : ""
const ticks = progressTicks(p)
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
else if (!ticks) this.stopClock()
}
stopClock() {
clearInterval(this.clock)
this.clock = 0
}
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.stopClock()
this.handle?.close()
}
frame(event, data) {
if (this.done) return
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
// the object, or null for nothing running -- then the status text again.
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
this.progress = isProgress(data.progress) ? data.progress : null
if (event !== "status") this.drawStatus()
}
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.said = data.text || ""
this.drawStatus()
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.stopClock()
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
this.stopClock()
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()
activityChanged()
}
}
}
/** While a reply is written, Stop takes Send's place (Send comes back beside it for a message typed meanwhile). */
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 ----------------------------------------------------------------------------------------
/**
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
* countdown included, and drawing it again would take away a command being corrected or an option
* already picked. New ones are added in the server's order; the ones no longer listed go.
*/
function renderCards(chat, cards) {
const box = $("cards")
const ids = cards.map((c) => String(c.id))
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
cards.forEach((card, i) => {
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
if (!el) {
try {
el = cardOf(chat, card)
} catch (err) {
// Never an empty box under a status that says the reply waits: say what went wrong.
console.error("card", card, err)
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
}
el.dataset.card = String(card.id)
el.dataset.chat = chat.id
}
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
})
box.hidden = !box.children.length
}
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" })
// A background subagent asks, not the conversation's own turn: said first (`job`).
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
let detailShown = false
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) {
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
// this item's text, and only when it differs -- an untouched one is not an edit.
const command = item.detail || item.title
detailShown = true
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
edit.addEventListener("input", () => {
if (edit.value !== command) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else {
// What it wants to do (a tool's own title), then its input as the device sent it.
part.append(h("p", { class: "ask__what", text: item.title }))
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
detailShown = true
}
} 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 && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
// The model's account of what it is for, attributed to it; then the server's reason for asking.
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
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
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
$("chat-title").textContent = title || t("New chat")
$("chat-title").title = title || ""
}
/** The chat's header: its title, where it works (an agent chat's folder and device), and its tools. */
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: `place${here ? " is-here" : ""}`, title: chat.device.directory },
icon(ICONS.folder),
h("span", { class: "place__name", text: folderName(chat.device.directory) }),
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
),
)
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
// "Continue in a terminal" stays in the chat's menu.
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
}
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
async function chatModels(chat) {
try {
return (await get(`/models?chat=${encodeURIComponent(chat.id)}`)).models || []
} catch {
return ctx.models()
}
}
/**
* Whether this chat is offered subagents: `{ cap, own }` (the instance's most; whether only a model
* the person names could serve them), or null. The chat's own `subagents` where the server gives
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
*/
async function teamOffered(chat) {
const said = chat.subagents
if (said !== undefined && said !== null) {
const offered = typeof said === "object" ? said.offered : said
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
}
if (chat.kind !== "chat") return null
try {
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
return team?.offered ? { cap: subagentsCap(), own: false } : null
} catch {
return null
}
}
// --- The chips under the text -------------------------------------------------------------------
//
// A chat's settings are small buttons under what is being written, each opening a menu: the
// folder and the mode (an agent chat), the model with its picture, how hard it thinks (as the
// model offers it: nothing for one level), and the team's size (where a team is offered). For a
// new chat they say what it will start with; for an open one, changing one changes the chat.
/** One chip: an icon or a lead (an avatar), its words, and a chevron when it opens a menu. */
function chip({ ico, lead, label, title, onclick, menuish = true, cls = "", disabled = false }) {
return h(
"button",
{ type: "button", class: `cchip ${cls}`, title: title || label, "aria-haspopup": menuish ? "menu" : null, disabled, onclick },
lead || (ico ? icon(ico) : null),
h("span", { class: "cchip__label", text: label }),
menuish ? h("span", { class: "cchip__chev" }, icon(ICONS.chevronDown)) : null,
)
}
const MODE_WORDS = {
manual: () => t("Manual — asks first"),
edit: () => t("Edit — asks before commands"),
auto: () => t("Auto — asks nothing"),
plan: () => t("Plan — changes nothing"),
}
function modeChip(value, onChange, disabled) {
return chip({
ico: ICONS.shield,
label: modeName(value),
title: `${t("Mode")}: ${MODE_WORDS[value]?.() || value}`,
disabled,
onclick: (e) => menu(e.currentTarget, MODES.map((m) => ({ label: MODE_WORDS[m](), checked: m === value, run: () => m !== value && onChange(m) }))),
})
}
function modelChip(models, value, onChange, mayPick) {
const m = models.find((x) => x.id === value) || (value ? { id: value, name: value } : null)
if (!m) return chip({ ico: ICONS.info, label: t("No model is available to this account."), menuish: false, disabled: true })
return chip({
lead: avatar(m),
label: m.name,
title: `${t("Model")}: ${m.name} (${m.id})`,
menuish: mayPick && models.length > 1,
cls: "cchip--model",
onclick: (e) => {
if (!mayPick || models.length < 2) return
menu(e.currentTarget, models.map((x) => ({ label: x.name, lead: avatar(x), checked: x.id === value, run: () => x.id !== value && onChange(x.id) })))
},
})
}
/** How hard the model thinks: nothing where it has one level; else its levels, in a menu. */
function effortChip(model, value, onChange) {
const { levels, start, control } = levelsOf(model)
if (control === "none") return null
const current = levels.includes(value) ? value : start
const words = (l) => (control === "switch" ? (l === "off" ? t("off") : t("Think before answering")) : levelWord(l))
return chip({
ico: ICONS.bulb,
label: control === "switch" ? (current === "off" ? t("off") : levelWord(current)) : levelWord(current),
title: `${t("Reasoning effort")}: ${levelWord(current)}`,
cls: current === "off" ? "is-dim" : "",
onclick: (e) => menu(e.currentTarget, levels.map((l) => ({ label: words(l), hint: control === "switch" && l !== "off" ? levelWord(l) : "", checked: l === current, run: () => l !== current && onChange(l) }))),
})
}
/**
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
*/
function agentsChip(value, onChange, { cap, own } = {}) {
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
return chip({
ico: ICONS.users,
label: `${t("Subagents")} ${said}`,
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
cls: `cchip--subagents${own ? " is-warn" : ""}`,
onclick: (e) => {
const fields = agentsControl({ value, id: "agents-min", cap })
popover(
e.currentTarget,
h(
"div",
{ class: "pop__body" },
h("p", { class: "pop__title", text: t("Subagents") }),
fields,
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
),
{ up: true, label: t("Subagents") },
)
},
})
}
/** The folder of a new agent chat: one of the agent's folders, or Add folder… where there is none. */
function folderChip(roots, value, onChange) {
if (!roots.length) return chip({ ico: ICONS.folder, label: t("Add folder…"), menuish: false, cls: "is-warn", onclick: () => invoke("remote_add") })
return chip({
ico: ICONS.folder,
label: folderName(value),
title: `${t("Folder")}: ${value}`,
onclick: (e) =>
menu(e.currentTarget, [
...roots.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
null,
{ label: t("Add folder…"), run: () => invoke("remote_add") },
]),
})
}
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
async function renderChips() {
const box = $("composer-chips")
const drawing = ++chipsDrawn
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
if (draft) {
const s = ctx.status()
const models = ctx.models()
const model = models.find((m) => m.id === draft.model)
const parts = []
if (draft.kind === "agent") {
const roots = s?.remote?.enabled ? s.remote.roots : []
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
const shell = terminalTarget()
aimTerminal(shell)
parts.push(modelChip(models, draft.model, (id) => {
draft.model = id
draft.effort = levelsOf(models.find((m) => m.id === id)).start
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// Last, so the chat's own settings come first where the line scrolls.
if (shell) {
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
b.dataset.terminalToggle = ""
b.setAttribute("aria-pressed", String(terminalOpen()))
b.setAttribute("aria-controls", "terminal")
parts.push(b)
}
box.replaceChildren(...parts.filter(Boolean))
return
}
const chat = open?.chat
aimTerminal(terminalTarget())
if (!chat?.kind) return box.replaceChildren()
const save = async (body) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id) {
open.chat = r.chat
renderChips()
if (body.model) activityChanged()
}
alertIn("")
} catch (err) {
alertIn(why(err))
}
}
const models = await chatModels(chat)
if (drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
const parts = []
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
box.replaceChildren(...parts.filter(Boolean))
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
}
/**
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
*/
function renderNotice() {
const box = $("link-notice")
const s = ctx.status()
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
box.hidden = !show
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
else box.replaceChildren()
}
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
export function statusChanged() {
renderNotice()
if (draft) {
renderChips()
renderHello()
} else if (open?.chat?.kind) {
// The devices were read again: whether this chat's opens a terminal may have changed.
renderHead(open.chat)
aimTerminal(terminalTarget())
}
}
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
/**
* Continue an agent chat in a terminal: the CLI's own session on the device it works on
* (`device_session_id`), opened with `lembas -s <id>` in the chat's folder. Shown to copy rather
* than run: a terminal is not something every desktop opens the same way, and the command is the
* person's to run where they want it. On this computer the first word is the CLI as it is
* started here (`lembas` on PATH, else the app's own copy by its path).
*/
export async function continueInTerminal(chat) {
const session = String(chat.device_session_id || "")
if (!/^[A-Za-z0-9_-]+$/.test(session)) return
const here = onThisComputer(chat)
let word = "lembas"
if (here) word = await invoke("terminal_word").catch(() => "lembas")
const dir = chat.device?.directory || ""
const quoted = /^[A-Za-z0-9/._+:@%=-]+$/.test(dir) ? dir : `'${dir.replace(/'/g, "'\\''")}'`
const code = `${dir ? `cd ${quoted} && ` : ""}${word} -s ${session}`
await showLine({
title: t("Continue in a terminal"),
text: here
? t("Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.")
: t("Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it."),
code,
copy: t("Copy"),
copied: t("Copied"),
close: t("Close"),
})
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { title })
if (open?.chat?.id === chat.id) {
open.chat = r.chat
setTitle(r.chat.title)
}
ctx.changed(r.chat)
} catch (err) {
alertIn(why(err))
}
}
async function archiveChat(chat) {
try {
await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: true })
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
async function deleteChat(chat) {
const yes = await ask({
title: t("Delete this chat?"),
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
ok: t("Delete"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
try {
await del(`/chats/${encodeURIComponent(chat.id)}`)
ctx.gone(chat)
} catch (err) {
alertIn(why(err))
}
}
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?.chat?.id !== id) discard()
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
draft = null
closePopover()
invoke("set_open_chat", { chat: id })
showComposer(true)
$("new-chat").hidden = true
$("main").dataset.new = "false"
clear($("composer-chips"))
renderNotice()
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
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
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
watchActivity(null)
invoke("set_open_chat", { chat: null })
}
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
let lastKind = "agent"
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
export function draftKind() {
return draft?.kind || lastKind
}
/** `/models` was read again: a new chat being written is drawn with what it says now. */
export function modelsChanged() {
if (!draft) return
const models = ctx.models()
if (!models.some((m) => m.id === draft.model)) {
const start = models.find((m) => m.default) || models[0]
draft.model = start?.id || ""
draft.effort = levelsOf(start).start
}
renderChips()
}
/** The greeting of a new chat, and the choice of what it is. */
function renderHello() {
const box = $("new-chat")
if (!draft) return
const me = ctx.me()
const first = String(me?.name || "").trim().split(/\s+/)[0]
const kind = draft.kind
const s = ctx.status()
const server = (() => {
try {
return new URL(s?.server || "").host
} catch {
return ""
}
})()
const tab = (k, ico, label) =>
h(
"button",
{
type: "button",
role: "tab",
class: "kind__tab",
"aria-selected": String(kind === k),
onclick: () => {
if (draft.kind === k) return
draft.kind = lastKind = k
renderHello()
renderChips()
renderNotice()
ctx.drafted?.(k)
$("composer-text").focus()
},
},
icon(ico),
h("span", { text: label }),
)
box.replaceChildren(
h("img", { class: "hello__mark", src: "logo-mark.svg", alt: "", width: "40", height: "40" }),
h("h2", { class: "hello__title", text: first ? t("Hello, %(name)s", { name: first }) : t("Hello") }),
h(
"p",
{ class: "hello__sub" },
kind === "agent"
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
),
h("div", { class: "kind", role: "tablist", "aria-label": t("New chat") }, tab("agent", ICONS.terminal, t("Agent")), tab("chat", ICONS.chat, t("Chat"))),
)
}
/** A new chat in the main view: the page empty, the greeting, and the composer ready. */
export function newChat(kind) {
closeChat()
discard()
closePopover()
const models = ctx.models()
const start = models.find((m) => m.default) || models[0]
lastKind = kind || lastKind
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
setTitle(t("New chat"))
clear($("chat-place"))
clear($("chat-tools"))
$("main").dataset.new = "true"
$("new-chat").hidden = false
renderHello()
renderChips()
renderNotice()
showComposer(true)
setRunning(false)
invoke("set_open_chat", { chat: null })
$("composer-text").focus()
}
async function createChat(text, attachments) {
const body = { text }
if (attachments.length) body.attachments = attachments
if (draft.model) body.model = draft.model
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
const agents = draft.agents
if (agents && (agents.min || agents.max)) body.agents = agents
if (draft.kind === "agent") {
const device = draftDevice()
if (!device || !agentReady(ctx.status())) {
renderNotice()
throw { detail: agentState(ctx.status()).sentence }
}
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
}
const r = await post("/chats", body)
draft = null
$("new-chat").hidden = true
$("main").dataset.new = "false"
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
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 (sending()) {
alertIn(t("Wait until the files are sent."))
return
}
const files = readyIds()
if (!text && !files.length) return
$("send").disabled = true
alertIn("")
try {
if (draft) await createChat(text, files)
else if (open?.chat?.id) {
const body = { text }
if (files.length) body.attachments = files
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
} else return
sent()
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
$("send").replaceChildren(icon(ICONS.send))
$("stop").replaceChildren(icon(ICONS.stop))
$("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))
}
})
onAgentChange(renderNotice)
showComposer(false)
}