On the server's /api/v2 (3.4.0, server dev 5d7b79c):
- The activity panel reads GET /chats/{chat}/tree beside the activity and
draws ui/js/tree.js's rows in a Subagents section, the server panel's words
and layout: nested by parent, position, phase, context %, state, model,
mode + request options, messages and the last one, open slots, the ended
ones folded and dimmed. A team's members are no longer drawn again among
the tasks. The stream's `tree` frame redraws it.
- A subagent's conversation (/subagents/{id}/transcript, read again every 3 s
while it works) and its findings open read-only in the panel; Stop asks,
then POSTs /stop. With an agent chat's device away: the kept tree with the
server's note, and its 409 sentence for the rest.
- A new agent chat's Subagents chip from /models?kind=agent&device= (each
model's `subagents`, `agent_subagents.offered` and `device_runs`); an open
agent chat's from its own `subagents`, as before.
- An approval item's `subagent` names it on the card.
- Progress keys are the spec's own ({{field}}), as the server's catalogues
have them; SERVER_PENDING is gone, every sentence is translated.
tests/ui reads the server's contract fixtures (scripts/fetch-fixtures.sh ->
tests/ui/fixtures, SOURCE says the commit): status frames, tree, tree frame,
transcript, findings, models?kind=agent, a subagent's approval; six C4 cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.7 KiB
JavaScript
201 lines
8.7 KiB
JavaScript
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it, the
|
||
// server's own panel in this app (`chat/_activity_tasks.html` tree_row): the main agent at the
|
||
// root, every subagent under its parent in the order added, how many slots are open, then the
|
||
// ended ones folded apart, dimmed, with how each ended. A row is the tasks' grid -- chevron, who
|
||
// it is and what it is doing, its context use in the time column, a stop square -- and opened,
|
||
// its facts (state, model, mode and request options, messages, findings), its task, its phase
|
||
// line, the last room message to or from it, and the buttons to read its conversation and its
|
||
// findings. Its subagents nest under it.
|
||
//
|
||
// The data is the server's `GET /api/v2/chats/{chat}/tree` (and the stream's `tree` frame):
|
||
//
|
||
// { min, max, size, open_slots,
|
||
// agents: [{ id, name, position, parent, state, model, mode, request, manager, task,
|
||
// context: { used, window }, progress, findings, messages }],
|
||
// ended: [{ ..., ended }] }
|
||
//
|
||
// Everything in it but the numbers is a model's words: set as text, never as markup.
|
||
|
||
import { h, icon, ICONS } from "./dom.js"
|
||
import { progressLine, progressText } from "./progress.js"
|
||
|
||
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
|
||
const STATES = {
|
||
working: "working",
|
||
waiting: "waiting for a slot",
|
||
finished: "finished",
|
||
idle: "idle",
|
||
paused: "paused: context full",
|
||
done: "done",
|
||
out_of_context: "out of context",
|
||
stopped: "stopped",
|
||
}
|
||
/** A subagent that may still be stopped (the server's TREE_LIVE). */
|
||
export const TREE_LIVE = ["working", "waiting", "paused"]
|
||
|
||
export function treeState(state) {
|
||
return STATES[state] ? t(STATES[state]) : String(state || "")
|
||
}
|
||
|
||
/** floor(used × 100 / window), or null when the window is not known. */
|
||
export function contextPercent(c) {
|
||
const used = Number(c?.used)
|
||
const window = Number(c?.window)
|
||
if (!(window > 0) || !(used >= 0)) return null
|
||
return Math.min(100, Math.floor((used * 100) / window))
|
||
}
|
||
|
||
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
|
||
export function treeOrder(agents) {
|
||
const names = new Map()
|
||
for (const a of agents) names.set(a.id, a.id).set(a.name, a.id)
|
||
const parentOf = (a) => {
|
||
const p = a.parent ? names.get(a.parent) : null
|
||
return p && p !== a.id ? p : null
|
||
}
|
||
const under = new Map()
|
||
for (const a of agents) {
|
||
const p = parentOf(a)
|
||
if (!under.has(p)) under.set(p, [])
|
||
under.get(p).push(a)
|
||
}
|
||
const out = []
|
||
const seen = new Set()
|
||
const walk = (parent, depth) => {
|
||
for (const a of under.get(parent) || []) {
|
||
if (seen.has(a.id)) continue
|
||
seen.add(a.id)
|
||
out.push({ agent: a, depth })
|
||
walk(a.id, depth + 1)
|
||
}
|
||
}
|
||
walk(null, 0)
|
||
for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 })
|
||
return out
|
||
}
|
||
|
||
/** The room's messages to or from an agent (by name). */
|
||
function messagesOf(room, a) {
|
||
return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name))
|
||
}
|
||
|
||
function firstLine(text) {
|
||
return String(text || "").split("\n").find((l) => l.trim()) || ""
|
||
}
|
||
|
||
/**
|
||
* The tree as rows. `act` holds what the rows can do: `open(a)` (its conversation, read-only),
|
||
* `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded
|
||
* across redraws. `room` is the room as read so far; `device` says the subagents run on the
|
||
* computer the chat runs on (an agent chat).
|
||
*/
|
||
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
|
||
const box = h("div", { class: "subagents" })
|
||
box.append(h("p", { class: "tasks__team", text: t("%(size)s of %(max)s", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }))
|
||
if (Number(tree.open_slots) > 0) box.append(h("p", { class: "tasks__goal subagents__slots", text: t("Open slots: %(n)s", { n: tree.open_slots }) }))
|
||
if (device) box.append(h("p", { class: "tasks__goal", text: t("They run on the computer this chat runs on.") }))
|
||
box.append(h("div", { class: "tasks__list subagents__list" }, treeOrder(tree.agents || []).map(({ agent, depth }) => row(agent, depth, { room, act, now, ended: false }))))
|
||
const ended = tree.ended || []
|
||
if (ended.length) {
|
||
const key = "fold:ended"
|
||
const fold = h(
|
||
"details",
|
||
{ class: "tasks__more subagents__ended", open: !!act.isOpen?.(key) },
|
||
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: `${t("Ended")} ${ended.length}` })),
|
||
h("div", { class: "tasks__list" }, ended.map((a) => row(a, 0, { room, act, now, ended: true }))),
|
||
)
|
||
fold.addEventListener("toggle", () => act.toggled?.(key, fold.open))
|
||
box.append(fold)
|
||
}
|
||
return box
|
||
}
|
||
|
||
function row(a, depth, { room, act, now, ended }) {
|
||
const key = `sub:${a.id}`
|
||
const main = !a.parent && (a.id === "lead" || a.name === "lead")
|
||
const pct = ended ? null : contextPercent(a.context)
|
||
const said = messagesOf(room, a)
|
||
const last = said[said.length - 1]
|
||
const mode = a.mode ? (a.request?.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode) : ""
|
||
const live = !ended && TREE_LIVE.includes(a.state)
|
||
const phase = !ended && a.progress ? progressText(a.progress, now) : ""
|
||
const stop =
|
||
live && !main && act.stop
|
||
? h(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "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()
|
||
act.stop(a)
|
||
},
|
||
},
|
||
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||
)
|
||
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||
const el = h(
|
||
"details",
|
||
{ class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: !!act.isOpen?.(key), dataset: { id: a.id } },
|
||
h(
|
||
"summary",
|
||
{ class: "agent__row" },
|
||
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||
h(
|
||
"span",
|
||
{ class: "agent__what" },
|
||
h(
|
||
"span",
|
||
{ class: "subagent__who" },
|
||
h("bdi", { class: "agent__name", text: a.name || a.id }),
|
||
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
|
||
),
|
||
main ? h("span", { class: "agent__role", text: t("the main agent") }) : a.position ? h("span", { class: "agent__role", text: a.position }) : null,
|
||
// The phase alone where a row has no room for its clock (progress.md §3); ended: how.
|
||
h("span", { class: "subagent__phase", dataset: { phase: phase ? a.progress.phase : "" }, text: phase || treeState(a.state) }),
|
||
),
|
||
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
|
||
stop,
|
||
),
|
||
h(
|
||
"div",
|
||
{ class: "agent__body" },
|
||
h(
|
||
"p",
|
||
{ class: "agent__facts" },
|
||
h("span", { class: "agent__state", text: treeState(a.state) }),
|
||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||
mode ? h("span", { text: mode }) : null,
|
||
h("span", { text: t("messages: %(n)s", { n: a.messages ?? said.length }) }),
|
||
a.findings ? h("span", { text: t("findings: %(n)s", { n: a.findings }) }) : null,
|
||
),
|
||
a.task && !main ? h("p", { class: "agent__brief", text: a.task }) : null,
|
||
phase ? h("p", { class: "agent__text subagent__line", dataset: { progress: JSON.stringify(a.progress) }, text: progressLine(a.progress, now) }) : null,
|
||
last ? h("p", { class: "agent__text", text: `${last.from} → ${(last.to || []).join(", ")}: ${firstLine(last.text)}` }) : null,
|
||
main
|
||
? null
|
||
: h(
|
||
"p",
|
||
{ class: "subagent__buttons" },
|
||
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => act.open(a) }) : null,
|
||
act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null,
|
||
),
|
||
),
|
||
)
|
||
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth))))
|
||
el.addEventListener("toggle", () => act.toggled?.(key, el.open))
|
||
return el
|
||
}
|
||
|
||
/** The tree's phase lines move on by themselves between reads (their clock). */
|
||
export function tickTree(root, now = Date.now()) {
|
||
for (const el of root.querySelectorAll(".subagent__line[data-progress]")) {
|
||
try {
|
||
el.textContent = progressLine(JSON.parse(el.dataset.progress), now)
|
||
} catch {}
|
||
}
|
||
}
|