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

+126
View File
@@ -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()
})
}