Stream Claude Code subagent activity inline in the transcript

This commit is contained in:
Ian Gulliver
2026-10-15 11:08:53 -07:00
parent 0e43a99115
commit d83212d46b
11 changed files with 325 additions and 35 deletions
-3
View File
@@ -2,9 +2,6 @@
# M1 — One agent end to end
- Subagent views
Render the vendor's subagent activity in the transcript once agents use subagents. Vendor-specific events that don't map onto the core event kinds get vendor-specific UI components.
# M2 — Seeing the work
- Forking from a snapshot
+1
View File
@@ -30,6 +30,7 @@ NDJSON both ways: one JSON object per line on stdin and stdout. Non-JSON stdout
- `--include-partial-messages` adds `stream_event` lines wrapping the raw API stream (`message_start`, `content_block_start`, `content_block_delta` with `text_delta`, `content_block_stop`, `message_delta`, `message_stop`), each with `parent_tool_use_id` (set inside subagents). The complete `assistant` event for a block still arrives, before that block's `content_block_stop`. Transcripts leave `stream_event` lines out.
- The `init` event arrives with the first turn, not at launch, so the session ID is unknown until the first message is sent.
- While a tool runs, `tool_progress` lines with `"heartbeat": true`, the tool's `elapsed_time_seconds`, and a `parent_tool_use_id` naming the tool call arrive every 30 seconds. A Bash call also brings `system` `task_started` and `task_notification` lines around it.
- Subagents (the `Agent` tool, listed in `init` tools as `Task`): `system` `task_started` (`task_type` `local_agent`, `tool_use_id` naming the `Agent` call, `subagent_type`, `description`, `prompt`, `is_backgrounded`), `task_progress` (`description` of what it is doing), `task_updated` (by `task_id` only), and `task_notification` (`status`, `summary`). `assistant` and `user` lines from inside the subagent carry `parent_tool_use_id` set to the `Agent` call's ID, plus `agent_id`, `subagent_type` and `task_description`. A foreground subagent (`run_in_background: false`) sends only its prompt this way, as a `user` text block, and its final report arrives as the `Agent` call's `tool_result`. A background one (the default) streams all its assistant and tool-result lines; the `Agent` call's `tool_result` comes back at once saying it launched, the turn can end with its own `result` while the subagent runs, and when the subagent finishes the CLI starts another turn by itself, ending in a second `result` carrying `origin.kind` `task-notification`.
- Lines that aren't a JSON object, don't parse, or have a `type` not in `knownTypes` (`internal/claude/session.go`) go to `Options.OnBadLine`; lines that parse are still delivered as events whatever their type.
## Control channel
+2 -2
View File
@@ -96,9 +96,9 @@ Agent health is `internal/agents/health.go`:
The Status modal (the navbar's **Status** button; Esc, ×, a click outside it or following a task link closes it) shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); running turns against each cap, each running turn with the time since its process's last event and `stuck` or `exited` when it is; queued turns in order, both linking to their tasks; each vendor's rate-limit status with its reset time and per-window utilization, and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created` or `admin.health` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`.
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `inbox_item_id` when its final message was filed as a `report` item. Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip.
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request and the continuation's handoff) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request and the continuation's handoff) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) stream inline with the main agent's, in arrival order, each marked `↳ <subagent_type>` with the description as its tooltip; its messages also get an accent rule down their left edge. A run of tool calls groups only calls from the same agent, so a call from another agent starts a new group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Picking a task points the right pane at it on the Transcript tab (see "Lead agent").
+9
View File
@@ -580,6 +580,15 @@ func transcriptLine(ev api.Event, root string) string {
p := ev.Payload
text := func(k string) string { return fmt.Sprint(p[k]) }
rel := func(s string) string { return strings.ReplaceAll(s, root+"/", "") }
if p["parent_tool_use_id"] != nil {
switch ev.Kind {
case "agent.message":
return "subagent: " + text("text")
case "agent.tool_call":
return "subagent tool " + text("name")
}
return ""
}
switch ev.Kind {
case "user.message":
if ev.Actor == "byre" {
+28 -10
View File
@@ -757,11 +757,18 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
for _, b := range blocks {
err = m.handleAssistantBlock(ctx, l, b)
parent, err := ev.ParentToolUseID()
if err != nil {
return err
}
for _, b := range blocks {
err = m.handleAssistantBlock(ctx, l, b, parent)
if err != nil {
return err
}
}
if parent != "" {
return nil
}
usage, err := ev.ContextUsage()
if err != nil {
@@ -778,6 +785,10 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
parent, err := ev.ParentToolUseID()
if err != nil {
return err
}
for _, b := range blocks {
if b.Type != "tool_result" {
continue
@@ -786,11 +797,11 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
err = m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{
err = m.emit(ctx, l, agentActor, "agent.tool_result", withParent(map[string]any{
"tool_use_id": b.ToolUseID,
"is_error": b.IsError,
"content": content,
})
}, parent))
if err != nil {
return err
}
@@ -943,30 +954,37 @@ func (m *Manager) updateContext(ctx context.Context, l *live, used, window int)
return m.emit(ctx, l, agentActor, "session.context", map[string]any{"model": l.contextModel, "used": used, "window": window})
}
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block) error {
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block, parent string) error {
switch b.Type {
case "text":
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": b.Text})
return m.emit(ctx, l, agentActor, "agent.message", withParent(map[string]any{"text": b.Text}, parent))
case "thinking":
if b.Thinking == "" {
return nil
}
return m.emit(ctx, l, agentActor, "agent.thinking", map[string]any{"text": b.Thinking})
return m.emit(ctx, l, agentActor, "agent.thinking", withParent(map[string]any{"text": b.Thinking}, parent))
case "tool_use":
input, err := decode(b.Input)
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.tool_call", map[string]any{
return m.emit(ctx, l, agentActor, "agent.tool_call", withParent(map[string]any{
"id": b.ID,
"name": b.Name,
"input": input,
})
}, parent))
}
return m.emit(ctx, l, agentActor, "agent.vendor", store.ToPayload(b))
return m.emit(ctx, l, agentActor, "agent.vendor", withParent(store.ToPayload(b), parent))
}
func withParent(payload map[string]any, parent string) map[string]any {
if parent != "" {
payload["parent_tool_use_id"] = parent
}
return payload
}
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
+82
View File
@@ -0,0 +1,82 @@
package agents
import (
"context"
"path/filepath"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/store"
)
func TestSubagentEvents(t *testing.T) {
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
l := &live{id: "s1", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
feed := func(typ, raw string) {
t.Helper()
err := m.handle(ctx, l, claude.Event{Type: typ, Raw: []byte(raw)})
if err != nil {
t.Fatalf("handle %s: %v", raw, err)
}
}
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"tool_use","id":"toolu_agent","name":"Agent","input":{"subagent_type":"general-purpose","description":"d","prompt":"p"}}],"usage":{"input_tokens":2,"cache_read_input_tokens":100,"output_tokens":17}},"parent_tool_use_id":null}`)
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"tool_use","id":"toolu_bash","name":"Bash","input":{"command":"ls"}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":33494,"output_tokens":20}},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
feed("user", `{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bash","type":"tool_result","content":"adsb-tools","is_error":false}]},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"text","text":"The first entry is adsb-tools."}],"usage":{"input_tokens":2,"output_tokens":3}},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
events, err := st.TaskEvents(ctx, "t1", 0, 100)
if err != nil {
t.Fatalf("task events: %v", err)
}
got := []string{}
for _, ev := range events {
if ev.AgentSessionId == nil {
continue
}
parent, _ := ev.Payload["parent_tool_use_id"].(string)
got = append(got, ev.Kind+" "+parent)
}
want := []string{
"agent.tool_call ",
"session.context ",
"agent.tool_call toolu_agent",
"agent.tool_result toolu_agent",
"agent.message toolu_agent",
}
if len(got) != len(want) {
t.Fatalf("want %q, got %q", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("want %q, got %q", want, got)
}
}
if l.contextUsed != 119 {
t.Fatalf("subagent usage changed the session's context: %d", l.contextUsed)
}
}
+8
View File
@@ -146,6 +146,14 @@ func (e Event) Blocks() ([]Block, error) {
return blocks, nil
}
func (e Event) ParentToolUseID() (string, error) {
msg := struct {
ParentToolUseID string `json:"parent_tool_use_id"`
}{}
err := json.Unmarshal(e.Raw, &msg)
return msg.ParentToolUseID, err
}
func (e Event) TextDelta() (string, error) {
msg := struct {
ParentToolUseID *string `json:"parent_tool_use_id"`
+73 -10
View File
@@ -153,6 +153,7 @@ export function mountChat(root, src, makeHead) {
}, { root: transcript, threshold: 0.5 });
const newCtx = () => ({
tools: new Map(),
subagents: new Map(),
run: undefined,
anchors: [],
unplaced: [],
@@ -255,10 +256,11 @@ export function mountChat(root, src, makeHead) {
releaseImages(entry.imgs);
pending = pending.filter((p) => p !== entry);
};
const addToRun = (el, call) => {
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls), snapMark(run.snaps)];
const addToRun = (el, call, parent) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [] };
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [], parent };
put(el);
return;
}
@@ -274,7 +276,7 @@ export function mountChat(root, src, makeHead) {
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
replace(run.group.summary, ...runHead(run));
};
const addAnchor = (seq, el, turnEnd) => {
const anchor = { seq, turnEnd, el, snaps: [], mark: undefined, run: turnEnd ? undefined : ctx.run };
@@ -301,7 +303,7 @@ export function mountChat(root, src, makeHead) {
}
run.snaps.push(n);
if (run.group) {
replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
replace(run.group.summary, ...runHead(run));
}
};
const placeSnapshot = (snap) => {
@@ -380,7 +382,12 @@ export function mountChat(root, src, makeHead) {
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload;
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
const parent = parentOf(p);
if (ev.kind === "agent.vendor") {
claudeTaskStatus(p, ctx);
continue;
}
if ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
if (ev.kind === "user.message" && ev.actor !== "byre") {
@@ -397,9 +404,16 @@ export function mountChat(root, src, makeHead) {
placeSnapshot(p);
continue;
}
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
ctx.run = undefined;
const el = renderSubagent(p, ctx);
put(el);
addAnchor(ev.seq, el.querySelector("summary"), false);
continue;
}
if (ev.kind === "agent.tool_call") {
const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
addAnchor(ev.seq, el.querySelector("summary"), false);
continue;
}
@@ -408,7 +422,7 @@ export function mountChat(root, src, makeHead) {
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
addToRun(el, undefined, parent);
continue;
}
ctx.run = undefined;
@@ -701,10 +715,56 @@ export function compactButton(sessionId, sessionState, c) {
};
return b;
}
function parentOf(p) {
return typeof p["parent_tool_use_id"] === "string" ? p["parent_tool_use_id"] : "";
}
function subagentTag(parent, ctx) {
if (!parent) {
return null;
}
const sub = ctx.subagents.get(parent);
return h("span", { class: "subagent-tag", title: sub?.description ?? "" }, `↳ ${sub?.type ?? "subagent"}`);
}
function renderToolCall(p, ctx) {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
return h("details", { class: "tool" }, h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
return h("details", { class: "tool" }, h("summary", {}, subagentTag(parentOf(p), ctx), h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))), h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)), result);
}
function isSubagentCall(p) {
return p["name"] === "Agent" || p["name"] === "Task";
}
function renderSubagent(p, ctx) {
const input = (typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {});
const id = String(p["id"]);
const type = String(input["subagent_type"] ?? "subagent");
const description = String(input["description"] ?? "");
const status = h("span", { class: "subagent-status" }, "starting…");
const result = h("div", { class: "tool-result pending" }, "running…");
const tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
ctx.subagents.set(id, { type, description, status });
return h("details", { class: "tool subagent" }, h("summary", {}, tag, h("span", { class: "tool-name" }, `${p["name"]} ${type}`), " ", h("span", { class: "tool-brief" }, description), " ", status), markdown(String(input["prompt"] ?? ""), "text md subagent-prompt", { taskId: ctx.taskId }), result);
}
function claudeTaskStatus(p, ctx) {
if (p["type"] !== "system") {
return;
}
const sub = ctx.subagents.get(String(p["tool_use_id"]));
if (!sub) {
return;
}
switch (p["subtype"]) {
case "task_started":
replace(sub.status, p["is_backgrounded"] === true ? "running in the background" : "running");
return;
case "task_progress":
replace(sub.status, String(p["description"] ?? "running"));
return;
case "task_notification":
sub.status.className = `subagent-status ${p["status"] === "completed" ? "done" : "error"}`;
replace(sub.status, String(p["status"]));
return;
}
}
function snapMark(ns) {
if (ns.length === 0) {
@@ -761,6 +821,9 @@ function renderEvent(ev, ctx) {
}
return h("div", { class: "msg user" }, h("div", { class: "who" }, ev.actor, " ", when), messageImages(p["images"]), String(p["text"] ?? "").trim() ? markdown(String(p["text"]), "text md", md) : null);
case "agent.message":
if (parentOf(p)) {
return h("div", { class: "msg agent subagent" }, h("div", { class: "who" }, subagentTag(parentOf(p), ctx), " ", when), markdown(String(p["text"]), "text md", md));
}
if (String(p["text"]).trim() === quietReply) {
ctx.quiet = true;
return null;
@@ -769,7 +832,7 @@ function renderEvent(ev, ctx) {
ctx.lastMessage = h("div", { class: "msg agent" }, markdown(String(p["text"]), "text md", md));
return ctx.lastMessage;
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md", md));
return h("details", { class: "thinking" }, h("summary", {}, subagentTag(parentOf(p), ctx), "thinking"), markdown(String(p["text"]), "text md", md));
case "agent.tool_result": {
const target = ctx.tools.get(String(p["tool_use_id"]));
const body = h("pre", {}, resultText(p["content"]));
+1 -1
View File
File diff suppressed because one or more lines are too long
+93 -10
View File
@@ -39,6 +39,13 @@ type Run = {
group: { el: HTMLDetailsElement; summary: HTMLElement; body: HTMLElement } | undefined;
calls: Call[];
snaps: number[];
parent: string;
};
type Subagent = {
type: string;
description: string;
status: HTMLElement;
};
type Anchor = {
@@ -52,6 +59,7 @@ type Anchor = {
type Ctx = {
tools: Map<string, HTMLElement>;
subagents: Map<string, Subagent>;
run: Run | undefined;
anchors: Anchor[];
unplaced: Snapshot[];
@@ -223,6 +231,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const newCtx = (): Ctx => ({
tools: new Map(),
subagents: new Map(),
run: undefined,
anchors: [],
unplaced: [],
@@ -333,10 +342,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
pending = pending.filter((p) => p !== entry);
};
const addToRun = (el: HTMLElement, call: Call | undefined) => {
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls), snapMark(run.snaps)];
const addToRun = (el: HTMLElement, call: Call | undefined, parent: string) => {
const run = ctx.run;
if (!run) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [] };
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], snaps: [], parent };
put(el);
return;
}
@@ -352,7 +363,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
run.group = { el: group, summary, body };
}
run.group.body.append(el);
replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
replace(run.group.summary, ...runHead(run));
};
const addAnchor = (seq: number, el: HTMLElement, turnEnd: boolean) => {
@@ -381,7 +392,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
run.snaps.push(n);
if (run.group) {
replace(run.group.summary, runSummary(run.calls), snapMark(run.snaps));
replace(run.group.summary, ...runHead(run));
}
};
@@ -463,7 +474,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
headImpl.onEvents?.(events);
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
if (ev.kind === "agent.message" || ev.kind === "turn.ended" || ev.kind === "user.message") {
const parent = parentOf(p);
if (ev.kind === "agent.vendor") {
claudeTaskStatus(p, ctx);
continue;
}
if ((ev.kind === "agent.message" && !parent) || ev.kind === "turn.ended" || ev.kind === "user.message") {
dropStream();
}
if (ev.kind === "user.message" && ev.actor !== "byre") {
@@ -480,9 +496,16 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
placeSnapshot(p as unknown as Snapshot);
continue;
}
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
ctx.run = undefined;
const el = renderSubagent(p, ctx);
put(el);
addAnchor(ev.seq, el.querySelector("summary")!, false);
continue;
}
if (ev.kind === "agent.tool_call") {
const el = renderToolCall(p, ctx);
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) });
addToRun(el, { name: String(p["name"]), target: toolTarget(p["input"], ctx.root) }, parent);
addAnchor(ev.seq, el.querySelector("summary")!, false);
continue;
}
@@ -491,7 +514,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
continue;
}
if (ev.kind === "agent.thinking") {
addToRun(el, undefined);
addToRun(el, undefined, parent);
continue;
}
ctx.run = undefined;
@@ -818,18 +841,75 @@ export function compactButton(sessionId: string | undefined, sessionState: strin
return b;
}
function parentOf(p: Record<string, unknown>): string {
return typeof p["parent_tool_use_id"] === "string" ? p["parent_tool_use_id"] : "";
}
function subagentTag(parent: string, ctx: Ctx): HTMLElement | null {
if (!parent) {
return null;
}
const sub = ctx.subagents.get(parent);
return h("span", { class: "subagent-tag", title: sub?.description ?? "" }, `↳ ${sub?.type ?? "subagent"}`);
}
function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const result = h("div", { class: "tool-result pending" }, "running…");
ctx.tools.set(String(p["id"]), result);
return h(
"details",
{ class: "tool" },
h("summary", {}, h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))),
h("summary", {}, subagentTag(parentOf(p), ctx), h("span", { class: "tool-name" }, String(p["name"])), " ", h("span", { class: "tool-brief" }, toolBrief(p["input"], ctx.root))),
h("pre", { class: "tool-input" }, JSON.stringify(p["input"], null, 2)),
result,
);
}
function isSubagentCall(p: Record<string, unknown>): boolean {
return p["name"] === "Agent" || p["name"] === "Task";
}
function renderSubagent(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const input = (typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {}) as Record<string, unknown>;
const id = String(p["id"]);
const type = String(input["subagent_type"] ?? "subagent");
const description = String(input["description"] ?? "");
const status = h("span", { class: "subagent-status" }, "starting…");
const result = h("div", { class: "tool-result pending" }, "running…");
const tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
ctx.subagents.set(id, { type, description, status });
return h(
"details",
{ class: "tool subagent" },
h("summary", {}, tag, h("span", { class: "tool-name" }, `${p["name"]} ${type}`), " ", h("span", { class: "tool-brief" }, description), " ", status),
markdown(String(input["prompt"] ?? ""), "text md subagent-prompt", { taskId: ctx.taskId }),
result,
);
}
function claudeTaskStatus(p: Record<string, unknown>, ctx: Ctx): void {
if (p["type"] !== "system") {
return;
}
const sub = ctx.subagents.get(String(p["tool_use_id"]));
if (!sub) {
return;
}
switch (p["subtype"]) {
case "task_started":
replace(sub.status, p["is_backgrounded"] === true ? "running in the background" : "running");
return;
case "task_progress":
replace(sub.status, String(p["description"] ?? "running"));
return;
case "task_notification":
sub.status.className = `subagent-status ${p["status"] === "completed" ? "done" : "error"}`;
replace(sub.status, String(p["status"]));
return;
}
}
function snapMark(ns: number[]): HTMLElement | null {
if (ns.length === 0) {
return null;
@@ -901,6 +981,9 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
);
case "agent.message":
if (parentOf(p)) {
return h("div", { class: "msg agent subagent" }, h("div", { class: "who" }, subagentTag(parentOf(p), ctx), " ", when), markdown(String(p["text"]), "text md", md));
}
if (String(p["text"]).trim() === quietReply) {
ctx.quiet = true;
return null;
@@ -910,7 +993,7 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
return ctx.lastMessage;
case "agent.thinking":
return h("details", { class: "thinking" }, h("summary", {}, "thinking"), markdown(String(p["text"]), "text md", md));
return h("details", { class: "thinking" }, h("summary", {}, subagentTag(parentOf(p), ctx), "thinking"), markdown(String(p["text"]), "text md", md));
case "agent.tool_result": {
const target = ctx.tools.get(String(p["tool_use_id"]));
+29
View File
@@ -735,6 +735,35 @@ button.compact.danger {
color: var(--muted);
}
.subagent-status {
float: right;
margin-left: 12px;
color: var(--accent);
}
.subagent-status.done {
color: var(--muted);
}
.subagent-status.error {
color: var(--danger);
}
.subagent-tag {
margin-right: 8px;
color: var(--accent);
font-size: 11px;
}
.msg.agent.subagent {
padding-left: 10px;
border-left: 1px solid var(--accent);
}
.subagent-prompt {
padding: 6px 0;
}
.tool-input,
.tool-result pre {
background: var(--raised);