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

+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
}