The activity panel beside a chat, from /api/v2's activity routes: Context
(used share, tokens against the window, a segmented bar and a row per
category), Outputs (the files written, lines added and removed, each opening
in the viewer), Background tasks (a team's members as a tree, helpers and
commands: state, elapsed time counting on between reads, model, latest step,
what it is writing, its whole conversation read-only, Stop -- a member after
a confirmation, as it takes those under it), the team's room (who to whom,
reports marked, the words through the page's own markdown) and the plan.
The chat follows GET /chats/{chat}/activity/events while it is open: the strip
above the message box sums the agents up and opens the panel; a new version
reads the panel again while it is open, the costly context part on opening,
after a reply and at most every 15 s. The stream is reopened when the server
closes it, and more slowly while it fails. Beside the chat on a wide window
(the viewer then goes over the chat), over it below --bp-wide, a full-height
sheet at --bp-narrow. The header's Activity button, the palette and Escape
open and close it.
An agent chat with a device session (device_session_id) can be continued in a
terminal: the chat's menu and a header button show 'cd <folder> && lembas -s
<id>' to copy, with lembas the CLI as it starts here (on PATH, else the app's
own copy by its path). Shown rather than run: no terminal opens the same way
on every desktop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.2 KiB
JavaScript
169 lines
6.2 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, 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()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
})
|
|
}
|