Daily use: attachments, edit and regenerate, the chat's menu, model avatars, a palette
ci / check (push) Failing after 4s
ci / start (push) Failing after 3s

- 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:
HomerandClaude Opus 5.5 committed 2026-11-09 08:15:31 +00:00
1 parent 7ee394b549
commit 2a51b4f6d0
16 files changed
+1840 -72

No files matched your search

+124
View File
@@ -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
}