Daily use: attachments, edit and regenerate, the chat's menu, model avatars, a palette
- Attachments from the file dialog, a drop on the window (Rust holds the dropped paths; the page never names one) or a paste (the bytes as the raw body of the call), sent at once to /api/v2/attachments as multipart and shown above the composer. - Edit message (rewind), Regenerate reply on the last reply, Copy on both. - The chat's menu (header and a right click in the sidebar): rename, open in the web interface, archive, delete. - The chat's model and effort in its header; effort for a new chat. Model avatars coloured as the server's stable_hue colours them (MD5 of the id, checked against Python), mixed into the theme's surface so no colour is hard-coded. - Ctrl+K palette over commands and every chat's title; Ctrl+N, Ctrl+,. - The agent's new states in the page; 34 new sentences in all eight languages, the server's own words where it has them; a Czech row that was Slovak; two untranslated strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16 files changed
+1840
-72
No files matched your search
+147
@@ -0,0 +1,147 @@
|
||||
// The files the next message carries. Three ways in, each the person's own act: the attach
|
||||
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
|
||||
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
|
||||
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
|
||||
// with the message that is sent next; one taken off before that is deleted on the server.
|
||||
|
||||
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
|
||||
import { invoke, listen, del, why } from "./bridge.js"
|
||||
|
||||
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error? }
|
||||
let seq = 0
|
||||
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
|
||||
|
||||
const REASONS = {
|
||||
folder: () => t("This is a folder; attach the files in it."),
|
||||
too_large: () => t("This file is too large to send."),
|
||||
}
|
||||
|
||||
/** The ids of the files sent and ready to go with the message. */
|
||||
export function readyIds() {
|
||||
return pending.filter((p) => p.state === "ready").map((p) => p.id)
|
||||
}
|
||||
|
||||
/** A file is still being sent. */
|
||||
export function sending() {
|
||||
return pending.some((p) => p.state === "sending")
|
||||
}
|
||||
|
||||
/** After a send: the files belong to the message now. */
|
||||
export function sent() {
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
/** Another chat opened, or a new one: files never sent are deleted on the server. */
|
||||
export function discard() {
|
||||
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = []
|
||||
render()
|
||||
}
|
||||
|
||||
function remove(p) {
|
||||
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
|
||||
pending = pending.filter((x) => x !== p)
|
||||
render()
|
||||
}
|
||||
|
||||
/** One file as the composer shows it: the same three tracks for every row. */
|
||||
function row(p) {
|
||||
const status =
|
||||
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
|
||||
return h(
|
||||
"li",
|
||||
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
|
||||
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
|
||||
h("span", { class: "file__state", text: status }),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "file__remove",
|
||||
"aria-label": t("Remove %(dir)s", { dir: p.name }),
|
||||
title: t("Remove"),
|
||||
disabled: p.state === "sending",
|
||||
onclick: () => remove(p),
|
||||
},
|
||||
icon(ICONS.close),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = clear($("composer-files"))
|
||||
list.hidden = !pending.length
|
||||
for (const p of pending) list.append(row(p))
|
||||
}
|
||||
|
||||
/** Show `names` as being sent while `call` sends them; then what became of each. */
|
||||
async function track(names, call) {
|
||||
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
|
||||
pending.push(...placeholders)
|
||||
render()
|
||||
let results
|
||||
try {
|
||||
results = await call()
|
||||
} catch (err) {
|
||||
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
|
||||
}
|
||||
pending = pending.filter((p) => !placeholders.includes(p))
|
||||
for (const r of results || []) {
|
||||
pending.push(
|
||||
r.attachment
|
||||
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
|
||||
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
|
||||
)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Choose files in the system's dialog. */
|
||||
async function pick() {
|
||||
const b = $("attach")
|
||||
b.disabled = true
|
||||
try {
|
||||
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
|
||||
} finally {
|
||||
b.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
|
||||
async function paste(e) {
|
||||
const files = [...(e.clipboardData?.files || [])]
|
||||
if (!files.length || !ctx.allowed()) return
|
||||
e.preventDefault()
|
||||
for (const f of files) {
|
||||
const name = f.name || "pasted"
|
||||
track([name], async () => {
|
||||
const data = new Uint8Array(await f.arrayBuffer())
|
||||
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
|
||||
return [{ name, size: f.size, attachment }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function initAttach(context) {
|
||||
ctx = context
|
||||
$("attach").replaceChildren(icon(ICONS.attach))
|
||||
$("attach").addEventListener("click", pick)
|
||||
$("composer-text").addEventListener("paste", paste)
|
||||
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
|
||||
listen("drag", (e) => {
|
||||
$("composer").dataset.drag = String(!!e.payload && usable())
|
||||
})
|
||||
listen("drop", (e) => {
|
||||
$("composer").dataset.drag = "false"
|
||||
if (!usable()) return
|
||||
const names = Array.isArray(e.payload) ? e.payload : []
|
||||
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether this account may attach files at all: the button shows only then. */
|
||||
export function allowAttach(on) {
|
||||
$("attach").hidden = !on
|
||||
$("composer").dataset.attach = String(on)
|
||||
}
|
||||
+254
-20
@@ -6,15 +6,18 @@
|
||||
// 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 } from "./dom.js"
|
||||
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
|
||||
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 } from "./popup.js"
|
||||
import { avatar, modelPicker } from "./models.js"
|
||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
|
||||
const MODES = ["manual", "edit", "auto", "plan"]
|
||||
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
|
||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||
let open = null // { chat, live, more, first }
|
||||
|
||||
export function openChatId() {
|
||||
@@ -37,14 +40,101 @@ function toBottom(force) {
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
function userMessage(m) {
|
||||
return h(
|
||||
const el = h(
|
||||
"article",
|
||||
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
|
||||
h("div", { class: "msg__text", text: m.text || "" }),
|
||||
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
|
||||
m.attachments?.length
|
||||
? h(
|
||||
"ul",
|
||||
{ class: "msg__files" },
|
||||
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
|
||||
)
|
||||
: 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) {
|
||||
@@ -65,9 +155,22 @@ function assistantMessage(m, chat) {
|
||||
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)
|
||||
if (meta || m.model) {
|
||||
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
|
||||
}
|
||||
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: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
|
||||
meta,
|
||||
),
|
||||
)
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -423,8 +526,120 @@ function renderHead(chat) {
|
||||
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
|
||||
)
|
||||
tools.append(mode)
|
||||
}
|
||||
const slot = h("span", { class: "head__model" })
|
||||
tools.append(slot)
|
||||
chatModels(chat).then((models) => {
|
||||
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
|
||||
})
|
||||
tools.append(h("button", { type: "button", class: "pane__btn", "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()
|
||||
}
|
||||
}
|
||||
|
||||
/** The chat's model, and its effort where the model offers more than one. */
|
||||
function headModel(slot, chat, models) {
|
||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||
const parts = []
|
||||
if (mayPick && models.length > 1) {
|
||||
parts.push(
|
||||
modelPicker({
|
||||
models,
|
||||
value: chat.model,
|
||||
label: t("Model"),
|
||||
onChange: async (id) => {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = r.chat
|
||||
headModel(slot, r.chat, models)
|
||||
}
|
||||
} catch (err) {
|
||||
alertIn(why(err))
|
||||
headModel(slot, chat, models)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else if (chat.model) {
|
||||
place.append(h("span", { class: "chip", text: chat.model }))
|
||||
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
||||
}
|
||||
const efforts = current.efforts || []
|
||||
if (efforts.length > 1) {
|
||||
parts.push(
|
||||
h(
|
||||
"select",
|
||||
{
|
||||
class: "head__mode",
|
||||
"aria-label": t("Reasoning effort"),
|
||||
title: t("Reasoning effort"),
|
||||
onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: e.target.value }).catch((err) => alertIn(why(err))),
|
||||
},
|
||||
chat.effort ? null : h("option", { value: "", text: "—", selected: true }),
|
||||
efforts.map((x) => h("option", { value: x, text: x, selected: chat.effort === x })),
|
||||
),
|
||||
)
|
||||
}
|
||||
slot.replaceChildren(...parts)
|
||||
}
|
||||
|
||||
/** 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 (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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +655,7 @@ function alertIn(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 }
|
||||
invoke("set_open_chat", { chat: id })
|
||||
@@ -517,6 +733,7 @@ let draft = null // { kind }
|
||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
||||
export function newChat(kind) {
|
||||
closeChat()
|
||||
discard()
|
||||
draft = { kind }
|
||||
clear($("messages"))
|
||||
renderCards({ id: "" }, [])
|
||||
@@ -528,15 +745,20 @@ export function newChat(kind) {
|
||||
form.hidden = false
|
||||
const s = ctx.status()
|
||||
const models = ctx.models()
|
||||
const start = (models.find((m) => m.default) || models[0])?.id || ""
|
||||
const effort = h("select", { id: "new-effort" })
|
||||
const effortField = field(t("Reasoning effort"), effort, "")
|
||||
const showEfforts = (id) => {
|
||||
const efforts = models.find((m) => m.id === id)?.efforts || []
|
||||
effort.replaceChildren(h("option", { value: "", text: "—" }), ...efforts.map((x) => h("option", { value: x, text: x })))
|
||||
effortField.hidden = efforts.length < 2
|
||||
}
|
||||
const modelField = field(
|
||||
t("Model"),
|
||||
h(
|
||||
"select",
|
||||
{ id: "new-model" },
|
||||
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
|
||||
),
|
||||
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showEfforts }),
|
||||
models.length ? "" : t("No model is available to this account."),
|
||||
)
|
||||
showEfforts(start)
|
||||
if (kind === "agent") {
|
||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
||||
const devices = ctx.thisDevices()
|
||||
@@ -567,10 +789,11 @@ export function newChat(kind) {
|
||||
t("A chat asking for more than the folder allows is lowered to it."),
|
||||
),
|
||||
modelField,
|
||||
effortField,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
form.append(h("div", { class: "fields" }, modelField))
|
||||
form.append(h("div", { class: "fields" }, modelField, effortField))
|
||||
}
|
||||
showComposer(true)
|
||||
setRunning(false)
|
||||
@@ -585,10 +808,13 @@ function field(label, control, hint) {
|
||||
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
|
||||
}
|
||||
|
||||
async function createChat(text) {
|
||||
async function createChat(text, attachments) {
|
||||
const body = { text }
|
||||
if (attachments.length) body.attachments = attachments
|
||||
const model = $("new-model")?.value
|
||||
if (model) body.model = model
|
||||
const effort = $("new-effort")?.value
|
||||
if (effort) body.effort = effort
|
||||
if (draft.kind === "agent") {
|
||||
const device = ctx.thisDevices()[0]
|
||||
const dir = $("new-dir")?.value
|
||||
@@ -618,16 +844,24 @@ function showComposer(on) {
|
||||
async function submit() {
|
||||
const box = $("composer-text")
|
||||
const text = box.value.trim()
|
||||
if (!text) return
|
||||
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)
|
||||
if (draft) await createChat(text, files)
|
||||
else if (open?.chat?.id) {
|
||||
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
|
||||
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)
|
||||
|
||||
@@ -74,6 +74,12 @@ export const ICONS = {
|
||||
min: "M4 10h8v1.5H4z",
|
||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||
}
|
||||
|
||||
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
|
||||
|
||||
+6
-1
@@ -5,7 +5,7 @@
|
||||
import { $, h, clear, when, append } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current() }
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
|
||||
let rows = []
|
||||
let more = false
|
||||
|
||||
@@ -49,6 +49,11 @@ function row(c) {
|
||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
||||
)
|
||||
b.addEventListener("click", () => ctx.open(c.id))
|
||||
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault()
|
||||
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, c)
|
||||
})
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
|
||||
+65
-14
@@ -11,7 +11,9 @@ import { renderSettings, initSettings } from "./settings.js"
|
||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||
import { initExplorer, showLocal, renderServer } from "./explorer.js"
|
||||
import { initList, refreshList, markCurrent } from "./list.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
|
||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
|
||||
import { initAttach, allowAttach } from "./attach.js"
|
||||
import { initPalette, openPalette } from "./palette.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
@@ -90,6 +92,7 @@ async function startApp() {
|
||||
}
|
||||
await Promise.all([loadModels(), loadDevices()])
|
||||
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
|
||||
allowAttach(me?.permissions?.["files.upload"] !== false)
|
||||
setKind(kind)
|
||||
renderServer()
|
||||
invoke("remote_refresh")
|
||||
@@ -142,6 +145,40 @@ function localPane(chat) {
|
||||
else showLocal({ roots, root: "", fixed: false })
|
||||
}
|
||||
|
||||
/** A new chat of `k`'s kind, in the main view. */
|
||||
function startNew(k) {
|
||||
showSettings(false)
|
||||
if (k !== kind) setKind(k)
|
||||
currentChat = null
|
||||
if (narrow()) sideOpen(false)
|
||||
newChat(k)
|
||||
markCurrent()
|
||||
localPane()
|
||||
}
|
||||
|
||||
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
|
||||
function showChat(id, k) {
|
||||
showSettings(false)
|
||||
if (k && k !== kind) setKind(k)
|
||||
if (narrow()) sideOpen(false)
|
||||
openChat(id).then(markCurrent)
|
||||
}
|
||||
|
||||
/** The palette's commands: what the menus and buttons do, by name. */
|
||||
function commands() {
|
||||
const out = [
|
||||
{ label: t("New agent chat"), run: () => startNew("agent") },
|
||||
{ label: t("New chat"), run: () => startNew("chat") },
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||
out.push(
|
||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
|
||||
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
function narrow() {
|
||||
return window.matchMedia("(max-width: 48rem)").matches
|
||||
}
|
||||
@@ -175,11 +212,8 @@ async function boot() {
|
||||
kind: () => kind,
|
||||
thisDevices,
|
||||
current: () => openChatId(),
|
||||
open: (id) => {
|
||||
showSettings(false)
|
||||
if (narrow()) sideOpen(false)
|
||||
openChat(id).then(markCurrent)
|
||||
},
|
||||
open: (id) => showChat(id),
|
||||
menu: (anchor, c) => chatMenu(anchor, c),
|
||||
})
|
||||
initChat({
|
||||
status: () => status,
|
||||
@@ -192,21 +226,25 @@ async function boot() {
|
||||
localPane(chat)
|
||||
markCurrent()
|
||||
},
|
||||
// Archived or deleted: the list without it, and a new chat where it was open.
|
||||
gone: (chat) => {
|
||||
if (!openChatId() || openChatId() === chat.id) {
|
||||
currentChat = null
|
||||
newChat(kind)
|
||||
localPane()
|
||||
}
|
||||
refreshList()
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
|
||||
onViewer((on) => {
|
||||
$("shell").dataset.viewer = String(on)
|
||||
})
|
||||
|
||||
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
|
||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||
$("new-chat-btn").addEventListener("click", () => {
|
||||
showSettings(false)
|
||||
currentChat = null
|
||||
if (narrow()) sideOpen(false)
|
||||
newChat(kind)
|
||||
markCurrent()
|
||||
localPane()
|
||||
})
|
||||
$("new-chat-btn").addEventListener("click", () => startNew(kind))
|
||||
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
|
||||
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
@@ -215,6 +253,19 @@ async function boot() {
|
||||
$("scrim").addEventListener("click", () => sideOpen(false))
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
|
||||
if (view !== "app" || document.querySelector("dialog[open]")) return
|
||||
const mod = e.ctrlKey || e.metaKey
|
||||
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
|
||||
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
openPalette()
|
||||
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
startNew(kind)
|
||||
} else if (mod && e.key === ",") {
|
||||
e.preventDefault()
|
||||
showSettings(true)
|
||||
}
|
||||
})
|
||||
|
||||
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Models as the person picks them: each with its avatar, as the server's web interface draws a
|
||||
// model without an uploaded image -- its initial on a colour of its own, the hue worked out from
|
||||
// the model's id exactly as the server does it (`stable_hue`: the first two bytes of the id's
|
||||
// MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
|
||||
// lightness are the theme's (app.css), only the hue is the model's.
|
||||
|
||||
import { h, icon, ICONS } from "./dom.js"
|
||||
import { menu } from "./popup.js"
|
||||
|
||||
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
|
||||
function md5(text) {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
|
||||
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
|
||||
const len = bytes.length
|
||||
const words = (((len + 8) >>> 6) + 1) * 16
|
||||
const w = new Uint32Array(words)
|
||||
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
|
||||
w[len >> 2] |= 0x80 << ((len % 4) * 8)
|
||||
w[words - 2] = (len * 8) >>> 0
|
||||
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
|
||||
let a0 = 0x67452301
|
||||
let b0 = 0xefcdab89
|
||||
let c0 = 0x98badcfe
|
||||
let d0 = 0x10325476
|
||||
for (let off = 0; off < words; off += 16) {
|
||||
let [A, B, C, D] = [a0, b0, c0, d0]
|
||||
for (let i = 0; i < 64; i++) {
|
||||
let F
|
||||
let g
|
||||
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
|
||||
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
|
||||
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
|
||||
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
|
||||
F = (F + A + K[i] + w[off + g]) >>> 0
|
||||
const s = S[(i >> 4) * 4 + (i % 4)]
|
||||
A = D
|
||||
D = C
|
||||
C = B
|
||||
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
|
||||
}
|
||||
a0 = (a0 + A) >>> 0
|
||||
b0 = (b0 + B) >>> 0
|
||||
c0 = (c0 + C) >>> 0
|
||||
d0 = (d0 + D) >>> 0
|
||||
}
|
||||
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
|
||||
}
|
||||
|
||||
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
|
||||
export function stableHue(id) {
|
||||
const d = md5(String(id))
|
||||
return ((d[0] << 8) | d[1]) % 360
|
||||
}
|
||||
|
||||
/** A model's avatar: its initial on its own colour. */
|
||||
export function avatar(model) {
|
||||
const label = String(model?.name || model?.id || "?").trim() || "?"
|
||||
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
|
||||
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
|
||||
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
|
||||
*/
|
||||
export function modelPicker({ id, models, value, onChange, label }) {
|
||||
let current = value
|
||||
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
|
||||
const draw = () => {
|
||||
const m = models.find((x) => x.id === current)
|
||||
btn.replaceChildren(
|
||||
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
|
||||
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
|
||||
icon(ICONS.chevronDown),
|
||||
)
|
||||
btn.title = m ? `${m.name} (${m.id})` : current || ""
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
menu(
|
||||
btn,
|
||||
models.map((m) => ({
|
||||
label: m.name,
|
||||
lead: avatar(m),
|
||||
checked: m.id === current,
|
||||
run: () => {
|
||||
if (m.id === current) return
|
||||
current = m.id
|
||||
draw()
|
||||
onChange?.(m.id)
|
||||
},
|
||||
})),
|
||||
)
|
||||
})
|
||||
Object.defineProperty(btn, "value", {
|
||||
get: () => current,
|
||||
set: (v) => {
|
||||
current = v
|
||||
draw()
|
||||
},
|
||||
})
|
||||
draw()
|
||||
return btn
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
|
||||
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
|
||||
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
|
||||
// is there.
|
||||
|
||||
import { h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get } from "./bridge.js"
|
||||
|
||||
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
|
||||
let dlg = null
|
||||
|
||||
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
|
||||
function fold(text) {
|
||||
return String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}+/gu, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function matches(text, words) {
|
||||
const f = fold(text)
|
||||
return words.every((w) => f.includes(w))
|
||||
}
|
||||
|
||||
export async function openPalette() {
|
||||
if (dlg) return
|
||||
const input = h("input", { type: "text", class: "palette__input", placeholder: t("Type a command or a chat's title"), "aria-label": t("Search"), role: "combobox", "aria-expanded": "true", "aria-controls": "palette-list", autocomplete: "off" })
|
||||
const list = h("ul", { class: "palette__list", id: "palette-list", role: "listbox" })
|
||||
dlg = h("dialog", { class: "dialog palette", "aria-label": t("Commands") }, h("div", { class: "palette__line" }, icon(ICONS.search), input), list)
|
||||
document.body.append(dlg)
|
||||
let chats = []
|
||||
let shown = []
|
||||
let at = 0
|
||||
const close = () => {
|
||||
if (!dlg) return
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
dlg = null
|
||||
}
|
||||
const choose = (item) => {
|
||||
close()
|
||||
item?.run()
|
||||
}
|
||||
const draw = () => {
|
||||
const words = fold(input.value).split(/\s+/).filter(Boolean)
|
||||
const commands = ctx.commands().filter((c) => matches(c.label, words))
|
||||
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
|
||||
shown = [
|
||||
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
|
||||
...found.map((c) => ({
|
||||
kind: c.kind,
|
||||
label: c.title || t("New chat"),
|
||||
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
|
||||
run: () => ctx.open(c),
|
||||
})),
|
||||
]
|
||||
at = Math.min(at, Math.max(0, shown.length - 1))
|
||||
clear(list)
|
||||
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
|
||||
shown.forEach((item, i) => {
|
||||
list.append(
|
||||
h(
|
||||
"li",
|
||||
{
|
||||
id: `palette-${i}`,
|
||||
role: "option",
|
||||
class: "palette__item",
|
||||
"aria-selected": String(i === at),
|
||||
onclick: () => choose(item),
|
||||
onpointermove: () => {
|
||||
if (at !== i) {
|
||||
at = i
|
||||
mark()
|
||||
}
|
||||
},
|
||||
},
|
||||
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
|
||||
h("span", { class: "palette__label", text: item.label }),
|
||||
h("span", { class: "palette__hint", text: item.hint }),
|
||||
),
|
||||
)
|
||||
})
|
||||
mark()
|
||||
}
|
||||
const mark = () => {
|
||||
list.querySelectorAll(".palette__item").forEach((el, i) => el.setAttribute("aria-selected", String(i === at)))
|
||||
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
|
||||
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
at = 0
|
||||
draw()
|
||||
})
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
if (!shown.length) return
|
||||
at = (at + (e.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length
|
||||
mark()
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
choose(shown[at])
|
||||
}
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
close()
|
||||
})
|
||||
dlg.addEventListener("click", (e) => {
|
||||
if (e.target === dlg) close()
|
||||
})
|
||||
dlg.showModal()
|
||||
input.focus()
|
||||
draw()
|
||||
try {
|
||||
const r = await get("/chats?limit=100")
|
||||
chats = (r.chats || []).filter((c) => !c.archived && !c.temporary)
|
||||
if (dlg) draw()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function initPalette(context) {
|
||||
ctx = context
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
|
||||
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
|
||||
// click outside close them; the focus goes back where it came from.
|
||||
|
||||
import { h } from "./dom.js"
|
||||
|
||||
let openMenu = null
|
||||
|
||||
/** Close the menu that is open, if any. */
|
||||
export function closeMenu() {
|
||||
if (!openMenu) return
|
||||
const { el, back } = openMenu
|
||||
openMenu = null
|
||||
el.remove()
|
||||
document.removeEventListener("pointerdown", outside, true)
|
||||
back?.focus?.()
|
||||
}
|
||||
|
||||
function outside(e) {
|
||||
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
|
||||
*/
|
||||
export function menu(anchor, items) {
|
||||
closeMenu()
|
||||
const plain = !items.some((i) => i?.lead)
|
||||
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
el.append(h("div", { class: "menu__sep", role: "separator" }))
|
||||
continue
|
||||
}
|
||||
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
|
||||
// labels line up whether or not an item has a lead.
|
||||
el.append(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
role: item.checked === undefined ? "menuitem" : "menuitemradio",
|
||||
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
|
||||
class: `menu__item${item.danger ? " is-danger" : ""}`,
|
||||
disabled: !!item.disabled,
|
||||
onclick: () => {
|
||||
closeMenu()
|
||||
item.run()
|
||||
},
|
||||
},
|
||||
h("span", { class: "menu__lead" }, item.lead || null),
|
||||
h("span", { class: "menu__label", text: item.label }),
|
||||
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
|
||||
const i = all.indexOf(document.activeElement)
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeMenu()
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
all[(i + 1) % all.length]?.focus()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
all[(i - 1 + all.length) % all.length]?.focus()
|
||||
}
|
||||
})
|
||||
document.body.append(el)
|
||||
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
|
||||
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
|
||||
const r = el.getBoundingClientRect()
|
||||
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
|
||||
el.style.width = `${Math.ceil(r.width)}px`
|
||||
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
|
||||
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
|
||||
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
|
||||
document.addEventListener("pointerdown", outside, true)
|
||||
el.querySelector(".menu__item:not(:disabled)")?.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
|
||||
* buttons. Resolves with the line (or true) on OK, null on cancel.
|
||||
*/
|
||||
export function ask({ title, text, value, ok, cancel, danger }) {
|
||||
return new Promise((resolve) => {
|
||||
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
|
||||
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
|
||||
const form = h(
|
||||
"form",
|
||||
{ class: "dialog__body", method: "dialog" },
|
||||
h("h2", { class: "dialog__title", text: title }),
|
||||
text ? h("p", { class: "muted", text }) : null,
|
||||
input,
|
||||
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
|
||||
)
|
||||
const dlg = h("dialog", { class: "dialog" }, form)
|
||||
let settled = false
|
||||
const done = (v) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
dlg.close()
|
||||
dlg.remove()
|
||||
resolve(v)
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault()
|
||||
if (input && !input.value.trim()) return input.focus()
|
||||
done(input ? input.value.trim() : true)
|
||||
})
|
||||
dlg.addEventListener("cancel", (e) => {
|
||||
e.preventDefault()
|
||||
done(null)
|
||||
})
|
||||
document.body.append(dlg)
|
||||
dlg.showModal()
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
} else okBtn.focus()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user