The events stream is held while it opens, so a reconnect and the window showing again cannot open two; while the window is hidden in the tray it is not reopened (the server polls for each), and showing the window opens it again. The Thinking switch says 'Think before answering' in the header too: 'Thinking' is the catalogues' word for a reply in progress. The chat's header restacks by the chat column's own width (a container query at --bp-narrow), so a chat beside the activity panel keeps its title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
25 KiB
JavaScript
684 lines
25 KiB
JavaScript
// 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) {
|
||
// Held while the stream opens, so nothing opens a second one meanwhile.
|
||
const pending = { close() {} }
|
||
stream = pending
|
||
let ended = false
|
||
let handleId = null
|
||
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) => {
|
||
ended = true
|
||
if (chat?.id !== id) return
|
||
if (stream === pending || stream?.id === handleId) stream = null
|
||
// Not while the window is hidden (in the tray): it is opened again when it shows.
|
||
if (document.visibilityState === "hidden") return
|
||
failures = err ? failures + 1 : 0
|
||
retry = setTimeout(() => {
|
||
retry = 0
|
||
if (chat?.id === id && !stream) {
|
||
if (err && panelOpen()) read(false)
|
||
listenTo(id)
|
||
}
|
||
}, err ? Math.min(60000, 5000 * failures) : 500)
|
||
},
|
||
})
|
||
handleId = handle.id
|
||
if (chat?.id !== id || stream !== pending) handle.close()
|
||
else if (!ended) stream = handle
|
||
} catch {
|
||
if (stream === pending) stream = null
|
||
if (chat?.id !== id) return
|
||
failures += 1
|
||
retry = setTimeout(() => {
|
||
retry = 0
|
||
if (chat?.id === id && !stream) 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) return
|
||
if (!stream && !retry) listenTo(chat.id)
|
||
if (panelOpen()) read(false)
|
||
})
|
||
}
|