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
+1399 -180

No files matched your search

+13 -1
View File
@@ -40,7 +40,19 @@ All notable changes to LLeMbas Desktop. The format follows
speed), running a tool and what it runs, waiting for you, waiting for subagents, compacting,
retrying (counting down), stopped or failed -- each with how long it has lasted, kept moving
every second. From a server that sends no such line (before 2.0.0-beta.8), the app shows the
server's own words as before. Translations come with the server's.
server's own words as before. In every language the server has.
- **Subagents, the whole tree** (harness spec 3.4.0, needs the server's 2.0.0-beta.8). The
activity panel shows the main agent and every subagent under the one it reports to: its name and
id, its position, what it is doing right now, how full its context is, its state, model and
mode (with what it may ask for), its messages in the room and the last one, how many slots are
still open, and the ones that ended -- dimmed, with how they ended. Any subagent's conversation
opens read-only, live while it works and after it ended, and so do its findings; a working one
can be stopped (it and those under it). An agent chat's tree comes from its computer; while that
computer is away the panel shows the tree as it last was and says the rest is kept there.
- **Subagents for agent chats.** A new agent chat offers the Subagents setting where the server
says this computer can run them, and an open agent chat where the server offers it for that
chat.
- **A subagent's approval names it**: "Subagent parser (s1), reviews the parser, asks:".
- `tests/ui/`: the page under a headless browser against a stand-in for the app's bridge, with
the server's answers as a table (`node tests/ui/run.mjs`, needs Playwright and a Chromium). CI
runs it on every push (the `ui` job), with Playwright and its Chromium installed for the run.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Copy the server's /api/v2 contract fixtures that tests/ui reads into tests/ui/fixtures/:
#
# scripts/fetch-fixtures.sh <server-ref> (a tag such as v2.0.0-beta.8, or a branch)
#
# The server is a git checkout, LLeMbas beside this repository by default (LEMBAS_SERVER names
# another); the files are read from the ref, not from its working tree. SOURCE records the ref and
# the commit, so the tests say which server they hold the app to. CI has no server checkout: the
# copies are committed.
set -euo pipefail
cd "$(dirname "$0")/.."
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
FROM="tests/fixtures/api_v2"
OUT="tests/ui/fixtures"
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|| { echo "fetch-fixtures: $1 is not a commit in $SERVER" >&2; exit 1; }
mkdir -p "$OUT"
for f in $FILES; do
git -C "$SERVER" show "$commit:$FROM/$f" > "$OUT/$f" || { echo "fetch-fixtures: $f is not at $1" >&2; exit 1; }
done
printf 'LLeMbas %s (%s) %s\n' "$1" "$commit" "$FROM" > "$OUT/SOURCE"
echo "fetch-fixtures: $(echo $FILES | wc -w) files from LLeMbas $1 (${commit:0:12}) -> $OUT"
+32 -36
View File
@@ -399,42 +399,52 @@ KEYS = (
"Put the last command and its output into the message box",
"Sign in again: the server no longer accepts this app's sign-in.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.",
)
# The progress line's sentences (ui/js/progress.js; harness spec 3.4.0, `loop.json`
# progress.texts, its `{{field}}` written as `%(field)s`). Shared with every client, so they
# are the server's to translate and never in OWN: until its catalogues have one, that sentence
# shows in English and this script says how many are waiting -- not a failure.
SERVER_PENDING = (
# progress.js (harness spec 3.4.0: loop.json progress.texts, keyed as the spec writes them)
"Queued",
"Queued: %(ahead)s ahead",
"Waiting for %(model)s: %(reason)s",
"Loading %(model)s",
"Reading the prompt: %(done)s / %(total)s tokens (%(percent)s%%)",
"Queued: {{ahead}} ahead",
"Waiting for {{model}}: {{reason}}",
"Loading {{model}}",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
"Reading the prompt",
"Thinking",
"Writing: %(rate)s tok/s",
"Writing: {{rate}} tok/s",
"Writing",
"Running %(tool)s: %(what)s",
"Running %(tool)s",
"Waiting for you: %(what)s",
"Waiting for subagents: %(running)s running",
"Running {{tool}}: {{what}}",
"Running {{tool}}",
"Waiting for you: {{what}}",
"Waiting for subagents: {{running}} running",
"Compacting the conversation",
"Retrying in %(in)s s: %(reason)s",
"Retrying in {{in}} s: {{reason}}",
"Stopped",
"Stopped: %(reason)s",
"Failed: %(reason)s",
"Stopped: {{reason}}",
"Failed: {{reason}}",
"it serves one request at a time and is answering another",
"all its slots are in use",
"its server holds another model now",
"allow %(tool)s?",
"allow {{tool}}?",
"an approval",
"a question",
"the plan",
"by you",
"step limit",
"budget",
"%(text)s · %(elapsed)s",
# tree.js, activity.js (the subagent tree), chat.js (a subagent's approval)
"waiting for a slot",
"finished",
"idle",
"paused: context full",
"out of context",
"the main agent",
"%(size)s of %(max)s",
"Open slots: %(n)s",
"They run on the computer this chat runs on.",
"Ended",
"Context used",
"messages: %(n)s",
"findings: %(n)s",
"Findings",
"Stop %(name)s and the subagents under it?",
"This device is not linked right now.",
"Subagent %(name)s (%(id)s), %(position)s, asks:",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -2254,20 +2264,6 @@ def main() -> None:
ours = OWN.get(lang, {})
table: dict[str, str] = {}
from_server = 0
waiting = 0
for key in SERVER_PENDING:
if key in KEYS:
continue
text = theirs.get(key)
if not text:
waiting += 1
elif set(FIELD.findall(text)) != set(FIELD.findall(key)):
print(f"gen-i18n: {lang}: the fields of {text!r} do not match {key!r}", file=sys.stderr)
problems += 1
else:
table[key] = text
if waiting:
print(f"gen-i18n: {lang}: {waiting} progress sentences wait for the server's catalogue (English until then)")
for key in KEYS:
text = theirs.get(key)
if text:
+1
View File
@@ -0,0 +1 @@
LLeMbas origin/dev (5d7b79c5c9dc0d9c3df9a8b401c02fdd67927c2d) tests/fixtures/api_v2
+31
View File
@@ -0,0 +1,31 @@
{
"id": "chat2",
"kind": "agent",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": true,
"mode": "edit",
"effort": "low",
"device": {
"token_id": "token1",
"directory": "/home/dev/project"
},
"device_session_id": "ses_01waybread",
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": {
"offered": true,
"named_only": false,
"cap": 32
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
+162
View File
@@ -0,0 +1,162 @@
{
"cards": [
{
"id": "card1",
"kind": "question",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "q0",
"kind": "question",
"tool": "ask_user",
"title": "Honey or not?",
"header": "Taste",
"detail": "",
"reason": "",
"purpose": "",
"options": [
{
"label": "Honey",
"description": "",
"recommended": true
},
{
"label": "Plain",
"description": "As the elves have it",
"recommended": false
}
],
"multiple": false,
"free_text": true,
"editable": false,
"plan": null,
"mode_choice": true,
"job": "",
"subagent": null
}
]
},
{
"id": "card2",
"kind": "approval",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "0",
"kind": "approval",
"tool": "bash",
"title": "Run a command",
"header": "",
"detail": "rm -rf build",
"reason": "Edit mode asks before anything that runs a command.",
"purpose": "Start clean",
"options": [],
"multiple": false,
"free_text": false,
"editable": true,
"plan": null,
"mode_choice": true,
"job": "explore (job1)",
"subagent": null
}
]
},
{
"id": "card3",
"kind": "plan",
"why": "",
"dismissable": true,
"expires_in": null,
"items": [
{
"index": 0,
"key": "p0",
"kind": "plan",
"tool": "plan_submit",
"title": "Bake lembas",
"header": "",
"detail": "",
"reason": "",
"purpose": "",
"options": [],
"multiple": false,
"free_text": true,
"editable": false,
"plan": {
"version": 2,
"title": "Bake lembas",
"summary": "",
"findings": [],
"objectives": [],
"phases": [
{
"id": "p1",
"title": "Plan",
"status": "pending",
"tasks": [
{
"id": "t1",
"text": "Gather mallorn leaves",
"status": "todo",
"note": ""
},
{
"id": "t2",
"text": "Bake slowly",
"status": "todo",
"note": ""
}
]
}
],
"steps": [
"Gather mallorn leaves",
"Bake slowly"
]
},
"mode_choice": true,
"job": "",
"subagent": null
}
]
},
{
"id": "card4",
"kind": "approval",
"why": "",
"dismissable": false,
"expires_in": null,
"items": [
{
"index": 0,
"key": "0",
"kind": "approval",
"tool": "bash",
"title": "Run a command",
"header": "",
"detail": "git push",
"reason": "Edit mode asks before anything that runs a command.",
"purpose": "",
"options": [],
"multiple": false,
"free_text": false,
"editable": true,
"plan": null,
"mode_choice": true,
"job": "",
"subagent": {
"id": "s1",
"name": "parser",
"position": "reviews the parser"
}
}
]
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"text": "Running bake --slow",
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "bash",
"what": "bake --slow"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"text": "",
"progress": null
}
@@ -0,0 +1,8 @@
{
"text": "Waiting for you to allow bash…",
"progress": {
"phase": "waiting_for_person",
"since": 1767323045000,
"what": "question"
}
}
@@ -0,0 +1,8 @@
{
"text": "",
"progress": {
"phase": "stopped",
"since": 1767323045000,
"reason": "person"
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"tree": {
"min": 1,
"max": 4,
"size": 2,
"open_slots": 1,
"agents": [
{
"id": "lead",
"name": "lead",
"position": "the main agent",
"parent": null,
"state": "working",
"model": "qwen-27b",
"mode": "edit",
"request": [],
"manager": true,
"context": {
"used": 41200,
"window": 131072
},
"progress": {
"phase": "thinking",
"since": 1767323045000
},
"findings": 0,
"messages": 2
},
{
"id": "s2",
"name": "parser",
"position": "reviews the parser",
"parent": "lead",
"state": "working",
"model": "qwen-27b",
"mode": "manual",
"task": "Review src/parse.ts.",
"request": [
"write"
],
"manager": false,
"context": {
"used": 9100,
"window": 131072
},
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "read",
"what": "src/parse.ts"
},
"findings": 2,
"messages": 3
}
],
"ended": [
{
"id": "s1",
"name": "scout",
"position": "maps the code",
"parent": "lead",
"state": "done",
"model": "qwen-27b",
"mode": "edit",
"task": "Map it.",
"request": [],
"manager": false,
"progress": null,
"findings": 1,
"messages": 1,
"ended": 1767323045000
}
]
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"models": [
{
"id": "qwen",
"name": "Qwen",
"vision": false,
"tools": true,
"efforts": [
"medium"
],
"thinking_switch": true,
"levels": [
"off",
"medium"
],
"effort": "medium",
"control": "switch",
"parallel": 2,
"context": 131072,
"image": "/api/v2/models/images/0a1b2c3d4e5f.png",
"default": true,
"subagents": {
"offered": true,
"named_only": false
}
},
{
"id": "gpt-oss",
"name": "gpt-oss",
"vision": false,
"tools": true,
"efforts": [
"low",
"medium",
"high"
],
"thinking_switch": false,
"levels": [
"low",
"medium",
"high"
],
"effort": "high",
"control": "slider",
"parallel": 1,
"context": 0,
"image": null,
"default": false,
"subagents": {
"offered": true,
"named_only": false
}
},
{
"id": "vision-small",
"name": "Vision",
"vision": true,
"tools": false,
"efforts": [],
"thinking_switch": false,
"levels": [
"default"
],
"effort": "default",
"control": "none",
"parallel": null,
"context": 8192,
"image": null,
"default": false,
"subagents": {
"offered": true,
"named_only": false
}
}
],
"subagents_cap": 32,
"agent_subagents": {
"offered": true,
"device_runs": true
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "s2",
"findings": [
{
"n": 1,
"text": "Login is POST /session.",
"time": 1767323045000
}
]
}
@@ -0,0 +1,24 @@
{
"id": "s2",
"state": "working",
"turns": [
{
"role": "user",
"text": "Write the routes.",
"machine": true
},
{
"role": "assistant",
"text": "Reading the API first.",
"tools": [
{
"name": "read",
"title": "src/api.py",
"isError": false
}
],
"error": "",
"running": true
}
]
}
+112
View File
@@ -0,0 +1,112 @@
{
"tree": {
"min": 2,
"max": 4,
"size": 3,
"open_slots": 0,
"agents": [
{
"id": "lead",
"name": "lead",
"position": "the main agent",
"parent": null,
"state": "working",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": true,
"progress": {
"phase": "waiting_for_subagents",
"since": 1767323045000,
"running": 2
},
"findings": 0,
"messages": 1
},
{
"id": "s1",
"name": "area",
"position": "owns the API",
"parent": "lead",
"state": "working",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": true,
"task": "Direct it.",
"progress": null,
"findings": 0,
"messages": 1
},
{
"id": "s2",
"name": "routes",
"position": "writes the routes",
"parent": "area",
"state": "working",
"model": "local/qwen",
"mode": "edit",
"request": [
"execute"
],
"manager": false,
"task": "Write the routes.",
"context": {
"used": 9100,
"window": 131072
},
"progress": {
"phase": "running_tool",
"since": 1767323045000,
"tool": "read",
"what": "src/api.py"
},
"findings": 1,
"messages": 1
}
],
"ended": [
{
"id": "s3",
"name": "scout",
"position": "maps the code",
"parent": "lead",
"state": "done",
"model": "local/qwen",
"mode": "auto",
"request": [],
"manager": false,
"task": "Map it.",
"progress": null,
"findings": 0,
"messages": 1,
"ended": 1767323045000
}
]
},
"room": [
{
"n": 1,
"from": "routes",
"to": [
"area"
],
"text": "Routes are in.",
"report": false,
"kind": "message",
"time": 1767323045000
},
{
"n": 2,
"from": "scout",
"to": [
"lead"
],
"text": "Mapped: see map.md.",
"report": true,
"kind": "report",
"time": 1767323045000
}
],
"live": true
}
+166 -46
View File
@@ -50,6 +50,9 @@ async function launch() {
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
}
/** One of the server's /api/v2 contract fixtures, as copied by scripts/fetch-fixtures.sh. */
const fixture = (name) => JSON.parse(readFileSync(join(HERE, "fixtures", `${name}.json`), "utf8"))
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
// --- The server, as the tests' table -----------------------------------------------------------
@@ -636,9 +639,14 @@ test("B1: a reply being written draws the stream's progress, its clock moving, a
const p = await page(agentChatOpen([["status", { text: "Writing…" }]]))
await openChatRow(p, "chat2")
await p.waitForSelector(".msg.is-live .msg__status")
await p.evaluate((path) => {
window.__mock.frame(path, "status", { text: "Running bake --slow", progress: { phase: "running_tool", since: Date.now() - 65000, tool: "bash", what: "bake --slow" } })
}, STREAM2)
// An older server sends no progress at all: its status text, as before.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Running bake --slow")
// The server's status frame (event_status.json), its clock set 65 s back from now.
const status = fixture("event_status")
await p.evaluate(([path, f]) => {
window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() - 65000 } })
}, [STREAM2, status])
const line = () => p.textContent(".msg.is-live .msg__status")
assert.equal(await line(), "Running bash: bake --slow · 1m 05s")
assert.equal(await p.getAttribute(".msg.is-live .msg__status", "data-phase"), "running_tool")
@@ -646,59 +654,171 @@ test("B1: a reply being written draws the stream's progress, its clock moving, a
// A data-only frame with progress (the ask frame carries it too): the line follows.
await p.evaluate((path) => window.__mock.frame(path, "ask", { cards: [], progress: { phase: "waiting_for_person", since: Date.now(), what: "approval", tool: "bash" } }), STREAM2)
assert.equal(await line(), "Waiting for you: allow bash? · 0s")
// Nothing running: `progress: null`, and the status text is drawn again.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Writing…", progress: null }), STREAM2)
// Nothing running (event_status_idle.json: no text, `progress: null`): the plain words again.
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_idle")])
assert.equal(await line(), "Writing…")
// An older server sends no progress at all: its status text, as before.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
assert.equal(await line(), "Running bake --slow")
// Waiting for a person's answer (event_status_person.json): the phase, not the text.
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() } }), [STREAM2, fixture("event_status_person")])
assert.equal(await line(), "Waiting for you: a question · 0s")
// Stopped and failed have no clock.
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Stopped.", progress: { phase: "stopped", since: Date.now() - 4000, reason: "person" } }), STREAM2)
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_stopped")])
assert.equal(await line(), "Stopped: by you")
assert.deepEqual(p.errors, [])
})
// --- C4 (ahead of the server): the subagent tree as spec 3.4.0 §12 gives it ---------------------
// --- C4: subagents (spec 3.4.0) ---------------------------------------------------------------
//
// ui/js/tree.js draws subagents.md §12's data; the activity panel takes it up once the server's
// /api/v2 carries the tree. Until then it is checked here on the spec's own example.
// The server's /api/v2 (its fixtures, copied into tests/ui/fixtures): GET /chats/{chat}/tree ->
// {tree, room, live}, the stream's `tree` frame, a subagent's transcript and findings, POST
// .../stop; an agent chat's device away -> the kept tree (`live` false) and 409 for the rest;
// /models?kind=agent&device= for a new agent chat's Subagents; `subagent` on an approval item.
const TREE = {
min: 2, max: 4, size: 3, open_slots: 1,
agents: [
{ id: "lead", name: "lead", position: "the main agent", parent: null, state: "working", model: "qwen-27b", mode: "edit", request: [], manager: true, context: { used: 41200, window: 131072 }, progress: { phase: "thinking", since: 0 } },
{ id: "s1", name: "parser", position: "reviews the parser", parent: "lead", state: "working", model: "qwen-27b", mode: "manual", request: ["write"], manager: true, task: "Read src/parse.ts", context: { used: 9100, window: 131072 }, progress: { phase: "running_tool", since: 0, tool: "read", what: "src/parse.ts" }, findings: 2, messages: 3 },
{ id: "s3", name: "lexer", position: "the lexer's tests", parent: "s1", state: "waiting", model: "qwen-27b", mode: "manual", request: [], manager: false, context: { used: 0, window: 131072 }, progress: null, findings: 0, messages: 0 },
],
ended: [{ id: "s0", name: "scout", position: "maps the repository", parent: "lead", state: "out_of_context", ended: 0, findings: 1 }],
const ACTIVITY = { activity: { version: "v1", summary: { active: 0 }, agents: [], context: null, outputs: [], team: null, plan: null } }
function c4(over = {}) {
return b3({
"GET /chats/chat2/activity": ACTIVITY,
"GET /chats/chat2/tree": fixture("tree"),
"GET /chats/chat2/subagents/s2/transcript": fixture("subagent_transcript"),
"GET /chats/chat2/subagents/s2/findings": fixture("subagent_findings"),
"POST /chats/chat2/subagents/s2/stop": { stopped: ["s2"] },
...over,
})
}
test("C4: the tree in order, each agent's position, state, mode, context and phase; slots; the ended ones apart", async () => {
const p = await page(b3())
const got = await p.evaluate(async (tree) => {
const m = await import("/js/tree.js")
const calls = []
const box = m.treePart(tree, { room: [{ n: 1, from: "lead", to: ["parser"], text: "Start with the grammar.\nThen the lexer." }], now: 65000, act: { open: (a) => calls.push(["open", a.id]), findings: (a) => calls.push(["findings", a.id]), stop: (a) => calls.push(["stop", a.id]) } })
document.body.append(box)
const rows = [...box.querySelectorAll(".subagents__list > .subagent:not(.subagent--slot)")].map((r) => ({ id: r.dataset.id, depth: r.style.getPropertyValue("--depth"), text: r.textContent }))
box.querySelector('[data-id="s1"] .subagent__actions .btn').click()
box.querySelector('[data-id="s0"] .subagent__actions .btn:last-child').click()
box.querySelector('[data-id="s1"] .agent__stop').click()
return { rows, slots: box.querySelectorAll(".subagent--slot").length, endedStop: box.querySelectorAll('[data-id="s0"] button.agent__stop').length, calls }
}, TREE)
assert.deepEqual(got.rows.map((r) => [r.id, r.depth]), [["lead", "0"], ["s1", "1"], ["s3", "2"], ["s0", "0"]])
const parser = got.rows[1].text
assert.match(parser, /parser \(s1\) — reviews the parser/)
assert.match(parser, /6%/, "context: floor(9100 × 100 / 131072)")
assert.match(parser, /manual \+ write/)
assert.match(parser, /Running read: src\/parse\.ts · 1m 05s/)
assert.match(parser, /Messages: 3/)
assert.match(parser, /lead: Start with the grammar\./)
assert.match(got.rows[2].text, /waiting for a slot/)
assert.match(got.rows[3].text, /out of context/)
assert.equal(got.slots, 1)
assert.equal(got.endedStop, 0, "an ended subagent cannot be stopped")
assert.deepEqual(got.calls, [["open", "s1"], ["findings", "s0"], ["stop", "s1"]])
async function openTree(p) {
await openChatRow(p, "chat2")
await p.click("#activity-open")
await p.waitForSelector("#activity .subagents .subagent")
}
const rows = (p) => p.evaluate(() => [...document.querySelectorAll("#activity .subagents__list > .subagent")].map((r) => [r.dataset.id, r.style.getPropertyValue("--depth")]))
test("C4: the activity panel draws the server's tree: nested by parent, slots, the ended ones apart", async () => {
const p = await page(c4())
await openTree(p)
assert.deepEqual(await rows(p), [["lead", "0"], ["s1", "1"], ["s2", "2"]])
const routes = await p.textContent('#activity .subagent[data-id="s2"]')
assert.match(routes, /routes/)
assert.match(routes, /writes the routes/)
assert.match(routes, /Running read: src\/api\.py/)
assert.match(routes, /6%/, "context: floor(9100 × 100 / 131072)")
assert.match(routes, /edit \+ execute/)
assert.match(routes, /messages: 1/)
assert.match(routes, /routes → area: Routes are in\./, "the last room message to or from it")
assert.match(await p.textContent('#activity .subagent[data-id="lead"]'), /the main agent/)
assert.match(await p.textContent("#activity .subagents"), /3 of 4/)
assert.match(await p.textContent("#activity .subagents"), /They run on the computer this chat runs on\./)
// The ended one, folded apart, with how it ended, and no stop square.
await p.click("#activity .subagents__ended > summary")
const scout = await p.textContent('#activity .subagents__ended .subagent[data-id="s3"]')
assert.match(scout, /done/)
assert.equal(await p.locator('#activity .subagent[data-id="s3"] button.agent__stop').count(), 0)
assert.equal(await p.locator('#activity .subagent[data-id="lead"] button.agent__stop').count(), 0, "the main agent is not stopped from here")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-1280.png") })
assert.deepEqual(p.errors, [])
})
test("C4: a subagent's conversation and findings open read-only; Stop asks, then stops it", async () => {
const p = await page(c4())
await openTree(p)
await p.click('#activity .subagent[data-id="s2"] > summary')
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
await p.waitForSelector("#activity .convo .convo__turn")
const convo = await p.textContent("#activity-body")
assert.match(convo, /Write the routes\./)
assert.match(convo, /Reading the API first\./)
assert.match(convo, /src\/api\.py/)
assert.equal(await p.locator("#activity-body textarea, #activity-body input").count(), 0, "nothing to type into")
await p.click("#activity-back")
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Findings')
await p.waitForSelector("#activity .findings")
assert.match(await p.textContent("#activity .findings"), /Login is POST \/session\./)
await p.click("#activity-back")
await p.click('#activity .subagent[data-id="s2"] button.agent__stop')
await p.waitForSelector("dialog[open]")
assert.match(await p.textContent("dialog[open]"), /Stop routes and the subagents under it\?/)
await p.click("dialog[open] .btn--danger")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/subagents/s2/stop").length === 1)
assert.deepEqual(p.errors, [])
})
test("C4: the stream's tree frame redraws the panel; with the device away the kept tree shows and the rest says why", async () => {
const away = { ...fixture("tree"), live: false }
const p = await page(c4({ "GET /chats/chat2/tree": away, "GET /chats/chat2/subagents/s2/transcript": { __error: { status: 409, detail: "That is kept on the computer this chat runs on, which is not linked right now." } } }))
await openTree(p)
assert.match(await p.textContent("#activity .subagents"), /This device is not linked right now\./)
await p.click('#activity .subagent[data-id="s2"] > summary')
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
await p.waitForSelector("#activity-body .is-error")
assert.match(await p.textContent("#activity-body"), /not linked right now/)
await p.click("#activity-back")
// A reply streams; its `tree` frame (event_tree.json) is the tree now.
await p.evaluate(() => window.__mock.frame("/chats/chat2/messages/m2/stream", "tree", null))
await p.context().close()
const live = await page({ ...c4(), routes: { ...c4().routes, "GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "Go")], more: false, live: "m2" } }, streams: { [STREAM2]: [] } })
await openTree(live)
await live.evaluate(([path, f]) => window.__mock.frame(path, "tree", f), [STREAM2, fixture("event_tree")])
await live.waitForFunction(() => !!document.querySelector('#activity .subagent[data-id="s2"]') && /parser/.test(document.querySelector('#activity .subagent[data-id="s2"]').textContent))
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /reviews the parser/)
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /manual \+ write/)
assert.deepEqual(live.errors, [])
})
test("C4: an approval a subagent asks for names it", async () => {
const card = fixture("event_ask").cards.find((c) => c.id === "card4")
const p = await page(agentChatOpen([["ask", { cards: [card] }]], { "POST /chats/chat2/interactions/card4": { answered: true } }))
await openChatRow(p, "chat2")
await p.waitForSelector("#cards .ask__subagent")
assert.equal(await p.textContent("#cards .ask__subagent"), "Subagent parser (s1), reviews the parser, asks:")
assert.equal(await p.inputValue("#cards .ask__edit"), "git push", "the command, correctable, as for the main agent's")
assert.deepEqual(p.errors, [])
})
test("C4: Subagents for a new agent chat as /models?kind=agent says for this device; for an open one as the chat says", async () => {
const facts = fixture("models_agent")
const newAgent = async (models) => {
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
await p.click("#new-chat-btn")
await p.click(".kind__tab >> text=Agent")
await p.waitForSelector("#composer-chips .cchip")
await p.waitForTimeout(200)
return p
}
let p = await newAgent(facts)
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
await p.click("#composer-chips .cchip--subagents")
await p.waitForSelector(".range2")
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
await p.context().close()
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
await p.context().close()
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
await p.context().close()
p = await newAgent({ __error: { status: 400, detail: "older server" } })
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
await p.context().close()
// An open agent chat: its own `subagents` (chat_agent.json offers them).
const chat = fixture("chat_agent")
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
await openChatRow(p, "chat2")
await p.waitForSelector("#composer-chips .cchip--subagents")
assert.deepEqual(p.errors, [])
})
test("C4: the tree at 390 px stays inside the window", async () => {
const p = await page(c4(), { width: 390, height: 844 })
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
await p.waitForSelector("#activity-open")
await p.click("#activity-open")
await p.waitForSelector("#activity .subagents .subagent")
await p.click('#activity .subagent[data-id="s2"] > summary')
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
const panel = await p.evaluate(() => { const b = document.querySelector("#activity-body"); return b.scrollWidth <= b.clientWidth })
assert.ok(panel, "the panel does not scroll sideways")
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-390.png") })
assert.deepEqual(p.errors, [])
})
+15
View File
@@ -1614,6 +1614,21 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
}
.termpanel__last:empty { display: none; }
/* --- The subagent tree (spec 3.4.0) ------------------------------------------------------------
Rows are the tasks' rows (.agent): name and id on one line, the position, then the phase (or how
it ended) clipped to one line -- its clock is in the opened row. Ended ones are dimmed. */
.subagents { display: grid; gap: var(--sp-2); min-width: 0; }
.subagents .tasks__team, .subagents .tasks__goal { margin: 0; }
.subagent__who { display: flex; gap: var(--sp-2); align-items: baseline; min-width: 0; }
.subagent__who .agent__id { flex: none; }
.subagent__phase { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--ink-muted); }
.agent.is-ended { opacity: var(--opacity-dim); }
.subagent__buttons { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; }
.findings { margin: 0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + var(--sp-5)); display: grid; gap: var(--sp-3); }
.findings__item { min-width: 0; overflow-wrap: anywhere; }
.ask__subagent { color: var(--ink); }
/* --- Narrower windows ---------------------------------------------------------------------- */
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
+344
View File
File diff suppressed because it is too large. Load diff
+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 ------------------------------------------------------------------------------------------
+53 -2
View File
@@ -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
View File
@@ -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). */
+72 -64
View File
@@ -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 },
{ class: "agent__what" },
h(
"span",
{ class: "subagent__who" },
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,
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
),
h("span", { class: "subagent__pct", text: pct === null ? "" : `${pct}%`, title: pct === null ? "" : t("%(percent)s%% of the context window used", { percent: pct }).replace(/%%/g, "%") }),
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(
@@ -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 {}
}
}