tool folding

This commit is contained in:
Ian Gulliver
2026-10-16 11:26:15 -07:00
parent 4dff9b7471
commit ff7ce86cb5
5 changed files with 77 additions and 21 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen
Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the page loads every active task's whole history when it starts (see "Events"), so this is what a page load costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC"` one task's.
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) 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 a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. 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. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. 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. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. 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. 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 (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. 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, and stays enabled while a turn runs: 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 bubble reading "queued…" while the session is running and "sending…" otherwise, 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) 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 a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. 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. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. 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. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. 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) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a 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. 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 (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. 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, and stays enabled while a turn runs: 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 bubble reading "queued…" while the session is running and "sending…" otherwise, 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.
**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `web/src/drafts.ts` and mirrored to the browser's localStorage under `byre.drafts.<login>`, one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced and flushed at once when a composer or answer box loses focus, when the transcript view is disposed, and on `pagehide`. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a reload mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A storage read that throws or holds malformed JSON (or entries of the wrong shape) is ignored, and a write that throws is logged; drafts then live only in memory. Pending images are not stored, only their count: after a reload the composer shows how many need re-attaching, until dismissed or the next successful send.
+31 -9
View File
@@ -313,12 +313,18 @@ export function mountChat(root, src, makeHead) {
};
const runHead = (run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el, call, parent) => {
const run = ctx.run;
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
put(el);
const sub = ctx.subagents.get(parent);
if (sub) {
sub.run = extendRun(sub.run, el, call, parent, (e) => sub.body.append(e));
return;
}
ctx.run = extendRun(ctx.run, el, call, parent, put);
};
const extendRun = (run, el, call, parent, place) => {
if (!run || run.parent !== parent) {
place(el);
return { first: el, group: undefined, calls: call ? [call] : [], parent };
}
if (call) {
run.calls.push(call);
}
@@ -333,6 +339,7 @@ export function mountChat(root, src, makeHead) {
}
run.group.body.append(el);
replace(run.group.summary, ...runHead(run));
return run;
};
let stream;
const dropStream = () => {
@@ -429,9 +436,16 @@ export function mountChat(root, src, makeHead) {
markReview(button, r);
}
}
const sub = ctx.subagents.get(parent);
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
const el = renderSubagent(p, ctx);
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
put(renderSubagent(p, ctx));
put(el);
continue;
}
if (ev.kind === "agent.tool_call") {
@@ -446,6 +460,11 @@ export function mountChat(root, src, makeHead) {
addToRun(el, undefined, parent);
continue;
}
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
if (ev.kind === "turn.ended") {
ctx.open = false;
@@ -785,8 +804,10 @@ 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"}`);
if (ctx.subagents.has(parent)) {
return null;
}
return h("span", { class: "subagent-tag" }, "↳ subagent");
}
function renderToolCall(p, ctx) {
const result = h("div", { class: "tool-result pending" }, "running…");
@@ -814,10 +835,11 @@ function renderSubagent(p, ctx) {
const description = String(input["description"] ?? "");
const status = h("span", { class: "subagent-status" }, "starting…");
const result = h("div", { class: "tool-result pending" }, "running…");
const body = h("div", { class: "subagent-body" });
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);
ctx.subagents.set(id, { type, description, status, body, run: undefined });
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 }), body, result);
}
function claudeTaskStatus(p, ctx) {
if (p["type"] !== "system") {
+1 -1
View File
File diff suppressed because one or more lines are too long
+34 -8
View File
@@ -66,6 +66,8 @@ type Subagent = {
type: string;
description: string;
status: HTMLElement;
body: HTMLElement;
run: Run | undefined;
};
type Ctx = {
@@ -397,12 +399,19 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const runHead = (run: Run) => [subagentTag(run.parent, ctx), runSummary(run.calls)];
const addToRun = (el: HTMLElement, call: Call | undefined, parent: string) => {
const run = ctx.run;
if (!run || run.parent !== parent) {
ctx.run = { first: el, group: undefined, calls: call ? [call] : [], parent };
put(el);
const sub = ctx.subagents.get(parent);
if (sub) {
sub.run = extendRun(sub.run, el, call, parent, (e) => sub.body.append(e));
return;
}
ctx.run = extendRun(ctx.run, el, call, parent, put);
};
const extendRun = (run: Run | undefined, el: HTMLElement, call: Call | undefined, parent: string, place: (el: HTMLElement) => void): Run => {
if (!run || run.parent !== parent) {
place(el);
return { first: el, group: undefined, calls: call ? [call] : [], parent };
}
if (call) {
run.calls.push(call);
}
@@ -417,6 +426,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
run.group.body.append(el);
replace(run.group.summary, ...runHead(run));
return run;
};
let stream: { el: HTMLElement; text: string; timer: number } | undefined;
@@ -515,9 +525,16 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
markReview(button, r);
}
}
const sub = ctx.subagents.get(parent);
if (ev.kind === "agent.tool_call" && isSubagentCall(p)) {
const el = renderSubagent(p, ctx);
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
put(renderSubagent(p, ctx));
put(el);
continue;
}
if (ev.kind === "agent.tool_call") {
@@ -532,6 +549,11 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
addToRun(el, undefined, parent);
continue;
}
if (sub) {
sub.run = undefined;
sub.body.append(el);
continue;
}
ctx.run = undefined;
if (ev.kind === "turn.ended") {
ctx.open = false;
@@ -907,8 +929,10 @@ 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"}`);
if (ctx.subagents.has(parent)) {
return null;
}
return h("span", { class: "subagent-tag" }, "↳ subagent");
}
function renderToolCall(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
@@ -946,14 +970,16 @@ function renderSubagent(p: Record<string, unknown>, ctx: Ctx): HTMLElement {
const description = String(input["description"] ?? "");
const status = h("span", { class: "subagent-status" }, "starting…");
const result = h("div", { class: "tool-result pending" }, "running…");
const body = h("div", { class: "subagent-body" });
const tag = subagentTag(parentOf(p), ctx);
ctx.tools.set(id, result);
ctx.subagents.set(id, { type, description, status });
ctx.subagents.set(id, { type, description, status, body, run: undefined });
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 }),
body,
result,
);
}
+10 -2
View File
@@ -894,15 +894,23 @@ button.compact.danger {
cursor: pointer;
}
.tool-group-body {
.tool-group-body,
.subagent-body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 0 4px;
}
.subagent-body:empty {
display: none;
}
.tool-group-body .thinking,
.tool-group-body .tool {
.tool-group-body .tool,
.subagent-body > .thinking,
.subagent-body > .tool,
.subagent-body > .tool-group {
background: var(--raised);
}