diff --git a/docs/operations.md b/docs/operations.md index d94016d..5918cb4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -248,7 +248,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 app loads every active task's whole history when it starts (see "Events"), so this is what its startup 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 = '' 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 (`open` in `TranscriptBuilder`: 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 2pt dots, 5pt apart along an 84pt 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 is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. 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 collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner the Project tab shows for task states is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); 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`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". 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, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). 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 relaunching the app. 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. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. +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. Review findings forwarded to the agent come this way too, so the transcript shows them separately: a `review.finished` event with findings draws its counts note with the review's `summary` (one bullet per finding) expanded under it, behind a border-coloured rule. 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 (`open` in `TranscriptBuilder`: 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 2pt dots, 5pt apart along an 84pt 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 is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. 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 collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner the Project tab shows for task states is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); 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`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". 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, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). 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 relaunching the app. 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. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. **Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id), and `comments`, the Changes view's pending line comments keyed by task id, each `{anchor: {path, line, old, quote}, text}`. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its `path:line`, the quoted line, its editable text and **remove**, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. 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 relaunch 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. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. 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 record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. @@ -323,7 +323,7 @@ The running macOS app posts a notification for each new question, error and rate The left pane's **Settings** tab asks macOS for notification permission and edits the preferences, and also holds the Default model menu (see "Agent sessions") and the two Review models menus (see "Reviews"). -Each question also shows in the task's transcript where it was asked, as a bubble (`QuestionBubble` in `mac/Sources/Byre/Chat.swift`) holding its `question` text and its state: "waiting for your answer" in the warn colours while it is open, then who chose which options or the first line of who answered what, "dismissed by" whoever dismissed it, or "withdrawn by the agent". The transcript builds it from the item's `inbox.created` event and updates it from each later `inbox.updated` event of the same item. Clicking a bubble shows that question in the Questions tab (`Store.showQuestion`, held in `Store.question` with its task id), open or not. An item that is no longer open shows read-only: its header adds its state, its chosen options are marked "chosen", its answers are listed under it, and it has no Other, Let the agent decide or Submit. The item comes from `Store.inbox` while it is open and otherwise from its latest `inbox.created` or `inbox.updated` event in the task's feed (`Store.questionItem`). Switching tab or task in any other way, including clicking the Questions tab itself, clears the pick, so the tab goes back to the task's oldest open item; answering the picked question clears it too. +Each question also shows in the task's transcript where it was asked, as a bubble (`QuestionBubble` in `mac/Sources/Byre/Chat.swift`) holding its `question` text and its state: "waiting for your answer" in the warn colours while it is open, then, on a faint yellow background (`Palette.warnFaint`) that keeps past questions apart from the rest of the stream, who chose which options or the first line of who answered what, "dismissed by" whoever dismissed it, or "withdrawn by the agent". The transcript builds it from the item's `inbox.created` event and updates it from each later `inbox.updated` event of the same item. Clicking a bubble shows that question in the Questions tab (`Store.showQuestion`, held in `Store.question` with its task id), open or not. An item that is no longer open shows read-only: its header adds its state, its chosen options are marked "chosen", its answers are listed under it, and it has no Other, Let the agent decide or Submit. The item comes from `Store.inbox` while it is open and otherwise from its latest `inbox.created` or `inbox.updated` event in the task's feed (`Store.questionItem`). Switching tab or task in any other way, including clicking the Questions tab itself, clears the pick, so the tab goes back to the task's oldest open item; answering the picked question clears it too. The Questions tab shows one item at a time, the task's oldest open one unless a bubble picked another, filling the tab without a card frame; the next appears when it is answered. Each item is answered on its own; there is no batch submission. A pick-one question's choices are clickable blocks, and clicking one sends that answer at once. A choice's block (or a pick-any choice's checkbox label) holds the whole list item: its first line, which is the choice's text, and everything under it, such as an example's paragraph or code block. A pick-any question's choices are checkboxes, sent with the Submit button at the bottom of the card. Below the choices, an "Other…" block in the same style opens the only text field. Under it, every question has one submit row: a "Let the agent decide" button at its left, away from the choices so it isn't hit by accident, and the Submit button at its right. Let the agent decide sends at once an `other` answer reading "Use your judgment for this decision and continue within the existing task instructions." (`delegateText` in `mac/Sources/Byre/Questions.swift`), which shows in the card's answer list like any other; the `ask_human` description tells agents that this answer settles only that decision and never grants an authorization the owner must give explicitly. ArrowDown and ArrowUp move between the choice blocks, the checkboxes and the Other block. A question has exactly one Submit button, enabled once there is something to send: on a pick-one question it sends the Other text as `other`; on a pick-any question it sends the checked choices with the Other text alongside (one `option` response), or the Other text alone as `other` when nothing is checked. The Other text box sends with the Submit button or on Enter (Shift+Enter for a newline), like the message box, and folds back into its block when left empty with no images. Pasting or dropping an image into it adds a thumbnail, as in the composer (both keep pending images in `Store.images`, an `ImageDrafts`); images upload when the answer is submitted and are put back if the submission fails. Pending answer images are not kept across a relaunch. Clicking a pick-one choice sends no images. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 3fe9ad0..a993037 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1017,6 +1017,16 @@ struct RowView: View { case .note(let note): NoteView(note: note, cancelVisible: row.id == model.builder.lastMergeAfterReview) + case .review(let note, let blocks): + VStack(alignment: .leading, spacing: 4) { + NoteView(note: note, cancelVisible: false) + MarkdownView(blocks: blocks, ctx: md) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) + } + } + case .handoff: DetailsBox(boxed: false) { NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false) @@ -1068,7 +1078,7 @@ private struct QuestionBubble: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(open ? Palette.warnTint : Palette.surface, in: RoundedRectangle(cornerRadius: 10)) + .background(open ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(hover ? Palette.accent : open ? Palette.warn.opacity(0.55) : Palette.border)) .contentShape(Rectangle()) .onTapGesture { diff --git a/mac/Sources/Byre/Palette.swift b/mac/Sources/Byre/Palette.swift index f35b4c4..7fd53c3 100644 --- a/mac/Sources/Byre/Palette.swift +++ b/mac/Sources/Byre/Palette.swift @@ -14,6 +14,7 @@ enum Palette { static let dangerTint = dynamic(0xfdecea, 0x3a1f1d) static let warn = dynamic(0x855600, 0xf2c66d) static let warnTint = dynamic(0xfdf3dc, 0x352a14) + static let warnFaint = dynamic(0xfcf8ec, 0x221f19) static let ok = dynamic(0x1a7f37, 0x7fd88f) static let okTint = dynamic(0xe5f4e9, 0x1b3322) static let task0 = dynamic(0xa4273f, 0xfe9fa6) diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index 9196c28..ff1c298 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -50,6 +50,7 @@ enum RowKind { case orphan(String) case turnEnd(error: String?) case note(Note) + case review(note: Note, md: [MDBlock]) case handoff case snapshot(Snapshot) case question @@ -703,7 +704,11 @@ final class TranscriptBuilder { case "review.finished": let r = try! decodePayload(ev.payload, as: Review.self) let outcome = r.state == .failed ? "failed: \(r.error)" : r.state == .skipped ? "skipped: no changes" : reviewCounts(r) - return note([.text("review of snapshot #\(r.snapshotN): \(outcome)")], r.state == .failed ? .error : .plain) + let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")] + if findings(r).isEmpty { + return note(parts, r.state == .failed ? .error : .plain) + } + return Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId) case "task.merge_queued": return note([.text("\(ev.actor) asked to merge; held until the merge in progress in this project is done")])