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

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