Stream Claude Code subagent activity inline in the transcript
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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").
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -757,12 +757,19 @@ 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 {
|
||||
err = m.handleAssistantBlock(ctx, l, b)
|
||||
err = m.handleAssistantBlock(ctx, l, b, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if parent != "" {
|
||||
return nil
|
||||
}
|
||||
usage, err := ev.ContextUsage()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Vendored
+73
-10
@@ -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"]));
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+93
-10
@@ -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"]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user