Subagents (C4): the tree in the activity panel, read-only sessions, findings, stop; agent chats' setting
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

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>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 10:44:35 +00:00
1 parent 89c769c806
commit 4b5b22c7d4
22 files changed
+1400 -181

No files matched your search

+130 -8
View File
@@ -13,6 +13,14 @@
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
// POST /chats/{chat}/compact summarise the earlier turns now
// GET /chats/{chat}/tree?since= the subagent tree (spec 3.4.0) and the room
// GET /chats/{chat}/subagents/{id}/transcript a subagent's conversation, read-only
// GET /chats/{chat}/subagents/{id}/findings what it kept
// POST /chats/{chat}/subagents/{id}/stop it and those under it
//
// The tree comes too as the reply stream's `tree` frame (chat.js hands it on). An agent chat's
// subagents run on its device: while the device is away the server sends the tree it kept
// (`live` false) and answers 409 for a conversation or findings, in its own words.
//
// 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
@@ -27,6 +35,7 @@ import { get, post, follow, why } from "./bridge.js"
import { markdown } from "./markdown.js"
import { ask } from "./popup.js"
import { openServerFile } from "./explorer.js"
import { treePart, tickTree, treeState, TREE_LIVE } from "./tree.js"
const ACTIVE = ["queued", "working", "waiting"]
// How many live agents show before "Show N more", as on the server's panel.
@@ -50,7 +59,12 @@ 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
let view = null // null: the panel; { kind, id, name }: one agent's conversation ({ kind: "sub", what } a subagent's)
let tree = null // the subagent tree (spec 3.4.0), null for none or an older server
let treeRoom = [] // the room as the tree's answer gives it (an agent chat's comes only there)
let treeLive = true // false: an agent chat's device is away, and this is the tree the server kept
let treeNote = "" // why the last stop did not go through
let subTimer = 0 // a live subagent's conversation, read again while it works
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
let ticker = 0
@@ -147,6 +161,10 @@ export function watchActivity(next) {
data = null
room = []
cursor = 0
tree = null
treeRoom = []
treeLive = true
treeNote = ""
readAt = contextAt = 0
view = null
compactError = ""
@@ -164,6 +182,15 @@ export function watchActivity(next) {
}
}
/** The reply stream's `tree` frame: the tree as it is now. */
export function treeChanged(chatId, next) {
if (!chat || chat.id !== chatId) return
tree = next || null
if (!panelOpen()) return
if (!view) render()
else if (view.kind === "sub" && view.what === "transcript") readAgent()
}
/** 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)
@@ -241,8 +268,16 @@ async function read(withContext) {
const id = chat.id
reading = (async () => {
try {
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
const [r, tr] = await Promise.all([
get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`),
// A server before 3.4.0 has no tree: 404, and the panel is as it was.
get(`/chats/${enc(id)}/tree`).catch(() => null),
])
const a = r.activity
if (chat?.id !== id || !a) return
tree = tr?.tree || null
treeRoom = tr?.room || []
treeLive = tr ? tr.live !== false : true
if (!withContext) a.context = data?.context ?? null
else contextAt = Date.now()
if (a.team) {
@@ -363,6 +398,7 @@ function tick() {
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
el.textContent = duration(Number(el.dataset.seconds) + extra)
}
tickTree($("activity-body"))
}
function renderError(text) {
@@ -392,13 +428,15 @@ function render() {
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
const entries = data.agents || []
// The tree draws the subagents; a team's members are not drawn again among the tasks.
const entries = (data.agents || []).filter((e) => !(tree && e.kind === "member"))
const parts = [
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
tree ? section("subagents", t("Subagents"), 0, subagentsPart()) : null,
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
data.team ? section("room", t("Room"), room.length, roomPart()) : null,
data.team || treeRoom.length ? section("room", t("Room"), roomList().length, roomPart()) : null,
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
]
body.replaceChildren(...parts.filter(Boolean))
@@ -561,7 +599,7 @@ function outputsPart(outputs) {
}
function tasksPart(entries) {
const team = data.team
const team = tree ? null : 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. Subagents, a team's members and background commands show here while they work.") }))
@@ -584,7 +622,84 @@ function tasksPart(entries) {
return box
}
/** The room: as the activity read it (a chat's team), else as the tree's answer gave it. */
function roomList() {
return room.length ? room : treeRoom
}
function subagentsPart() {
const box = treePart(tree, {
room: roomList(),
device: chat?.kind === "agent",
act: {
open: (a) => openSub(a, "transcript"),
findings: (a) => openSub(a, "findings"),
stop: stopSub,
isOpen: (key) => openRows.has(key),
toggled: (key, on) => (on ? openRows.add(key) : openRows.delete(key)),
},
})
if (!treeLive) box.prepend(h("p", { class: "activity__empty", text: t("This device is not linked right now.") }))
if (treeNote) box.prepend(h("p", { class: "activity__empty is-error", text: treeNote }))
return box
}
async function stopSub(a) {
if (!chat) return
const yes = await ask({ title: t("Stop %(name)s", { name: a.name }), text: t("Stop %(name)s and the subagents under it?", { name: a.name }), ok: t("Stop"), cancel: t("Cancel"), danger: true })
if (!yes) return
treeNote = ""
try {
await post(`/chats/${enc(chat.id)}/subagents/${enc(a.id)}/stop`)
} catch (err) {
treeNote = why(err)
}
read(false)
}
function openSub(a, what) {
view = { kind: "sub", id: a.id, name: a.name || a.id, what }
$("activity").dataset.view = "agent"
$("activity-title").textContent = what === "findings" ? `${view.name} · ${t("Findings")}` : view.name
$("activity-back").hidden = false
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
readAgent()
}
/** A subagent's conversation or findings, read-only; a live one's conversation is read again while it works. */
async function readSub() {
clearTimeout(subTimer)
const { id, what } = view
let r
try {
r = await get(`/chats/${enc(chat.id)}/subagents/${enc(id)}/${what}`)
} catch (err) {
// 409: an agent chat's device is away -- the server says it is on the device.
if (view?.id === id && view.what === what) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
return
}
if (view?.kind !== "sub" || view.id !== id || view.what !== what) return
const body = $("activity-body")
if (what === "findings") {
const list = r.findings || []
body.replaceChildren(
list.length
? h("ol", { class: "findings" }, list.map((f) => h("li", { class: "findings__item", value: String(f.n) }, h("div", { class: "md" }, markdown(String(f.text || ""))))))
: h("p", { class: "activity__empty", text: t("Nothing yet.") }),
)
return
}
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
const live = TREE_LIVE.includes(r.state)
const head = h("div", { class: "convo__head" }, h("p", { class: "agent__facts" }, h("span", { class: "agent__state", text: treeState(r.state) })))
const turns = turnsOf(r.turns || [])
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
if (live) subTimer = setTimeout(() => view?.kind === "sub" && view.id === id && panelOpen() && readAgent(), 3000)
}
function roomPart() {
const room = roomList()
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
return h(
"ol",
@@ -747,6 +862,7 @@ function openAgent(a) {
}
function closeAgent() {
clearTimeout(subTimer)
view = null
render()
$("activity-close").focus()
@@ -754,6 +870,7 @@ function closeAgent() {
async function readAgent() {
if (!chat || !view) return
if (view.kind === "sub") return readSub()
const { kind, id } = view
let r
try {
@@ -779,7 +896,14 @@ async function readAgent() {
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) => {
const turns = turnsOf(r.transcript || [])
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
}
/** A conversation's turns, read-only: the person's (or the harness's) messages and the replies. */
function turnsOf(list) {
return list.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 || "") }))
}
@@ -806,8 +930,6 @@ async function readAgent() {
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 ------------------------------------------------------------------------------------------