- Sidebar: New chat, search, Scheduled, Files, the chats of both kinds by day (Today, Earlier), Settings with the account at the foot; folds away (Ctrl+B), a drawer on a narrow window. - A new chat is the chat page empty: a greeting, Agent or Chat, and the composer. - The composer is a card: the text, then attach, the chips (folder and mode for an agent chat, model with its picture, effort as the model offers it, the team's size), and a round Send that Stop replaces while a reply is written. An open chat's chips change the chat. - Messages: the person's in a bubble on the right with attached pictures as thumbnails, a reply full width; thinking and tool calls as compact rows that unfold. - Activity: a chip above the composer for every open chat, the panel on the right as before. - The agent: a chip in the title bar opening its state and the one thing to do about it; "This computer is not linked yet" with Link this computer right above the composer of an agent chat, and "refused" only with the server's reason. - Files: a page with the two trees as tabs. Settings: grouped, the groups listed beside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
8.6 KiB
JavaScript
239 lines
8.6 KiB
JavaScript
// 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, hint?, 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()
|
|
closePopover()
|
|
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),
|
|
item.hint
|
|
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
|
|
: 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`
|
|
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
|
|
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
|
|
el.style.top = above ? `${at.top - r.height}px` : `${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()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
|
|
* it is and selectable), and Copy beside Close. Resolves when it is closed.
|
|
*/
|
|
export function showLine({ title, text, code, copy, copied, close }) {
|
|
return new Promise((resolve) => {
|
|
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
|
|
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
|
|
const closeBtn = h("button", { type: "button", class: "btn", text: close })
|
|
const dlg = h(
|
|
"dialog",
|
|
{ class: "dialog" },
|
|
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
|
|
)
|
|
const done = () => {
|
|
dlg.close()
|
|
dlg.remove()
|
|
resolve()
|
|
}
|
|
copyBtn.addEventListener("click", () => {
|
|
navigator.clipboard
|
|
?.writeText(code)
|
|
.then(() => (copyBtn.textContent = copied))
|
|
.catch(() => {
|
|
// No clipboard: the line is selected, for Ctrl+C.
|
|
const range = document.createRange()
|
|
range.selectNodeContents(line)
|
|
getSelection()?.removeAllRanges()
|
|
getSelection()?.addRange(range)
|
|
})
|
|
})
|
|
closeBtn.addEventListener("click", done)
|
|
dlg.addEventListener("cancel", (e) => {
|
|
e.preventDefault()
|
|
done()
|
|
})
|
|
document.body.append(dlg)
|
|
dlg.showModal()
|
|
copyBtn.focus()
|
|
})
|
|
}
|
|
|
|
let openPop = null
|
|
|
|
/** Close the popover that is open, if any. */
|
|
export function closePopover() {
|
|
if (!openPop) return
|
|
const { el, back, onClose } = openPop
|
|
openPop = null
|
|
el.remove()
|
|
document.removeEventListener("pointerdown", outsidePop, true)
|
|
document.removeEventListener("keydown", escPop, true)
|
|
onClose?.()
|
|
back?.focus?.()
|
|
}
|
|
|
|
function outsidePop(e) {
|
|
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
|
|
}
|
|
|
|
function escPop(e) {
|
|
if (e.key === "Escape" && openPop) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
closePopover()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A small panel under `anchor` (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).
|
|
*/
|
|
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
|
|
closePopover()
|
|
closeMenu()
|
|
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
|
|
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
|
|
draw()
|
|
document.body.append(el)
|
|
const place = () => {
|
|
const at = anchor.getBoundingClientRect()
|
|
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`
|
|
const top = up ? at.top - r.height - 6 : at.bottom + 6
|
|
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
|
|
}
|
|
place()
|
|
openPop = { el, back: anchor, onClose }
|
|
document.addEventListener("pointerdown", outsidePop, true)
|
|
document.addEventListener("keydown", escPop, true)
|
|
el.querySelector("button, input, select")?.focus()
|
|
return {
|
|
redraw() {
|
|
if (openPop?.el !== el) return
|
|
draw()
|
|
place()
|
|
},
|
|
close: closePopover,
|
|
}
|
|
}
|
|
|
|
export function popoverOpen() {
|
|
return !!openPop
|
|
}
|