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:
20 files changed
+4275
-758
No files matched your search
+119
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user