Hide the lead's nothing-to-say turns from its transcript

This commit is contained in:
Ian Gulliver
2026-10-15 10:21:34 -07:00
parent 03b70bd44e
commit 40595d4c5a
4 changed files with 14 additions and 8 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
- **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no inbox report; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note. Its turns don't count against the caps.
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs` and becomes its next turn; the composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends folds it into one collapsed row, "byre <the turn's first line> · nothing to say", that expands to the signals and anything the lead did.
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends removes the whole turn, from its `byre` message through anything the lead did, so nothing of it is drawn.
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Todo tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/<id>`, `#/project/<id>`, or `#/` for neither.
- **Pane lock.** While the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
+6 -3
View File
@@ -401,9 +401,12 @@ export function mountChat(root, src, makeHead) {
continue;
}
if (ev.kind === "turn.ended" && ctx.quiet && ctx.turnStart?.parentNode === transcript) {
const brief = ctx.turnStart.querySelector(".tool-brief")?.textContent;
const label = h("div", {}, brief && h("span", { class: "tool-name" }, "byre"), brief && " ", h("span", { class: "tool-brief" }, brief ? `${brief} · nothing to say` : "nothing to say"));
addAnchor(ev.seq, foldTurn(ctx.turnStart, label), true);
let node = ctx.turnStart;
while (node && node !== spinner) {
const next = node.nextSibling;
node.remove();
node = next;
}
ctx.turnStart = undefined;
ctx.quiet = false;
continue;
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -484,9 +484,12 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
continue;
}
if (ev.kind === "turn.ended" && ctx.quiet && ctx.turnStart?.parentNode === transcript) {
const brief = ctx.turnStart.querySelector(".tool-brief")?.textContent;
const label = h("div", {}, brief && h("span", { class: "tool-name" }, "byre"), brief && " ", h("span", { class: "tool-brief" }, brief ? `${brief} · nothing to say` : "nothing to say"));
addAnchor(ev.seq, foldTurn(ctx.turnStart, label), true);
let node: ChildNode | null = ctx.turnStart;
while (node && node !== spinner) {
const next: ChildNode | null = node.nextSibling;
node.remove();
node = next;
}
ctx.turnStart = undefined;
ctx.quiet = false;
continue;