A chat app's shape: sidebar, a new chat as a chat page, the chat's settings as composer chips
- 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>
This commit is contained in:
15 files changed
+1856
-916
No files matched your search
+73
-3
@@ -21,11 +21,12 @@ function outside(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
|
||||
* 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) {
|
||||
@@ -50,7 +51,9 @@ export function menu(anchor, items) {
|
||||
},
|
||||
},
|
||||
h("span", { class: "menu__lead" }, item.lead || null),
|
||||
h("span", { class: "menu__label", text: item.label }),
|
||||
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 ? "✓" : "" }),
|
||||
),
|
||||
)
|
||||
@@ -76,7 +79,9 @@ export function menu(anchor, items) {
|
||||
// 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`
|
||||
// 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()
|
||||
@@ -166,3 +171,68 @@ export function showLine({ title, text, code, copy, copied, close }) {
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user