Subagents (C4): the tree in the activity panel, read-only sessions, findings, stop; agent chats' setting
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:
22 files changed
+1400
-181
No files matched your search
+130
-8
@@ -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 ------------------------------------------------------------------------------------------
|
||||
|
||||
+53
-2
@@ -13,9 +13,9 @@ import { markdown } from "./markdown.js"
|
||||
import { openViewer, showKind } from "./viewer.js"
|
||||
import { readLocal } from "./explorer.js"
|
||||
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||
import { watchActivity, activityChanged, planList, activityButton } from "./activity.js"
|
||||
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||
import { folderName } from "./list.js"
|
||||
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||
@@ -66,6 +66,38 @@ function terminalTarget() {
|
||||
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
|
||||
}
|
||||
|
||||
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
|
||||
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
|
||||
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
|
||||
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
|
||||
|
||||
async function readAgentFacts(device) {
|
||||
if (!device) return null
|
||||
const known = agentFacts.get(device)
|
||||
if (known && Date.now() - known.at < 5000) return known
|
||||
try {
|
||||
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
|
||||
if (!r.agent_subagents) {
|
||||
agentFacts.set(device, { at: Date.now(), none: true })
|
||||
return agentFacts.get(device)
|
||||
}
|
||||
noteSubagentsCap(r.subagents_cap)
|
||||
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
|
||||
agentFacts.set(device, facts)
|
||||
return facts
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
|
||||
function agentOffer(facts, model) {
|
||||
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
|
||||
const said = facts.models.get(model?.id)
|
||||
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
|
||||
return { cap: subagentsCap(), own: namedOnly(said) }
|
||||
}
|
||||
|
||||
/** Text into the message box, after what is there, for the person to read before sending. */
|
||||
export function intoComposer(text) {
|
||||
const box = $("composer-text")
|
||||
@@ -412,6 +444,10 @@ class Live {
|
||||
case "ask":
|
||||
renderCards(this.chat, data.cards || [])
|
||||
break
|
||||
case "tree":
|
||||
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
|
||||
treeChanged(this.chat.id, data.tree)
|
||||
break
|
||||
case "jobs":
|
||||
this.jobs.hidden = !(data.jobs || []).length
|
||||
append(
|
||||
@@ -522,6 +558,11 @@ function cardOf(chat, card) {
|
||||
const part = h("div", { class: "ask__item" })
|
||||
// A background subagent asks, not the conversation's own turn: said first (`job`).
|
||||
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
|
||||
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
|
||||
if (item.subagent) {
|
||||
const sub = item.subagent
|
||||
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
|
||||
}
|
||||
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||
let detailShown = false
|
||||
if (item.kind === "approval") {
|
||||
@@ -822,6 +863,16 @@ async function renderChips() {
|
||||
}, mayPick))
|
||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||
const known = device ? agentFacts.get(device) : null
|
||||
const offer = device ? agentOffer(known, model) : null
|
||||
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
|
||||
if (device && (!known || Date.now() - known.at > 5000)) {
|
||||
readAgentFacts(device).then((f) => {
|
||||
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||
})
|
||||
}
|
||||
// Last, so the chat's own settings come first where the line scrolls.
|
||||
if (shell) {
|
||||
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||
|
||||
+22
-23
@@ -4,29 +4,29 @@
|
||||
// sentences. The server sends the object beside the old `status` text in a chat's stream; where it
|
||||
// sends none (an older server), the status text is drawn as before.
|
||||
//
|
||||
// The texts are the spec's, with its `{{field}}` written as the page's `%(field)s`; the server's
|
||||
// catalogues translate them under the same English (scripts/gen-i18n.py).
|
||||
// The texts are the spec's, word for word with their `{{field}}`s: the server's catalogues
|
||||
// translate them under the same keys (scripts/gen-i18n.py takes them from there).
|
||||
|
||||
/** The thirteen phases: a text for each, and a variant where a field may be missing. */
|
||||
const TEXTS = {
|
||||
queued: "Queued",
|
||||
"queued.ahead": "Queued: %(ahead)s ahead",
|
||||
waiting_for_model: "Waiting for %(model)s: %(reason)s",
|
||||
loading_model: "Loading %(model)s",
|
||||
reading_prompt: "Reading the prompt: %(done)s / %(total)s tokens (%(percent)s%%)",
|
||||
"queued.ahead": "Queued: {{ahead}} ahead",
|
||||
waiting_for_model: "Waiting for {{model}}: {{reason}}",
|
||||
loading_model: "Loading {{model}}",
|
||||
reading_prompt: "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||
"reading_prompt.bare": "Reading the prompt",
|
||||
thinking: "Thinking",
|
||||
writing: "Writing: %(rate)s tok/s",
|
||||
writing: "Writing: {{rate}} tok/s",
|
||||
"writing.bare": "Writing",
|
||||
running_tool: "Running %(tool)s: %(what)s",
|
||||
"running_tool.bare": "Running %(tool)s",
|
||||
waiting_for_person: "Waiting for you: %(what)s",
|
||||
waiting_for_subagents: "Waiting for subagents: %(running)s running",
|
||||
running_tool: "Running {{tool}}: {{what}}",
|
||||
"running_tool.bare": "Running {{tool}}",
|
||||
waiting_for_person: "Waiting for you: {{what}}",
|
||||
waiting_for_subagents: "Waiting for subagents: {{running}} running",
|
||||
compacting: "Compacting the conversation",
|
||||
retrying: "Retrying in %(in)s s: %(reason)s",
|
||||
retrying: "Retrying in {{in}} s: {{reason}}",
|
||||
stopped: "Stopped",
|
||||
"stopped.reason": "Stopped: %(reason)s",
|
||||
failed: "Failed: %(reason)s",
|
||||
"stopped.reason": "Stopped: {{reason}}",
|
||||
failed: "Failed: {{reason}}",
|
||||
}
|
||||
|
||||
/** The words a field stands for. */
|
||||
@@ -36,7 +36,7 @@ const REASONS = {
|
||||
group: "its server holds another model now",
|
||||
}
|
||||
const WHAT = {
|
||||
approval: "allow %(tool)s?",
|
||||
approval: "allow {{tool}}?",
|
||||
"approval.bare": "an approval",
|
||||
question: "a question",
|
||||
plan: "the plan",
|
||||
@@ -44,19 +44,17 @@ const WHAT = {
|
||||
const STOPPED = { person: "by you", steps: "step limit", budget: "budget" }
|
||||
|
||||
/** Every sentence above, for scripts/gen-i18n.py to find (it reads `t("…")` calls by hand). */
|
||||
export const PROGRESS_SENTENCES = [...Object.values(TEXTS), ...Object.values(REASONS), ...Object.values(WHAT), ...Object.values(STOPPED), "%(text)s · %(elapsed)s"]
|
||||
export const PROGRESS_SENTENCES = [...Object.values(TEXTS), ...Object.values(REASONS), ...Object.values(WHAT), ...Object.values(STOPPED)]
|
||||
|
||||
const NO_ELAPSED = ["stopped", "failed"]
|
||||
|
||||
const has = (p, k) => p[k] !== undefined && p[k] !== null && p[k] !== ""
|
||||
|
||||
/** A sentence in the page's language, `%%` a percent sign (the server's way), then its
|
||||
* `%(field)s` filled -- in that order, so a field's own text is never read as a template. */
|
||||
/** A sentence in the page's language (the spec's text is the key, as in the server's
|
||||
* catalogues), its `{{field}}`s filled -- after the lookup, so a field's own text is never read
|
||||
* as a template. */
|
||||
function say(text, fields = {}) {
|
||||
return t(text)
|
||||
.replace(/%%/g, "\u0000")
|
||||
.replace(/%\((\w+)\)s/g, (all, name) => (Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all))
|
||||
.replace(/\u0000/g, "%")
|
||||
return t(text).replace(/\{\{(\w+)\}\}/g, (all, name) => (Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all))
|
||||
}
|
||||
|
||||
/** An integer as it is; a rate with one decimal, in the page's language (12.5, 12,5). */
|
||||
@@ -126,7 +124,8 @@ export function progressText(p, now = Date.now()) {
|
||||
export function progressLine(p, now = Date.now(), { bare = false } = {}) {
|
||||
const text = progressText(p, now)
|
||||
if (!text || bare || NO_ELAPSED.includes(p.phase) || !has(p, "since")) return text
|
||||
return say("%(text)s · %(elapsed)s", { text, elapsed: elapsed(now - Number(p.since)) })
|
||||
// progress.line, `{{text}} · {{elapsed}}`: the same in every language.
|
||||
return `${text} · ${elapsed(now - Number(p.since))}`
|
||||
}
|
||||
|
||||
/** Whether the line changes by itself as time passes (it has a clock, or a countdown). */
|
||||
|
||||
+73
-65
@@ -1,24 +1,25 @@
|
||||
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it: the
|
||||
// main agent at the root, every subagent under its parent in the order added, the open slots,
|
||||
// then the ended ones, dimmed, with how each ended. Per agent: its name and id, its position,
|
||||
// its state, model, mode (and request options), how full its context is, its current phase (the
|
||||
// progress line), its messages in the room, and -- for any subagent, live or ended -- its session
|
||||
// read-only and its findings; a live one can be stopped.
|
||||
// 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 spec's, as the server passes it on in /api/v2 for a chat's own subagents and
|
||||
// for an agent chat's alike:
|
||||
// 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: [{ id, name, position, parent, state, ended, findings }] }
|
||||
// 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 } from "./progress.js"
|
||||
import { progressLine, progressText } from "./progress.js"
|
||||
|
||||
/** The states' words (loop.json team.read.states). */
|
||||
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
|
||||
const STATES = {
|
||||
working: "working",
|
||||
waiting: "waiting for a slot",
|
||||
@@ -29,7 +30,8 @@ const STATES = {
|
||||
out_of_context: "out of context",
|
||||
stopped: "stopped",
|
||||
}
|
||||
export const TREE_SENTENCES = Object.values(STATES)
|
||||
/** 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 || "")
|
||||
@@ -43,20 +45,19 @@ export function contextPercent(c) {
|
||||
return Math.min(100, Math.floor((used * 100) / window))
|
||||
}
|
||||
|
||||
function modeWords(a) {
|
||||
if (!a.mode) return ""
|
||||
const req = (a.request || []).filter(Boolean)
|
||||
return req.length ? `${a.mode} ${req.map((r) => `+ ${r}`).join(" ")}` : a.mode
|
||||
}
|
||||
|
||||
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
|
||||
export function treeOrder(agents) {
|
||||
const ids = new Set(agents.map((a) => a.id))
|
||||
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 parent = a.parent && ids.has(a.parent) && a.parent !== a.id ? a.parent : null
|
||||
if (!under.has(parent)) under.set(parent, [])
|
||||
under.get(parent).push(a)
|
||||
const p = parentOf(a)
|
||||
if (!under.has(p)) under.set(p, [])
|
||||
under.get(p).push(a)
|
||||
}
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
@@ -73,7 +74,7 @@ export function treeOrder(agents) {
|
||||
return out
|
||||
}
|
||||
|
||||
/** The room's messages to or from an agent (by name; `all` reaches everyone). */
|
||||
/** 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))
|
||||
}
|
||||
@@ -83,33 +84,25 @@ function firstLine(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree as rows. `act` holds what the rows can do: `open(a)` (its session, read-only),
|
||||
* 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.
|
||||
* 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() } = {}) {
|
||||
const rows = []
|
||||
for (const { agent, depth } of treeOrder(tree.agents || [])) rows.push(agentRow(agent, depth, { room, act, now, ended: false }))
|
||||
for (let i = 0; i < (Number(tree.open_slots) || 0); i++) {
|
||||
rows.push(h("div", { class: "subagent subagent--slot", style: `--depth: 1` }, h("span", { class: "subagent__slot", text: t("Open slot") })))
|
||||
}
|
||||
const ended = tree.ended || []
|
||||
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
|
||||
const box = h("div", { class: "subagents" })
|
||||
box.append(
|
||||
h("p", { class: "subagents__size", text: t("%(size)s of at most %(max)s agents", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }),
|
||||
h("div", { class: "subagents__list" }, rows),
|
||||
)
|
||||
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 %(n)s", { n: ended.length }) })),
|
||||
h(
|
||||
"div",
|
||||
{ class: "subagents__list" },
|
||||
ended.map((a) => agentRow(a, 0, { room, act, now, ended: true })),
|
||||
),
|
||||
{ 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)
|
||||
@@ -117,15 +110,17 @@ export function treePart(tree, { room = [], act = {}, now = Date.now() } = {}) {
|
||||
return box
|
||||
}
|
||||
|
||||
function agentRow(a, depth, { room, act, now, ended }) {
|
||||
function row(a, depth, { room, act, now, ended }) {
|
||||
const key = `sub:${a.id}`
|
||||
const main = !a.parent && a.id === "lead"
|
||||
const pct = contextPercent(a.context)
|
||||
const line = a.progress ? progressLine(a.progress, now) : ""
|
||||
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 =
|
||||
!ended && !main && act.stop
|
||||
live && !main && act.stop
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
@@ -144,19 +139,25 @@ function agentRow(a, depth, { room, act, now, ended }) {
|
||||
: 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 } },
|
||||
{ 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", title: a.position || a.name },
|
||||
h("bdi", { class: "agent__name", text: a.name || a.id }),
|
||||
h("span", { class: "subagent__id", text: ` (${a.id})` }),
|
||||
a.position ? h("span", { class: "subagent__position", text: ` — ${main ? t("the main agent") : a.position}` }) : null,
|
||||
{ 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: "subagent__pct", text: pct === null ? "" : `${pct}%`, title: pct === null ? "" : t("%(percent)s%% of the context window used", { percent: pct }).replace(/%%/g, "%") }),
|
||||
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
|
||||
stop,
|
||||
),
|
||||
h(
|
||||
@@ -167,21 +168,19 @@ function agentRow(a, depth, { room, act, now, ended }) {
|
||||
{ class: "agent__facts" },
|
||||
h("span", { class: "agent__state", text: treeState(a.state) }),
|
||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||
modeWords(a) ? h("span", { class: "subagent__mode", text: modeWords(a) }) : null,
|
||||
a.findings ? h("span", { text: t("Findings: %(n)s", { n: a.findings }) }) : 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,
|
||||
),
|
||||
pct === null ? null : h("div", { class: "subagent__bar", role: "meter", "aria-valuemin": "0", "aria-valuemax": "100", "aria-valuenow": String(pct), "aria-label": t("Context") }, h("span", { style: `--pct: ${pct}%` })),
|
||||
line ? h("p", { class: "subagent__phase", "data-phase": a.progress?.phase || "", text: line }) : null,
|
||||
a.task ? h("p", { class: "agent__brief", text: a.task }) : null,
|
||||
said.length || a.messages
|
||||
? h("p", { class: "subagent__room" }, h("span", { class: "chip", text: t("Messages: %(n)s", { n: a.messages ?? said.length }) }), last ? h("span", { class: "subagent__last", text: `${last.from}: ${firstLine(last.text)}` }) : null)
|
||||
: 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(
|
||||
"div",
|
||||
{ class: "subagent__actions" },
|
||||
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its session"), onclick: () => act.open(a) }) : null,
|
||||
"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,
|
||||
),
|
||||
),
|
||||
@@ -190,3 +189,12 @@ function agentRow(a, depth, { room, act, now, ended }) {
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user