The activity panel, and continuing an agent chat in a terminal
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:
10 files changed
+1072
-40
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user