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
+13
-1
@@ -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.
|
||||
|
||||
Executable
+27
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
LLeMbas origin/dev (5d7b79c5c9dc0d9c3df9a8b401c02fdd67927c2d) tests/fixtures/api_v2
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"text": "Running bake --slow",
|
||||
"progress": {
|
||||
"phase": "running_tool",
|
||||
"since": 1767323045000,
|
||||
"tool": "bash",
|
||||
"what": "bake --slow"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+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