The activity panel, and continuing an agent chat in a terminal
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

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>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 10:50:46 +00:00
1 parent 844684a152
commit d04bc4b6bd
10 files changed
+1072 -40

No files matched your search

+667
View File
@@ -0,0 +1,667 @@
// The chat's activity panel, beside it: what is in its context, by category, against the model's
// window; the files the chat and its agents wrote; every background agent -- a team's members as
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
// facts as the server's own panel (`services/activity.py`), from /api/v2:
//
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
//
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
// while the panel is open a new `version` reads the panel again. The context is the costly part
// of a read; it is asked for when the panel opens, after a reply and at most every so often.
//
// Every name, role, brief, step, room message and conversation here is a model's words: set as
// text, and a room message or a reply through the page's own markdown (markdown.js), which
// builds elements and never parses HTML.
import { $, h, clear, icon, ICONS } from "./dom.js"
import { get, post, follow, why } from "./bridge.js"
import { markdown } from "./markdown.js"
import { ask } from "./popup.js"
import { openServerFile } from "./explorer.js"
const ACTIVE = ["queued", "working", "waiting"]
// How many live agents show before "Show N more", as on the server's panel.
const SHOWN = 6
// The context is read again on a change at most this often (ms); always on opening and after a reply.
const CONTEXT_EVERY = 15000
let layout = () => {} // (on) -> the shell makes room for the panel
let chat = null // the open chat: { id, kind, … }
let stream = null // the events stream's handle
let retry = 0 // the reconnect timer
let failures = 0
let summary = null
let version = ""
let data = null // the last `activity`
let room = [] // the room so far: each read asks only for what came after `cursor`
let cursor = 0
let readAt = 0 // when `data` was read: the elapsed times count on from there
let contextAt = 0
let reading = null // the read in flight
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
let view = null // null: the panel; { kind, id, name }: one agent's conversation
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
let ticker = 0
// --- Words ----------------------------------------------------------------------------------------
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
export function duration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0))
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
}
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
export function compact(value) {
const n = Math.trunc(Number(value) || 0)
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
if (n < 1000) return String(n)
if (n < 100000) return `${trim(n / 1000, 1)}k`
if (n < 1000000) return `${Math.round(n / 1000)}k`
return `${trim(n / 1000000, 2)}M`
}
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
export function summaryText(counts) {
if (!counts) return ""
const parts = []
if (counts.working === 1) parts.push(t("1 agent working"))
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
return parts.join(" · ")
}
function stateWord(state) {
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
}
function contextWord(key) {
return (
{
messages: t("Messages"),
system: t("System prompt"),
memory: t("Memory and personal"),
instructions: t("Instruction files"),
tools: t("Tools"),
mcp_tools: t("MCP tools"),
since: t("Since the last reply"),
buffer: t("Compaction buffer"),
free: t("Free space"),
}[key] || String(key)
)
}
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
function percentSentence(text, fields) {
return t(text, fields).replace(/%%/g, "%")
}
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
export function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Following a chat -----------------------------------------------------------------------------
const enc = encodeURIComponent
export function panelOpen() {
return !$("activity").hidden
}
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
export function watchActivity(next) {
const id = next?.id || null
if (id && chat?.id === id) {
chat = next
return
}
stop()
chat = id ? next : null
summary = null
version = ""
data = null
room = []
cursor = 0
readAt = contextAt = 0
view = null
openRows.clear()
renderStrip()
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
if (!chat) {
if (panelOpen()) showPanel(false)
return
}
listenTo(chat.id)
if (panelOpen()) {
render()
read(true)
}
}
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
export function activityChanged() {
if (chat && panelOpen()) read(true)
}
function stop() {
clearTimeout(retry)
retry = 0
failures = 0
stream?.close()
stream = null
}
async function listenTo(id) {
try {
const handle = await follow(`/chats/${enc(id)}/activity/events`, {
onFrame: (event, frame) => {
if (chat?.id !== id || event !== "activity" || !frame || typeof frame !== "object") return
failures = 0
heard(frame)
},
// The server closes the stream after a while (`close`): it is opened again at once. A broken
// one is opened again later, more slowly each time -- polling, in effect, while it fails.
onEnd: (err) => {
if (chat?.id !== id) return
stream = null
failures = err ? failures + 1 : 0
retry = setTimeout(() => {
if (chat?.id === id && !stream) {
if (err && panelOpen()) read(false)
listenTo(id)
}
}, err ? Math.min(60000, 5000 * failures) : 500)
},
})
if (chat?.id !== id) handle.close()
else stream = handle
} catch {
if (chat?.id !== id) return
failures += 1
retry = setTimeout(() => chat?.id === id && listenTo(id), Math.min(60000, 5000 * failures))
}
}
function heard(frame) {
summary = frame.summary || summary
renderStrip()
if (frame.version && frame.version !== version) {
version = frame.version
if (panelOpen()) read(Date.now() - contextAt > CONTEXT_EVERY)
}
}
/** Read the panel's facts; one read at a time, and one more after it if asked for meanwhile. */
async function read(withContext) {
if (!chat) return
if (reading) {
readAgain = withContext ? "context" : readAgain || "plain"
return reading
}
const id = chat.id
reading = (async () => {
try {
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
if (chat?.id !== id || !a) return
if (!withContext) a.context = data?.context ?? null
else contextAt = Date.now()
if (a.team) {
// A cursor lower than ours is a room begun again (a new team): read from the start.
if (a.team.cursor < cursor) {
room = []
cursor = 0
readAgain = readAgain || "plain"
}
for (const m of a.team.room || []) if (m.n > cursor) room.push(m)
cursor = Math.max(cursor, a.team.cursor || 0)
} else {
room = []
cursor = 0
}
data = a
version = a.version || version
summary = a.summary || summary
readAt = Date.now()
renderStrip()
if (view) await readAgent()
else render()
} catch (err) {
if (chat?.id === id && !data) renderError(why(err))
}
})()
await reading
reading = null
if (readAgain) {
const ctxToo = readAgain === "context"
readAgain = ""
read(ctxToo)
}
}
// --- The strip above the message box ---------------------------------------------------------------
function renderStrip() {
const strip = $("activity-strip")
const text = chat && summary?.total ? summaryText(summary) : ""
strip.hidden = !text
if (!text) return clear(strip)
const busy = summary.active > 0
strip.replaceChildren(
h(
"button",
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
h("span", { class: `dot${busy ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "strip__text", text }),
icon(ICONS.chevron),
),
)
}
// --- The panel ------------------------------------------------------------------------------------------
/** Open or close the panel (the header's Activity button, the strip, Escape). */
export function showPanel(on) {
if (on && !chat) return
$("activity").hidden = !on
layout(on)
$("activity-open")?.setAttribute("aria-expanded", String(on))
clearInterval(ticker)
ticker = 0
if (!on) {
view = null
return
}
ticker = setInterval(tick, 1000)
render()
read(true)
$("activity-close").focus()
}
export function togglePanel() {
showPanel(!panelOpen())
}
/** The elapsed times of agents still at work count on between reads. */
function tick() {
if (document.visibilityState !== "visible") return
const extra = (Date.now() - readAt) / 1000
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
el.textContent = duration(Number(el.dataset.seconds) + extra)
}
}
function renderError(text) {
clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text }))
}
function section(name, title, count, ...body) {
const el = h(
"details",
{ class: "activity__section", open: !openRows.has(`section:${name}`) },
h("summary", { class: "activity__heading" }, h("span", { text: title }), count ? h("span", { class: "activity__count", text: String(count) }) : null),
...body,
)
el.addEventListener("toggle", () => (el.open ? openRows.delete(`section:${name}`) : openRows.add(`section:${name}`)))
return el
}
function render() {
$("activity").dataset.view = "panel"
const head = $("activity-title")
$("activity-back").hidden = true
head.textContent = t("Activity")
const body = $("activity-body")
if (!data) {
clear(body).append(h("p", { class: "activity__empty", text: t("Loading…") }))
return
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
body.replaceChildren(
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
section("tasks", t("Background tasks"), 0, tasksPart()),
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
)
body.scrollTop = scroll
}
function contextPart(c) {
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
const known = c.percent !== null && c.percent !== undefined
const used = h(
"p",
{ class: "ctx__used" },
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
)
const cats = c.categories || []
const bar = c.limit
? h(
"div",
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
cats
.filter((r) => r.key !== "free" && r.tokens)
.map((r) => {
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
return seg
}),
)
: null
const rows = h(
"ul",
{ class: "ctx__rows" },
cats.map((r) =>
h(
"li",
{ class: "ctx__row" },
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
h("span", { class: "ctx__name", text: contextWord(r.key) }),
h("span", { class: "ctx__num", text: compact(r.tokens) }),
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
),
),
)
const note = h(
"p",
{ class: "ctx__note" },
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
)
return h("div", { class: "ctx" }, used, bar, rows, note)
}
/** A category's key as a class name: the server's words, kept to letters and underscores. */
function safeKey(key) {
return String(key).replace(/[^a-z_]/gi, "") || "other"
}
function outputsPart(outputs) {
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
return h(
"ul",
{ class: "outs" },
outputs.map((o) =>
h(
"li",
{ class: "outs__row" },
icon(ICONS.file),
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
h(
"span",
{ class: "outs__diff" },
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
),
),
),
)
}
function tasksPart() {
const entries = data.agents || []
const team = data.team
const box = h("div", { class: "tasks", "aria-live": "polite" })
if (!entries.length && !team) {
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
return box
}
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
if (team) {
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
}
const live = entries.filter((e) => ACTIVE.includes(e.state))
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
if (live.length) {
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
}
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
if (team) {
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
box.append(
room.length
? h(
"ol",
{ class: "room" },
room.map((m) =>
h(
"li",
{ class: `room__entry${m.report ? " is-report" : ""}` },
h(
"div",
{ class: "room__meta" },
h("span", { class: "room__n", text: `#${m.n}` }),
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
),
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
),
),
)
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
)
}
return box
}
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
function fold(name, label, list) {
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
return el
}
/** An agent and, under it, the agents it leads or started. */
function agentRows(a) {
return [agentRow(a), ...(a.children || []).map(agentRows)]
}
function agentRow(a) {
const key = `${a.kind}:${a.id}`
const active = ACTIVE.includes(a.state)
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
if (active) {
time.dataset.active = ""
time.dataset.seconds = String(Number(a.seconds) || 0)
}
const stopBtn = a.stoppable
? h(
"button",
{
type: "button",
class: "pane__btn agent__stop",
"aria-label": t("Stop %(name)s", { name: a.name }),
title: t("Stop %(name)s", { name: a.name }),
onclick: (e) => {
e.preventDefault()
e.stopPropagation()
stopAgent(a)
},
},
icon(ICONS.stop),
)
: h("span", { class: "agent__stop", "aria-hidden": "true" })
const facts = h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
a.agent ? h("span", { text: a.agent }) : null,
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
)
const step = a.step
? h(
"p",
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
)
: null
const el = h(
"details",
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
h(
"summary",
{ class: "agent__row" },
h("span", { class: "agent__dot", "aria-hidden": "true" }),
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
time,
stopBtn,
),
h(
"div",
{ class: "agent__body" },
facts,
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
step,
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
a.transcript
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
: null,
),
)
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
return el
}
async function stopAgent(a) {
if (!chat) return
if (a.kind === "member") {
const yes = await ask({
title: t("Stop %(name)s", { name: a.name }),
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
ok: t("Stop"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
}
try {
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
} catch (err) {
// 409: it had finished meanwhile; the read below shows that.
if (err?.status !== 409) renderError(why(err))
}
read(false)
}
// --- One agent's conversation, read-only ------------------------------------------------------------
function openAgent(a) {
view = { kind: a.kind, id: a.id, name: a.name || a.id }
$("activity").dataset.view = "agent"
$("activity-title").textContent = view.name
$("activity-back").hidden = false
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
readAgent()
}
function closeAgent() {
view = null
render()
$("activity-close").focus()
}
async function readAgent() {
if (!chat || !view) return
const { kind, id } = view
let r
try {
r = await get(`/chats/${enc(chat.id)}/agents/${enc(kind)}/${enc(id)}`)
} catch (err) {
if (view?.id === id) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
return
}
if (view?.kind !== kind || view?.id !== id) return
const a = r.agent || {}
const body = $("activity-body")
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
const head = h(
"div",
{ class: "convo__head" },
h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
h("span", { text: duration(a.seconds) }),
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
),
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
)
const turns = (r.transcript || []).map((turn) => {
if (turn.role === "user") {
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
}
return h(
"article",
{ class: "msg msg--assistant convo__turn" },
turn.text ? h("div", { class: "md" }, markdown(String(turn.text))) : null,
turn.tools?.length
? h(
"ul",
{ class: "convo__tools" },
turn.tools.map((c) =>
h(
"li",
{ class: "convo__tool" },
h("span", { class: `dot${c.isError ? " is-error" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: String(c.name || "") }),
c.title ? h("code", { class: "agent__title", text: String(c.title) }) : null,
),
),
)
: null,
turn.error ? h("p", { class: "msg__note is-error", text: String(turn.error) }) : null,
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
)
})
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
}
// --- Start ------------------------------------------------------------------------------------------
export function initActivity({ onLayout }) {
layout = onLayout || layout
$("activity-close").replaceChildren(icon(ICONS.close))
$("activity-close").addEventListener("click", () => showPanel(false))
$("activity-back").replaceChildren(icon(ICONS.back))
$("activity-back").addEventListener("click", closeAgent)
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
// panel stays; one Escape closes one thing.
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
if (!$("viewer").hidden) return
e.preventDefault()
if (view) closeAgent()
else showPanel(false)
},
true,
)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && chat && panelOpen()) read(false)
})
}