Library upload, new folder, rename and delete; a chat's tools and skills (/scope)
ci / ui (push) Waiting to run
ci / check (push) Failing after 4s
ci / start (push) Failing after 4s

Files -> Server writes through /api/v2/files/{upload,mkdir,rename} and
DELETE /files (the trash); upload is a Rust command (library_upload) that
picks files in the system dialog and sends them multipart in the field
`files`. A chat's menu opens Tools and skills..., the web's chat-settings
switches over GET/POST /chats/{id}/scope and /allow/clear. A popover can
open at a context menu's point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:53:38 +00:00
1 parent d691a4dcfa
commit 67ce6076c5
13 files changed
+582 -27

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ import { folderName, listed, folderTree, newFolder } from "./list.js"
import { unarchive } from "./archived.js"
import { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
import { scopePopover } from "./scope.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -1037,6 +1038,7 @@ export function chatMenu(anchor, chat) {
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) })
}
if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, 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, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
+126 -11
View File
@@ -3,7 +3,8 @@
// 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 { invoke, get, post, del, why } from "./bridge.js"
import { menu, ask } from "./popup.js"
import { openViewer } from "./viewer.js"
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
@@ -18,12 +19,19 @@ export const SERVER_FILES = {
/** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
/**
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
*/
function tree(box, { list, open, rootPath, actions }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const levels = new Map() // path -> { ul, depth }, each directory drawn now
const opened = new Set() // the directories open now
const level = async (parent, path, depth) => {
levels.set(path, { ul: parent, depth })
let entries
try {
entries = await list(path)
@@ -42,26 +50,45 @@ function tree(box, { list, open, rootPath }) {
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
const items = actions?.(e) || []
const li = h(
"li",
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
row,
items.length
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
)
if (isDir(e)) {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
const expand = async (opening) => {
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
kids?.remove()
kids = null
return
}
opened.add(e.path)
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
}
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
if (opened.has(e.path)) expand(true)
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
const done = level(ul, rootPath, 0)
const reload = (path) => {
const at = levels.get(path) || levels.get(rootPath)
if (!at) return Promise.resolve()
clear(at.ul)
return level(at.ul, levels.has(path) ? path : rootPath, at.depth)
}
return { done, reload }
}
// --- This computer ------------------------------------------------------------------------------
@@ -99,7 +126,7 @@ function renderLocal() {
head.append(pick)
}
const root = local.root
tree(box, {
return tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
@@ -125,12 +152,96 @@ export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
// The library's writes, as the person (each answers with the directory's listing, or a refusal
// in the server's words):
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
// POST /files/mkdir { path, name }
// POST /files/rename { path, name }
// DELETE /files?path= to the trash, as on the web
let server = null // the tree drawn now: { reload(path) }
let serverRoot = "/" // the directory the server answered `/` with: the person's home
const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/"
const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir)
function serverSaid(text, error = false) {
const line = $("server-status")
line.hidden = !text
line.textContent = text || ""
line.className = `status${error ? " is-error" : ""}`
line.setAttribute("role", error ? "alert" : "status")
}
/** Into `dir`: files chosen in the system's dialog. */
export async function uploadInto(dir) {
serverSaid("")
try {
const r = await invoke("library_upload", { dir, title: t("Upload") })
if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true)
if (r.sent) await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function makeFolder(dir) {
const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") })
if (!name) return
try {
await post("/files/mkdir", { path: dir, name })
serverSaid("")
await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function renameEntry(e) {
const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") })
if (!name || name === e.name) return
try {
await post("/files/rename", { path: e.path, name })
serverSaid("")
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
async function deleteEntry(e) {
const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/files?path=${encodeURIComponent(e.path)}`)
serverSaid(t("Moved to the trash."))
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
/** What can be done to an entry of the server's tree: a mount (another store) is filled, never
* renamed or deleted from here. A read-only place is refused by the server, in its words. */
function serverActions(e) {
const items = []
if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) })
if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) })
return items
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
serverSaid("")
server = tree(box, {
rootPath: "/",
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
list: async (path) => {
const r = await get(SERVER_FILES.list(path))
if (path === "/" && r.path) serverRoot = r.path
return r.entries || []
},
open: (e) => openServerFile(e.path, e.name),
actions: serverActions,
})
}
@@ -177,6 +288,10 @@ export function initExplorer() {
$("server-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("server-upload").replaceChildren(icon(ICONS.send))
$("server-upload").addEventListener("click", () => uploadInto(serverRoot))
$("server-mkdir").replaceChildren(icon(ICONS.folder))
$("server-mkdir").addEventListener("click", () => makeFolder(serverRoot))
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}
+4 -3
View File
@@ -199,7 +199,8 @@ function escPop(e) {
}
/**
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
* A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's
* point; or above it, `up`), holding `content` (an element, or a function
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
*/
@@ -211,7 +212,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
draw()
document.body.append(el)
const place = () => {
const at = anchor.getBoundingClientRect()
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y }
const r = el.getBoundingClientRect()
const left = align === "end" ? at.right - r.width : at.left
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
@@ -219,7 +220,7 @@ export function popover(anchor, content, { up = false, align = "start", label =
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
}
place()
openPop = { el, back: anchor, onClose }
openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose }
document.addEventListener("pointerdown", outsidePop, true)
document.addEventListener("keydown", escPop, true)
el.querySelector("button, input, select")?.focus()
+91
View File
@@ -0,0 +1,91 @@
// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families
// the server offers this chat now (web search among them) and the skills, each on or off for this
// chat only, and the commands it was told to stop asking about.
//
// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } }
// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope }
// POST /chats/{chat}/allow/clear -> { scope }
//
// Narrowing only, on the server: switching on what the model, the permissions or the instance
// left out reaches nothing. Labels, skill names and descriptions are set as text.
import { h } from "./dom.js"
import { get, post, why } from "./bridge.js"
import { popover } from "./popup.js"
/** The switches of `chat`, in a popover under `anchor`. */
export async function scopePopover(anchor, chat) {
const path = `/chats/${encodeURIComponent(chat.id)}`
let scope = null
let error = ""
let busy = false
const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") })
// Drawn again after each answer: the switch that was used keeps the focus.
const redraw = (key) => {
pop.redraw()
if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus()
}
const send = async (url, body) => {
const key = document.activeElement?.dataset?.key || ""
busy = true
redraw(key)
try {
scope = (await post(url, body))?.scope || scope
error = ""
} catch (err) {
error = why(err)
}
busy = false
redraw(key)
}
const toggle = (kind, name, label, on) =>
h(
"label",
{ class: "check scope__row", title: label.title || null },
h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }),
h("span", { text: label.text }),
)
function draw() {
const body = h("div", { class: "pop__body scope" })
if (!scope) {
body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") }))
return body
}
const families = scope.families || []
const skills = scope.skills || []
const allow = scope.allow || []
if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") }))
if (families.length) {
body.append(
h("p", { class: "pop__title", text: t("Tools") }),
h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))),
)
}
if (skills.length) {
body.append(
h("p", { class: "pop__title", text: t("Skills") }),
h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))),
)
}
if (allow.length) {
body.append(
h("p", { class: "pop__title", text: t("Always allowed here") }),
h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))),
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })),
)
}
if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") }))
if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error }))
return body
}
try {
scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] }
} catch (err) {
error = why(err)
}
pop.redraw()
}