diff --git a/api/openapi.yaml b/api/openapi.yaml index 0834c11..7f4bd30 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1526,7 +1526,7 @@ components: Review: type: object description: An agent session on the vendor the task's agent doesn't use, reviewing one snapshot's change. - required: [id, task_id, snapshot_n, agent_session_id, vendor, model, state, error, created_at, finding_states] + required: [id, task_id, snapshot_n, agent_session_id, vendor, model, state, error, created_at, finding_states, finding_responses] properties: id: { type: string } task_id: { type: string } @@ -1547,6 +1547,20 @@ components: type: object description: "How the human addressed each finding, keyed by its index in result.findings; a finding not listed is open." additionalProperties: { $ref: "#/components/schemas/FindingState" } + finding_responses: + type: object + description: "How the task's agent answered each finding with respond_to_finding, keyed by its index in result.findings; its latest answer wins." + additionalProperties: { $ref: "#/components/schemas/FindingResponse" } + + FindingResponse: + type: object + required: [response, reason] + properties: + response: + type: string + enum: [fixing, ignoring] + x-enum-varnames: [FindingFixing, FindingIgnoring] + reason: { type: string, description: "What the agent is doing about it, or why it leaves it." } FindingState: type: string @@ -1781,8 +1795,8 @@ components: TaskTab: type: string - enum: [transcript, board, changes, files, terminal, questions, reviews, workflow, project] - x-enum-varnames: [TabTranscript, TabBoard, TabChanges, TabFiles, TabTerminal, TabQuestions, TabReviews, TabWorkflow, TabProject] + enum: [transcript, board, changes, files, terminal, workflow, project] + x-enum-varnames: [TabTranscript, TabBoard, TabChanges, TabFiles, TabTerminal, TabWorkflow, TabProject] TerminalSize: type: object diff --git a/docs/macos.md b/docs/macos.md index 2f3e83c..e8c5b65 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -46,11 +46,11 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead | | `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views | | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments | -| `Questions.swift` | The Questions tab: one item at a time, choices, Other, Let the agent decide, answered items read-only | +| `Questions.swift` | The question card an open question shows in the transcript: choices, Other, Let the agent decide | | `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs, line comments | | `Files.swift` | Read-only worktree browser | | `Terminal.swift` | The terminal view and its WebSocket | -| `Reviews.swift` | Review cards, finding diffs, Fix and Ignore | +| `Reviews.swift` | Finding rows, finding diffs, Fix and Ignore, Stop for a running review | | `Workflow.swift` | Stage graph and attempts | | `Project.swift` | Project tab and TODO items | | `Notes.swift`, `Workflows.swift`, `Status.swift`, `Settings.swift` | Left-pane tabs | @@ -87,7 +87,7 @@ One window (a `Window` scene, id `main`) shows one saved server at a time: `Serv At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. - **Left pane:** tabs Lead, Status, Server, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. -- **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in their fixed order, hiding Questions, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges. The pane's accent is the selected task's colour. +- **Right pane:** the task bar (task picker `<project> ▸ <title> ▾` and the preview links), then the task's tabs in their fixed order, hiding Board and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, not system sheets or alerts. The switcher and the Discard confirmation sit just below their anchor; the merge dialog is centred in the right pane. @@ -97,13 +97,13 @@ Connections belong to `Servers`, not to the window: every saved server's store a ## Views -- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, question bubbles that follow their item's `inbox.updated` events and open it in the Questions tab when clicked, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. +- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review (and stop while the review runs), question bubbles that follow their item's `inbox.updated` events, a finished review as its summary row followed by one row per finding, the agent's `respond_to_finding` calls as their own rows, and notes for the other event kinds. Rows that hold a review (`Row.review`) are kept per review id and updated by each later `review.*` event. It is incremental: a new batch extends the existing rows. At the transcript's top right, the attention buttons (`AttentionBar`, from `ChatModel.attention`) count what needs the user: open questions, findings of the latest review that neither the user nor the agent has answered, and the current ready-for-merge overview; each click scrolls to the next one, wrapping. Jumps from elsewhere (`Store.jumpTo` with a `JumpTarget`: a summary, a question or a snapshot's review) select the task's transcript and scroll there once the row exists. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. The head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. -- **Questions.** The question a transcript bubble picked (`Store.question`), read-only once it is no longer open, or else the selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. -- **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. +- **Questions.** An open question's bubble in the transcript holds its card, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. Once answered or dismissed, the bubble shrinks to the question and who answered what. +- **Changes, Files, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: <status>` with New shell, 1011 for a start failure, reconnect after 2 s while the task is active, one socket per open tab and selected task. Colours come from the palette. Ctrl combinations typed in it go to the shell. The cursor is a steady block (`setCursorStyle(.steadyBlock)` when the view is made): SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion (variable refresh rate) display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. A program in the shell can still ask for a blinking cursor with DECSCUSR. -- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Questions tab when it has open items, its unread summary when it has one, and its Transcript otherwise. -- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end. `confirmMerge` (`MergeDialog.swift`): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Questions tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. +- **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: tasks with inbox items first, then least recently visited, the selected task last, the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Transcript scrolled to its oldest open question when it has open items, to its unread summary when it has one, and at the end otherwise. +- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end. `confirmMerge` (`MergeDialog.swift`): the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and jump to the review or the oldest open question in the transcript, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. ## Markdown diff --git a/docs/operations.md b/docs/operations.md index 5d53335..3e64eb3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -33,7 +33,7 @@ Every task worktree is made by `createTask` (`internal/agents/tasks.go`): new ta ## Closing a task -A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, Discard's just below its button like the task picker's switcher and Merge's centred in the right pane, and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `mac/Sources/Byre/MergeDialog.swift`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. When the latest snapshot's review is running as the dialog opens, its buttons are Cancel, **Merge now** and **After review**, the default (Enter picks it); otherwise Cancel and **Merge**. The buttons are chosen once at opening, so a review that finishes while the dialog is open leaves **After review** there and choosing it is refused. The review and question warnings link to the Reviews tab at that snapshot or the Questions tab; following one closes the dialog and keeps the title, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes. +A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record, summary and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a confirmation, Discard's just below its button like the task picker's switcher and Merge's centred in the right pane, and are disabled while the task's session is `running`. Merge is also disabled until `unmerged` reports a commit or a changed file; the transcript fetches it when the task is opened and again shortly after each tool result or turn end. Merge's confirmation (`confirmMerge` in `mac/Sources/Byre/MergeDialog.swift`) is kept brief: the commit title as text with a ✎ button that swaps in an input, a collapsed "N co-authors" line holding the trailers, and one warning line for each thing worth stopping for. Those are the latest snapshot's review still running, failed, missing or with findings that weren't ignored (counted by severity); the task's open inbox questions, which merging dismisses; the worktree differing from the latest snapshot (`changes?since=` its commit), or no snapshot at all; and "Review status unavailable" when those fetches fail. Earlier snapshots' reviews aren't mentioned. Warnings never block Merge. When the latest snapshot's review is running as the dialog opens, its buttons are Cancel, **Merge now** and **After review**, the default (Enter picks it); otherwise Cancel and **Merge**. The buttons are chosen once at opening, so a review that finishes while the dialog is open leaves **After review** there and choosing it is refused. The review and question warnings link to that snapshot's review or the oldest open question in the task's transcript; following one closes the dialog and keeps the title, so the next Merge opens with it. While open, the dialog refetches on the task's `snapshot.created` and `review.*` events and redraws on inbox changes. A task's summary is what its agent writes, before the task closes, about the whole task: what it set out to do, what was built or tried, the key decisions and why, and what was left out. The agent sets it with the `set_task_summary` MCP tool (`internal/agents/summary.go`), any time while the task is active; each call replaces `tasks.summary` and records a `task.summary` event carrying it, which the transcript shows as a note. The Merge turn's prompt and the discard turn's prompt (`summaryRequest`) both ask for it. Turn summaries don't stand in for it: each covers only its own turn, and a merged task's last one is always the Merge turn's. Tasks closed before the column existed have none. `sqlite3 byre.db "SELECT id, status, summary FROM tasks WHERE status != 'active' ORDER BY closed_at DESC LIMIT 5"` shows recent ones. @@ -246,15 +246,15 @@ Agent health is `internal/agents/health.go`: The left pane's **Status** tab 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); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's rate-limit status with its reset time and, per window, the share used, the share of the window's time elapsed (from `resetsAt` and `windowMins`), and a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line, all vendors in one table so their columns and pace bars line up; a Clients table, one row per entry in `clients`, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created`, `admin.health` or `admin.clients` 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.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall`, `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash` and `call_mcp_tool` as `mcp__<server>__<tool>` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded 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 `summary: true` when its `result` is the turn's summary (see above). 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. +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.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall` (as `mcp__<namespace>__<tool>`, so byre's tools carry the same names as Claude's), `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash` and `call_mcp_tool` as `mcp__<server>__<tool>` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded 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 `summary: true` when its `result` is the turn's summary (see above). 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. 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 = '<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. 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. +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, and then each finding as its own row (see "Reviews"). 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:<task id>` 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. -The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), 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. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Questions tab when it has open inbox items, otherwise on the Transcript tab, scrolled to its latest summary when that is unread (see below and "Lead agent"). +The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), 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. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Transcript tab, scrolled to its oldest open question when it has open inbox items, otherwise to its latest summary when that is unread (see below and "Lead agent"). **Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `mac/Sources/Byre/Store.swift`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Merge after review** (`merge_after_review`), **Reviewing** (the task's `state` is `review_pending` or `reviewing`), **Working** (`running`), **Discarding** (`discarding`; see "Task state in the store" for both), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Reviewing**, **Working**, **Discarding** and **Merging**. The label follows the tasks and inbox state the app already holds, so it needs no extra fetch. @@ -272,8 +272,8 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie - **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), `session.error` and `session.rate_limited` on a task, `inbox.updated` when a human answered a question (the signal also carries its text and the agent's recommendation, so the lead sees where the human departed from it), `task.created` (naming the TODO item it was started from, or a subtask's parent, if any), `task.merge_queued` ("<task>'s merge was requested by <login> and is held until the merge in progress in its project is done."), `task.merge_after_review` ("<task> was set by <login> to merge once the review of snapshot <n> finishes with no findings."), `task.merge_after_review_cancelled` ("<task>'s merge after review was cancelled by <actor>."), `task.merge_after_review_dropped` ("<task> didn't merge after the review: <reason>"), `task.merge_started` ("<task> is merging, started by <login>."), `task.merged`, `task.discarded`, `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters), `task.workflow_stage` ("<task> started stage "<stage>" of workflow "<workflow>".") and `task.workflow_finished` ("<task> finished workflow "<workflow>":" with its output cut to 1500 characters), `review.started` ("<task> started a review of snapshot <n>."), `review.finished` ("<task> review of snapshot <n>:" with the review's summary, `change` and findings, or why it failed or was skipped; `reviewLine` in `internal/agents/review.go`), `user.message` on a task from a human (see "Pane lock"), and `task.state` ("<task> state: <from> → <to>.", or "<task> state: <to>." for a new task; see "Task state in the store"). The store hands `Observe` all of one transaction's events together, and they become one signal, its `task.state` lines first, so a turn's summary always arrives with the state its turn end left the task in. 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)", followed by "Tasks now:" with the `list_tasks` text and a line saying what the lead's right pane shows (task or project, and selected tab) and whether the human is working in it (`leadStateText` in `internal/agents/lead.go`). If collecting that fails, for example because a project's repository is gone, the batch is still sent, ending instead with "The task list and right pane state are unavailable:" and the error, which is also logged as `lead state`. 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 `mac/Sources/Byre/TranscriptBuilder.swift`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it, so an empty one never takes up space. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message is never drawn, the turn isn't opened, so the scanner stays off, its partial message stays hidden, and its tool calls, subagents and thinking are built into a held row list instead of the transcript. The turn is released, inserting those rows where the turn belongs, when it draws anything else: a real message, a note, or its own `turn.ended` when it ends without `NO_REPLY`. When it ends in `NO_REPLY` while still held, the held rows are dropped, so nothing of the turn ever appears; a turn that draws a real message anywhere in it keeps all of it, even when a later message is `NO_REPLY`. - **Tools.** `list_tasks` (each project's active tasks with their state, each subtask noting its parent, a held merge (`merge_queued_at`) and a merge after review (`merge_after_review`) each noted, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`; closed tasks too), `search_closed_tasks` (see "Closing a task"), `read_inbox`, `read_status` (the `GET /api/admin/status` body as JSON, built by the same `Server.status`, which the server hands `agents.New`), `answer_question` (an `option` or `other` 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; while it is mid-turn the message is queued through `Manager.Message` like the human's, see "Agent sessions", and the tool result says it was queued; on a team task it posts to the board, see "Teams"), `create_task` and `start_todo` (each with an optional `workflow` to freeze and run; `create_task` also takes `agents` to start a team), `create_project` (`name` and `repo_path`, created as the user by the same `Server.createProject` as `POST /api/projects`, which the server hands `agents.New`, so it publishes the same `project.created` event, and returned as JSON), `interrupt`, `compact_task`, `take_snapshot` (see "Snapshots"), `merge_task` (optional `target`: `main`, or `parent` for a subtask, see "Subtasks"), `discard_task`, `show`, `compact_self`, the note tools (see "Notes") and the workflow tools (see "Workflows"). 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` and `tab` (the selected one). The tabs' order is fixed; neither the lead nor the human can change it. 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 Project 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 app applies the focus at startup and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. -- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or its selected tab is `questions` and the task has no open items, or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When an `inbox.updated`, `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merging."/"is merged."/"is discarded."), even when the event itself makes no signal, such as a withdrawn question; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. -- **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link, a notification, or the app's own switch to Questions when a question arrives) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows still applies its tab order. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. +- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and changes nothing. Otherwise, while the human is working in the right pane (the app reports `focused` or `drafting`, below), the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged, discarded or merging (`merging_by` or `finalizing` set), or the human is waiting on the task's agent (`Manager.paneWaiting`): the selected tab is `transcript`, the task's implementer session is running, the app reports no unsent text in the pane, and either the `user.message` that started the running turn came from the user after the pane last moved, or the pane last moved 10 seconds ago or more (`watchHold`). The pane's last move is the last `PUT /api/me/focus` or `show` that set it (`Manager.paneMoved`, in memory, so a restart counts as long ago). For the user's own message, unsent text reported before the turn started doesn't count, since the app's report of the composer emptying on send can arrive after the message does. A `user.message` on a task from a human (not `byre`, an agent or a lead) signals every lead "<actor> sent <task> a message; its agent is working on it.", and the sender's own lead, when its pane shows that task and is waiting that way, also gets "Your right pane is free: the human is waiting on <task>'s agent." Each `PUT /api/me/focus` from the user starts a 10-second timer (`endWatchHold`); when it fires, if the pane hasn't moved since, the app still reports the human in it, the user hasn't messaged the task since the move (that already freed it, above), and the pane is waiting that way, the lead gets the signal "Your right pane is free: the human has watched <task>'s agent work for 10 seconds without typing." The lead's own `show` starts no timer. When a `task.merge_started`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> is merging." (or "is merged."/"is discarded."). The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`todo` in `mac/Sources/Byre/MarkdownView.swift`) as the title followed by `start` and `discuss` actions, as accent link text so the line stays selectable, that call `POST /api/projects/{id}/todo/tasks` with the user's default model, as the Project tab's do, or, once an active task was started from that item, the task's colour, dot and state, linking to it. When the lead suggests new work that isn't in TODO.md, its guidance tells it to write a link to `#/suggest/<project_id>/<URL-encoded title>/<URL-encoded brief>`, drawn by the same code with `start` and `discuss` buttons: `start` calls `POST /api/projects/{id}/tasks` with the title, the brief as `prompt` and the user's default model, which checks the user's git identity, creates the task and starts its agent in one request, with no lead turn in between; `discuss` sends the lead `POST /api/me/lead/messages` asking to discuss the suggestion, quoting its title and brief (with the user's default model only when the user has no live lead session). Once an active task in that project has the suggestion's title, it shows that task's state the same way. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The app sends `PUT /api/me/pane-focus` with `focused` and `drafting` whenever either changes (`PaneFocus` in `mac/Sources/Byre/PaneFocus.swift`): `focused` is true while the window is key, the app is active and the keyboard focus or the last click is inside the right pane, so a click anywhere in it counts, not only on inputs and buttons; `drafting` while any text field in the right pane other than the terminal holds non-blank text. The window reports to the server it shows. It sends false on deactivating, switching to another server, Forget Server, closing the window and quit, and resends true when its event stream reconnects. byre keeps both flags and the time of the last report in memory per user (`Manager.paneFocus`, `Manager.paneReported`), so they start false after a restart until the app resends them. +- **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link or a notification) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows changes nothing and says so. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it. - **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it. - **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends. - **Notes.** The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in `lead_notes`, one row per note keyed by `owner`, through its `remember` (add, or replace the text and project of an `id`), `forget` and `list_notes` tools (`internal/agents/leadnotes.go`). A note has a `project_id` when it applies to one project and none (the column empty) when it applies to the user in every project; `remember` takes `project_id` and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a `lead.note_saved` or `lead.note_deleted` event owned by the user. When a lead's agent process starts, `notesText` lists every note with its id after `leadGuidance`, the every-project notes first and then each project's under its name and id, in the session's instructions (`--append-system-prompt` for Claude, which keeps what the session's first request carried; `developerInstructions` for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. `GET /api/me/lead/notes` lists the caller's notes and `DELETE /api/me/lead/notes/{noteId}` deletes one (`Manager.DeleteLeadNote`), and, when the user has a live lead session, holds a message for it from `byre` naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on `NO_REPLY`) or mid-turn after the lead's next tool call; the Notes tab (`mac/Sources/Byre/Notes.swift`) shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. `sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at"` shows them. @@ -281,7 +281,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`): it is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on this Mac lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, always in that order. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "<name>" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`): it is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on this Mac lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the transcript's attention buttons, at its top right, count what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO @@ -295,7 +295,9 @@ The **Project** tab is the dashboard of the right pane's project (the focused ta Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. The tool's description tells agents to keep everything about one choice (what it does, its example, code, trade-offs) in lines indented beneath that choice rather than in text above the list; those lines render inside the option, and only the marker line's text is the option. When every item of a choice list has exactly one non-blank indented line and that line is only a Markdown image, the list renders as a grid of tiles (`grid`, set in `parseList` in `mac/Sources/Byre/Markdown.swift`): four across, or two across when the context is at most 480pt wide. The tool's description tells agents to write image choices this way. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. All questions are alike: the tool's description and its result tell the agent to end its turn after asking, and the answer arrives as its next message. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops it from holding back delivery; anything else is rejected with a message saying why. The inbox holds only questions; errors go in the transcript (see "Errors"). -`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The right pane shows a task's questions in its Questions tab. The tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Questions tab's button is highlighted in the warn colours (`attention` on `PaneTabButton` in `mac/Sources/Byre/Panes.swift`) and carries their count. Answering or dismissing the task's last open item from the Questions tab switches the pane to the Transcript tab. The other way round, a new question from the selected task arriving while the Transcript tab is shown switches the pane to the Questions tab, unless a text box in the pane holds unsent text (the `drafting` flag of the pane-focus report); questions already open when the app started don't. +`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. The right pane shows a task's questions in its transcript, where they were asked, and they are answered there (below). + +**Attention.** At the top right of a task's transcript, over its content, a column of buttons (`AttentionBar` in `mac/Sources/Byre/Chat.swift`, counted by `ChatModel.attention`) shows what in the transcript needs the user, one button per kind that has any, in the warn colours: "N questions" for its open questions, "N findings" for the findings of the task's latest review (the highest-numbered snapshot with a `review.*` event, once it is `done`) that have neither a state from the user nor a response from the agent, and "ready to merge" while the latest ready-for-merge overview is current (see "Ready for merge"). Each click scrolls to the next one of that kind, top-anchored, and after the last back to the first. A button goes away once nothing of its kind is left. Elsewhere, the switcher and the Merge confirmation's question warning jump to the task's oldest open question (`Store.showQuestion`), and the review warning to that snapshot's review (`Store.jumpTo` with `.review`): each selects the task's Transcript tab and scrolls there once the row is in the transcript. `POST /api/inbox/responses` answers any number of questions in one submission, each becoming `answered`. Response types: @@ -325,9 +327,9 @@ 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, 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. +Each question shows in the task's transcript where it was asked, as a bubble (`QuestionBubble` in `mac/Sources/Byre/Chat.swift`). While it is open, the bubble, in the warn colours, holds the whole question card (`QuestionCard` in `mac/Sources/Byre/Questions.swift`), answered in place. Once it is answered or dismissed, the bubble shrinks, on a faint yellow background (`Palette.warnFaint`) that keeps past questions apart from the rest of the stream, to its `question` text and 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. -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. +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 on the answered bubble 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. There is no snoozing. The `snoozed_until` and `snooze_until` columns are still in the schema, always empty, so builds from older branches keep working against the same database; nothing reads them. For the same reason `inbox_items` keeps its `priority` and `blocking` columns, which nothing reads or writes; new rows take their defaults (0 and 1, which older builds read as a blocking question). @@ -368,9 +370,9 @@ byre never signs with a key on the machine it runs on. When a project checkout's ## Changes -`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the Questions tab always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. +`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. -The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and **remove** drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows **Send N comments**, which posts them as one ordinary message to the task's live session, or starts a session when there is none (`Store.messageTask`, also used by a review finding's Fix), in the order they were made, each as `` `path:line` `` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its `+`, `-` or space, and the comment, separated by blank lines (`commentsMessage` in `mac/Sources/Byre/Changes.swift`). Empty boxes stay open; a failed send puts the comments back. The Questions tab shows just its summary line (file count, +/− totals, base) for the selected task at the top. +The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and **remove** drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows **Send N comments**, which posts them as one ordinary message to the task's live session, or starts a session when there is none (`Store.messageTask`, also used by a review finding's Fix), in the order they were made, each as `` `path:line` `` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its `+`, `-` or space, and the comment, separated by blank lines (`commentsMessage` in `mac/Sources/Byre/Changes.swift`). Empty boxes stay open; a failed send puts the comments back. ## Files @@ -398,7 +400,7 @@ Merging or discarding a task stops new snapshots for it, waits for one in progre `GET /api/tasks/{taskId}/changes?snapshot=<n>` reports a snapshot instead of the live worktree: its `tree` is the snapshot's tree, and `base` is the merge-base of the snapshot's commit and the default branch, or with `step=true` the previous snapshot's tree (for snapshot 1, the same merge-base: the snapshot commit's parent is the worktree's `HEAD`, which already holds whatever byre committed at the end of the turn). It reads the stored shas, so it works only while the task is active and its refs keep the objects; `since` with `snapshot` is a 400. `GET /api/tasks/{taskId}/diff` takes that `base` and `tree` as usual. -The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `mac/Sources/Byre/Store.swift`). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading `snapshot #n` (its trigger and time in the tooltip), placed where its `snapshot.created` event falls in the task's event order: just after the turn end it belongs to (after the triggering text for an old `message` snapshot). A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's **changes** button pins the snapshot (`Store.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab; its **fork** button forks it (see "Forking") and points the right pane at the new task; its **review** button sets `Store.reviewed` (cleared when the selected task changes) and switches the right pane to the Reviews tab. The Reviews tab shows one snapshot's review at a time: a header with a snapshot picker and ← → steps to the neighbouring reviewed snapshots (all setting `Store.reviewed`), then that snapshot's latest review, or the newest review when nothing is picked. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes. +The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `mac/Sources/Byre/Store.swift`). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading `snapshot #n` (its trigger and time in the tooltip), placed where its `snapshot.created` event falls in the task's event order: just after the turn end it belongs to (after the triggering text for an old `message` snapshot). A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's **changes** button pins the snapshot (`Store.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab; its **fork** button forks it (see "Forking") and points the right pane at the new task; its **review** button scrolls the transcript to that snapshot's review, and is disabled until the review has finished; while it runs, a **stop** button sits beside it. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes. ## Forking @@ -416,10 +418,12 @@ Every new snapshot of a task gets a review: a separate agent session on the vend - **Starting.** After `takeSnapshot` creates a snapshot, `afterSnapshot` runs `startReviewer`: it picks the model from the task creator's `users.review_model` and `users.second_review_model` (`reviewerModel`): the second review model when the task's latest implementer session runs exactly the review model, the review model otherwise, and the vendor follows from the model. They are returned as `review_model` and `second_review_model` on `GET /api/me`, set together with `PUT /api/me/review-models` from the Settings tab's Review models menus, and record `user.updated`. A model whose vendor's CLI isn't on the service's `PATH` fails the review with the start error. It then checks the snapshot's commit out detached at `worktrees/<project-id>/review-<review-id>`, creates an `agent_sessions` row with `role` `reviewer` on the task (created by the task's creator), a `reviews` row (`running`, one per snapshot), and sends `reviewPrompt` as its first turn (actor `byre`): all of the task's work as `git diff` from the merge-base of the snapshot and the default branch, with its changed files, and the task's first message to its agent. Every review covers the whole task against the default branch, not only what changed since the previous snapshot, and a snapshot with no changes against it is `skipped`. - **Running.** Reviewer sessions get `reviewGuidance` and only the `submit_review` tool, whose arguments (`reviewSchema`: `change`, a few sentences of Markdown on what the change does, required and non-empty; a Markdown `summary`, which the guidance and schema require to be exactly "No significant findings." when there are no findings and otherwise a bulleted list with one line per finding; `findings`, each with `severity`, `category`, `title`, `detail`, `path`, `line`, `end_line` and `images`; and top-level `images`) become the review's `result` and mark it `done`. The guidance encourages, without requiring, Mermaid blocks for a data flow, call graph, state machine or schema the change alters, and screenshots when the change alters something visible. -- **Forwarding.** When a submitted review has findings, byre sends them to the task's live agent as a `byre` message (`forwardFindings` and `findingsMessage` in `internal/agents/review.go`): each finding's severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief, act on those that hold up, and say in its final message which it acted on and which it left and why. It goes through `Manager.Message`, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says `review findings not forwarded`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available. +- **Forwarding.** When a submitted review has findings, byre sends them to the task's live agent as a `byre` message (`forwardFindings` and `findingsMessage` in `internal/agents/review.go`): each finding's number, severity, title, location and detail, under a preamble asking the agent to check each one against the code and its brief and, before changing anything, answer each with `respond_to_finding` (see "Agent responses"). It goes through `Manager.Message`, so it is queued like any message while the agent is mid-turn. A task with no live agent session gets nothing, and the log says `review findings not forwarded`. Forwarding doesn't set any finding's state; **fix** and **ignore** stay available until the agent or the user answers. - **Screenshots.** Reviewer sessions also get the Playwright MCP server (`playwrightMCP` in `internal/agents/review.go`: `npx @playwright/mcp` at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a `stdio` entry in `--mcp-config` and to Codex as `mcp_servers` in the thread's `config`. Its automatically named files go to `worktrees/<project-id>/review-<review-id>-media`, removed with the checkout. Each `images` entry is `{path, caption}`, the path absolute or relative to the checkout; at `submit_review` byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of `store.ImageTypes` within `store.MaxBlobBytes`, then stores each as a blob and replaces its `path` with `blob`, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or turn summaries, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. `StopReview` (`POST /api/reviews/{reviewId}/stop`, the Stop button on a running review's card) ends the reviewer the same way and fails the review ("the reviewer was stopped"), and `Watch` calls it for a reviewer whose process the health check finds `stuck` (no stream event for `StuckAfter`), which is how a reviewer whose connection died while the machine slept gets cleared. Reviews are of all the task's work, so a newer one makes older ones moot: once a snapshot's review starts, or is skipped for having no diff, byre fails the task's running reviews of earlier snapshots ("superseded by snapshot N") and ends their reviewers (`failRunningReviews`). Each snapshot's review starts on its own goroutine under `reviewMu`, so they can run out of order; `afterSnapshot` starts no review for a snapshot when the task already has a review of a later one. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails. - **Events.** `review.started`, `review.finished` (state `done`, `failed` or `skipped`; a skipped review emits only `review.finished`) and `review.finding` (a finding's state set, with the user as actor) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions. -- **UI.** The **Reviews** tab (`mac/Sources/Byre/Reviews.swift`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary, screenshots and findings, and, while it runs, a **Stop** button (`POST /api/reviews/{reviewId}/stop`). The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot against the default branch (`changes?snapshot=<n>`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Clicking a screenshot opens it full size. Each open finding has **fix** and **ignore** buttons (also on review cards in the inbox). **fix** sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or **ignore** on its own, records the finding's state (`fixed` or `ignored`) with `PUT /api/reviews/{reviewId}/findings/{index}`, the index being the finding's position in `result.findings`; the review's `finding_states` (the `reviews.finding_states` column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a `done` review's findings take a state, and there is no way to clear one. The tab loads when it opens, when the task changes, and on each `review.*` event in the task's stream. The tab follows the latest `review.*` event of the highest-numbered snapshot: while that review runs, the tab label (and its entry in the More menu) carries no badge, hiding any earlier review's count; once it finishes with open findings (those with no state), the tab (or the More menu holding it) is highlighted with a badge of their count. Each snapshot row's **review** button shows that snapshot's latest `review.*` event: a fill in the accent tint (`Palette.accentTint`, the same in every task) while the review runs, and otherwise a mark over its top-right corner: a grey ● when it was skipped, a green ✓ when it finished with no open findings, and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. While any of a task's reviews is `running` and its agent is idle, the task's state is `reviewing` (see "Task state in the store"), which the task switcher and the Project tab show as **Reviewing** (see "Task state"). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones. +- **UI.** Reviews live in the task's transcript (`renderReview` in `mac/Sources/Byre/TranscriptBuilder.swift`, rows in `mac/Sources/Byre/Reviews.swift`). A finished review is a note at its `review.finished` event, "review of snapshot #n:" with its state or severity counts or error; with findings, the note carries the summary and screenshots, and each finding follows as its own row, numbered from 1 as in the message to the agent. While a review runs, its snapshot row has a **stop** button (`POST /api/reviews/{reviewId}/stop`). The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot against the default branch (`changes?snapshot=<n>`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Clicking a screenshot opens it full size. A finding with no answer from the user or the agent has **fix** and **ignore** buttons. **fix** sends the finding's number, severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or **ignore** on its own, records the finding's state (`fixed` or `ignored`) with `PUT /api/reviews/{reviewId}/findings/{index}`, the index being the finding's position in `result.findings`; the review's `finding_states` (the `reviews.finding_states` column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a `done` review's findings take a state; the user can't clear one, and only the agent's answer clears a `fixed` one (below). +- **Agent responses.** The message forwarding findings to the agent numbers them and tells it to answer each with the task agent's `respond_to_finding` MCP tool (`respondToFindingTool` in `internal/agents/review.go`) before changing anything: `snapshot`, `finding` (from 1), `response` (`fixing` or `ignoring`) and a one-line `reason`. byre records it in the review's `finding_responses` (the `reviews.finding_responses` column, a JSON object from index to `{response, reason}`; a later call for the same finding replaces it) and emits `review.finding` with actor `agent`. The answer also clears the finding's `fixed` state, since it answers that fix request: an ignore after the user's **fix** brings **fix** back so the user can overrule it again. A user's `ignored` stays. A call naming a snapshot with no `done` review, or a finding it doesn't have, is refused. On the finding, an answer shows as "agent fixing: …" or "agent ignoring: …" and dims the finding's diff, detail and screenshots. Fixing takes away both buttons; ignoring keeps **fix**, so the user can overrule it, and drops **ignore**. The tool call itself is its own transcript row (not folded into a group of tool calls): what the agent answered for which finding, its title and reason, a **show** button that scrolls to the finding, and, for an ignore, the same **fix** button while the user hasn't answered the finding. A call that fails is hidden. The Merge confirmation's review warning counts findings the user hasn't ignored, whatever the agent answered. +- **Snapshot row.** Each snapshot row's **review** button shows that snapshot's latest `review.*` event: a fill in the accent tint (`Palette.accentTint`, the same in every task) while the review runs, and otherwise a mark over its top-right corner: a grey ● when it was skipped, a green ✓ when it finished with no open findings (none, or each answered by the user or the agent), and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. While any of a task's reviews is `running` and its agent is idle, the task's state is `reviewing` (see "Task state in the store"), which the task switcher and the Project tab show as **Reviewing** (see "Task state"). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones. ## Workflows diff --git a/internal/agents/codex.go b/internal/agents/codex.go index 2eb5bd1..1bbcec5 100644 --- a/internal/agents/codex.go +++ b/internal/agents/codex.go @@ -114,6 +114,9 @@ func codexToolCall(item codex.ThreadItem) (string, any, error) { return "", nil, err } args, err := decode(item.Arguments) + if item.Type == codex.ThreadItemTypeDynamicToolCall && item.Namespace != "" { + tool = "mcp__" + item.Namespace + "__" + tool + } return tool, args, err } diff --git a/internal/agents/lead.go b/internal/agents/lead.go index fc158a2..a9616e7 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -21,7 +21,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Whenever you suggest new work that isn't in TODO.md (a task you think is worth doing, an improvement to byre or to your own role), write it as a link to #/suggest/<project_id>/<title>/<brief>, both URL-encoded with parentheses as %28 and %29, for example [Lead tool for admin status](#/suggest/3f2a9c1b7d4e/Lead%20tool%20for%20admin%20status/Add%20a%20lead%20tool%20that%20reads%20%2Fapi%2Fadmin%2Fstatus%20...). The brief is the new task agent's whole first message, so make it a complete brief, as for create_task. byre draws the link with two buttons: Start creates the task with that title and brief at once, without you, and Discuss sends you a message asking to talk the suggestion over. Once an active task has that title, it shows the task's state instead. Don't ask whether to start a suggestion; the buttons do that. - Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, and the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up: - Discussing, picking or starting TODO items, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved). - - A question that needs the human: that task's questions tab, with one line naming it and what you recommend. + - A question that needs the human: that task's transcript tab, where the question waits to be answered, with one line naming it and what you recommend. - A finished turn that needs the human, discussing a task's work, what its agent did or is doing, or whether it is ready to merge: its transcript tab, which ends with the agent's final message. A specific file in its worktree: its files tab. - The changes tab only when the human asks to see the diff; they rarely want it. - Show, don't tell. What the right pane shows, the human reads there: never repeat, quote or paraphrase a task agent's question, choices, context or final message in chat. Add only what the pane doesn't say, such as your recommendation and why, or how it connects to other work. @@ -29,12 +29,12 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Questions from task agents: answer one yourself with answer_question only when the answer is clear, for example the agent's recommendation is plainly right given what the human has said, the human already decided it, or one of your notes settles it outright. Otherwise show it to the human with your recommendation and wait. When the human tells you an answer, give it with answer_question. Say which questions you answered and how, naming the note by its id when one decided it. - Notes: you learn how the human works and keep it with remember, forget and list_notes. byre adds every note to your instructions at the start of each lead session, so they outlast compaction; the human can read and delete them in the Settings tab. Write a note when the human shows a priority, a preference or the reasoning behind a choice that will recur: an answer that departs from the agent's recommendation (the answered signal shows both), a correction or overrule of you or a task agent, or a statement of how they want things done. Record the choice and the why, in their terms, general enough to apply to the next case and specific enough to act on, for example "Prefers replacing a state label over adding a tag beside it: keeps the picker uncluttered." File each note under the project it is about with project_id when it concerns that project's code, UI, docs or process, and with no project_id only when it holds for how the human works everywhere; apply a project's notes only to that project's tasks. Task agents start with their creator's every-project notes and their project's, and suggest new ones with suggest_note, which reaches you as a signal: save a suggestion only when it holds and your notes don't already cover it, choosing its project yourself, and otherwise drop it; neither needs a word to the human. Don't note one-off facts about a single task, and don't duplicate: update an existing note with remember and its id instead. When the human contradicts a note, update it or forget it. Before recommending anything, check your notes, and when one shaped the recommendation, cite its id. - Finished turns: read them and show the human only what matters: work finished and ready to merge, problems, decisions made. Stay quiet about routine progress. -- Reviews: after each new snapshot of a task, a reviewer agent reviews the change, on the human's review model, or their second review model when the review model wrote the work. The results are on the task's reviews tab, and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. +- Reviews: after each new snapshot of a task, a reviewer agent reviews the change, on the human's review model, or their second review model when the review model wrote the work. The results are in the task's transcript, where its agent answers each finding with respond_to_finding (fixing, or ignoring with a reason), and read_transcript shows each review's findings. They are for the human to look at when they want; mention one only when its findings matter to what the human is deciding. - Dig into context with list_tasks, read_transcript, read_inbox and read_status (vendor rate limits and agent health), and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message. - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. - Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine. - Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. Every stage of a workflow runs as its own task on the stage's model, once the stages it comes after have finished, so stages fan out (several stages after one) and fan in (one stage after several); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and when the final stage finishes byre discards the run's other stage tasks, leaving the final one to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". -- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer, link to #/task/<task_id>/questions; at a finished turn, to #/task/<task_id>/transcript. +- Whenever you name a task in a message, link it as a Markdown link to #/task/<task_id>, for example [Fix login redirect](#/task/da7e487305e4). byre draws it in that task's colour, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task/<task_id>/<tab>. When you point the human at a question to answer or a finished turn, link to #/task/<task_id>/transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line <details><summary>Title</summary>, the Markdown content, then a line </details>. - You are long-running. Your context window is large. byre sends you a signal once your context passes half of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. - Don't commit, push, or touch git branches yourself.` @@ -416,7 +416,7 @@ func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (st return m.waitingText(ctx, owner, ev) } switch ev.Kind { - case "inbox.updated", "task.merge_started", "task.merged", "task.discarded": + case "task.merge_started", "task.merged", "task.discarded": default: return "", nil } @@ -431,9 +431,6 @@ func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (st if err != nil || finished == "" { return "", err } - if ev.Kind == "inbox.updated" && finished != inboxEmptied { - return "", nil - } return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), nil } diff --git a/internal/agents/leadtools.go b/internal/agents/leadtools.go index 583c201..faf56b4 100644 --- a/internal/agents/leadtools.go +++ b/internal/agents/leadtools.go @@ -457,17 +457,17 @@ func (m *Manager) leadTools(l *live) []mcp.Tool { tool("show", "Point the human's right pane at a task, or at a project with no task, and select one of its tabs. The tabs' order is fixed. "+ "Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+ - "Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), questions (the task's oldest open question), reviews (the task's reviews by reviewer agents), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ + "Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+ "Omit both task_id and project_id to clear the pane. "+ "If the pane already shows that task (or project) on that tab, nothing changes and you are told so. "+ "Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+ - "Otherwise refused while the human is working in the right pane (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task, or a questions tab with nothing left open),"+ + "Otherwise refused while the human is working in the right pane (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task),"+ "or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+ "Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab, and sends no signal otherwise.", `{"type": "object", "properties": { "task_id": {"type": "string"}, "project_id": {"type": "string", "description": "Ignored when task_id is given."}, - "tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "questions", "reviews", "workflow", "project"]} + "tab": {"type": "string", "enum": ["transcript", "board", "changes", "files", "terminal", "workflow", "project"]} }, "required": ["tab"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { f := api.Focus{} @@ -629,8 +629,6 @@ func sameView(a, b api.Focus) bool { return deref(a.ProjectId) == deref(b.ProjectId) } -const inboxEmptied = "has no open questions left" - func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) { if f.TaskId == nil { return "", nil @@ -645,17 +643,7 @@ func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) if task.Merging { return "is merging", nil } - if f.Tab != api.TabQuestions { - return "", nil - } - items, err := m.store.ListInbox(ctx, store.InboxFilter{State: api.Open, TaskID: task.Id}) - if err != nil { - return "", err - } - if len(items) > 0 { - return "", nil - } - return inboxEmptied, nil + return "", nil } const watchHold = 10 * time.Second diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 2f3e573..a98161a 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -747,7 +747,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api return nil, err } l.notes = "\n\n" + taskNotesGuidance + "\n\n" + notes - tools = append(tools, m.suggestNoteTool(l), m.readyForMergeTool(l)) + tools = append(tools, m.suggestNoteTool(l), m.readyForMergeTool(l), m.respondToFindingTool(l)) } if sess.Role == api.Implementer && sess.Member != "" { sessions, err := m.store.ListSessions(ctx, task.Id) diff --git a/internal/agents/review.go b/internal/agents/review.go index 63be255..2ce4533 100644 --- a/internal/agents/review.go +++ b/internal/agents/review.go @@ -364,9 +364,11 @@ func findingsMessage(r api.Review) string { res = *r.Result } b := &strings.Builder{} - fmt.Fprintf(b, "A reviewer looked at snapshot #%d of your work and raised the findings below. Reviewers can be wrong or miss context: check each one against the code and your brief, act on the ones that hold up, and in your final message say briefly which you acted on and which you're leaving and why.", r.SnapshotN) + fmt.Fprintf(b, "A reviewer looked at snapshot #%d of your work and raised the findings below. Reviewers can be wrong or miss context: check each one against the code and your brief. "+ + "Before you change anything, answer each finding with respond_to_finding (snapshot %d and the finding's number): fixing if it holds up and you'll act on it, ignoring with your reason if you're leaving it. "+ + "The human sees your answers on the findings and can overrule an ignore.", r.SnapshotN, r.SnapshotN) findings, _ := res["findings"].([]any) - for _, f := range findings { + for i, f := range findings { fm, _ := f.(map[string]any) where := "" if p, ok := fm["path"].(string); ok && p != "" { @@ -376,11 +378,74 @@ func findingsMessage(r api.Review) string { } where = " (`" + where + "`)" } - fmt.Fprintf(b, "\n\n**[%v] %v**%s\n\n%s", fm["severity"], fm["title"], where, strings.TrimSpace(fmt.Sprint(fm["detail"]))) + fmt.Fprintf(b, "\n\n**Finding %d: [%v] %v**%s\n\n%s", i+1, fm["severity"], fm["title"], where, strings.TrimSpace(fmt.Sprint(fm["detail"]))) } return b.String() } +type findingResponseArgs struct { + Snapshot int `json:"snapshot"` + Finding int `json:"finding"` + Response api.FindingResponseResponse `json:"response"` + Reason string `json:"reason"` +} + +func (m *Manager) respondToFindingTool(l *live) mcp.Tool { + return mcp.Tool{ + Name: "respond_to_finding", + Description: "Answer one finding of a review of your work, as soon as you've decided what to do about it: fixing when you'll act on it, ignoring when you're leaving it. " + + "The human sees your answer on the finding; an ignore keeps the human's fix button, so they can overrule you. Call it again to change your answer.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "snapshot": {"type": "integer", "description": "The reviewed snapshot's number."}, + "finding": {"type": "integer", "description": "The finding's number in the review, from 1."}, + "response": {"type": "string", "enum": ["fixing", "ignoring"]}, + "reason": {"type": "string", "description": "One line: what you'll do about it, or why you're leaving it."} + }, + "required": ["snapshot", "finding", "response", "reason"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + args := findingResponseArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed respond_to_finding call: %w", err) + } + if !args.Response.Valid() { + return "", fmt.Errorf("%w: response must be fixing or ignoring", ErrInvalid) + } + if args.Finding < 1 { + return "", fmt.Errorf("%w: finding numbers start at 1", ErrInvalid) + } + reason := strings.TrimSpace(args.Reason) + if reason == "" { + return "", fmt.Errorf("%w: reason must not be empty", ErrInvalid) + } + reviews, err := m.store.ListReviews(ctx, l.taskID) + if err != nil { + return "", err + } + reviewID := "" + for _, r := range reviews { + if r.SnapshotN == args.Snapshot && r.State == api.ReviewStateDone { + reviewID = r.Id + } + } + if reviewID == "" { + return "", fmt.Errorf("%w: snapshot %d has no finished review", ErrInvalid, args.Snapshot) + } + err = m.store.SetFindingResponse(ctx, reviewID, args.Finding-1, api.FindingResponse{Response: args.Response, Reason: reason}, agentActor) + if errors.Is(err, store.ErrNotFound) { + return "", fmt.Errorf("%w: the review of snapshot %d has no finding %d", ErrInvalid, args.Snapshot, args.Finding) + } + if err != nil { + return "", err + } + return fmt.Sprintf("Recorded: %s finding %d of snapshot %d's review.", args.Response, args.Finding, args.Snapshot), nil + }, + } +} + type reviewImage struct { entry map[string]any contentType string diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 10d8813..f4fb057 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -219,6 +219,24 @@ func (e FileChangeStatus) Valid() bool { } } +// Defines values for FindingResponseResponse. +const ( + FindingFixing FindingResponseResponse = "fixing" + FindingIgnoring FindingResponseResponse = "ignoring" +) + +// Valid indicates whether the value is a known member of the FindingResponseResponse enum. +func (e FindingResponseResponse) Valid() bool { + switch e { + case FindingFixing: + return true + case FindingIgnoring: + return true + default: + return false + } +} + // Defines values for FindingState. const ( FindingFixed FindingState = "fixed" @@ -471,8 +489,6 @@ const ( TabChanges TaskTab = "changes" TabFiles TaskTab = "files" TabProject TaskTab = "project" - TabQuestions TaskTab = "questions" - TabReviews TaskTab = "reviews" TabTerminal TaskTab = "terminal" TabTranscript TaskTab = "transcript" TabWorkflow TaskTab = "workflow" @@ -489,10 +505,6 @@ func (e TaskTab) Valid() bool { return true case TabProject: return true - case TabQuestions: - return true - case TabReviews: - return true case TabTerminal: return true case TabTranscript: @@ -853,6 +865,16 @@ type FileDiff struct { Path string `json:"path"` } +// FindingResponse defines model for FindingResponse. +type FindingResponse struct { + // Reason What the agent is doing about it, or why it leaves it. + Reason string `json:"reason"` + Response FindingResponseResponse `json:"response"` +} + +// FindingResponseResponse defines model for FindingResponse.Response. +type FindingResponseResponse string + // FindingState fixed: sent to the task's agent to fix. ignored: dismissed, and left out of the Merge warning. type FindingState string @@ -1069,6 +1091,9 @@ type Review struct { // Error failed: why. Error string `json:"error"` + // FindingResponses How the task's agent answered each finding with respond_to_finding, keyed by its index in result.findings; its latest answer wins. + FindingResponses map[string]FindingResponse `json:"finding_responses"` + // FindingStates How the human addressed each finding, keyed by its index in result.findings; a finding not listed is open. FindingStates map[string]FindingState `json:"finding_states"` FinishedAt *time.Time `json:"finished_at,omitempty"` @@ -3877,265 +3902,267 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", + "HCINH4Qony6V+RbN2Fmhe3Cf36tI0/v3/2cA", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/store/migrations/061-add-finding-responses-and-drop-questions-reviews-tabs.sql b/internal/store/migrations/061-add-finding-responses-and-drop-questions-reviews-tabs.sql new file mode 100644 index 0000000..ce5c595 --- /dev/null +++ b/internal/store/migrations/061-add-finding-responses-and-drop-questions-reviews-tabs.sql @@ -0,0 +1,4 @@ +ALTER TABLE reviews ADD COLUMN finding_responses TEXT NOT NULL DEFAULT '{}'; +UPDATE events SET payload = json_set(payload, '$.finding_responses', json('{}')) +WHERE kind IN ('review.started', 'review.finished', 'review.finding') AND json_type(payload, '$.finding_responses') IS NULL; +UPDATE users SET focus = json_set(focus, '$.tab', 'transcript') WHERE json_extract(focus, '$.tab') IN ('questions', 'reviews'); diff --git a/internal/store/review.go b/internal/store/review.go index 9ee0cb6..1ccdb76 100644 --- a/internal/store/review.go +++ b/internal/store/review.go @@ -10,7 +10,7 @@ import ( "g.fc.run/ai/byre/internal/api" ) -const reviewColumns = `id, task_id, snapshot_n, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at, finding_states` +const reviewColumns = `id, task_id, snapshot_n, coalesce(agent_session_id, ''), vendor, model, state, result, error, created_at, finished_at, finding_states, finding_responses` func (s *Store) CreateReview(ctx context.Context, r api.Review) error { finishedAt := "" @@ -110,6 +110,33 @@ func (s *Store) SetFindingState(ctx context.Context, id string, index int, state }) } +func (s *Store) SetFindingResponse(ctx context.Context, id string, index int, resp api.FindingResponse, actor string) error { + b, err := json.Marshal(resp) + if err != nil { + return err + } + return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + res, err := tx.ExecContext(ctx, ` + UPDATE reviews SET + finding_responses = json_set(finding_responses, '$."' || ?1 || '"', json(?2)), + finding_states = CASE WHEN json_extract(finding_states, '$."' || ?1 || '"') = ?3 + THEN json_remove(finding_states, '$."' || ?1 || '"') ELSE finding_states END + WHERE id = ?4 AND state = ?5 AND ?1 < json_array_length(nullif(result, ''), '$.findings') + `, index, string(b), string(api.FindingFixed), id, string(api.ReviewStateDone)) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return emitReview(ctx, tx, emit, "review.finding", id, actor) + }) +} + func (s *Store) ReviewerSessionIDs(ctx context.Context, taskID string) (map[string]bool, error) { rows, err := s.db.QueryContext(ctx, `SELECT id FROM agent_sessions WHERE task_id = ? AND role = ?`, taskID, string(api.Reviewer)) if err != nil { @@ -177,7 +204,8 @@ func scanReview(row scanner) (api.Review, error) { createdAt := "" finishedAt := "" findingStates := "" - err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &createdAt, &finishedAt, &findingStates) + findingResponses := "" + err := row.Scan(&r.Id, &r.TaskId, &r.SnapshotN, &r.AgentSessionId, &r.Vendor, &r.Model, &state, &result, &r.Error, &createdAt, &finishedAt, &findingStates, &findingResponses) if err != nil { return r, err } @@ -187,6 +215,11 @@ func scanReview(row scanner) (api.Review, error) { if err != nil { return r, err } + r.FindingResponses = map[string]api.FindingResponse{} + err = json.Unmarshal([]byte(findingResponses), &r.FindingResponses) + if err != nil { + return r, err + } if result != "" { res := map[string]any{} err = json.Unmarshal([]byte(result), &res) diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift index a7154ce..8e0ee75 100644 --- a/mac/Sources/Byre/Changes.swift +++ b/mac/Sources/Byre/Changes.swift @@ -38,7 +38,6 @@ func untilCancelled() async { @Observable final class ChangesModel { let store: Store - let compact: Bool private(set) var changes: TaskChanges? private(set) var error = "" private(set) var diffs: [String: Result<String, ApiError>] = [:] @@ -52,20 +51,19 @@ final class ChangesModel { @ObservationIgnored private var target = 0 @ObservationIgnored private var fetching: Set<String> = [] - init(_ store: Store, compact: Bool) { + init(_ store: Store) { self.store = store - self.compact = compact open = store.changesMemory.open range = store.changesMemory.range pinRange = store.changesMemory.pinRange } var pinned: Int? { - compact ? nil : store.pinned + store.pinned } var mode: ChangeMode { - compact ? .main : pinned != nil ? pinRange : range + pinned != nil ? pinRange : range } var empty: String { @@ -217,7 +215,6 @@ final class ChangesModel { } struct ChangesView: View { - var compact = false @Environment(Store.self) private var store @ViewState private var model: ChangesModel? @@ -225,14 +222,12 @@ struct ChangesView: View { VStack(spacing: 0) { if let model { ChangesHead(model: model) - if !compact { - ChangesList(model: model) - } + ChangesList(model: model) } } - .frame(maxHeight: compact ? nil : .infinity, alignment: .top) + .frame(maxHeight: .infinity, alignment: .top) .task(id: store.selectedTaskId) { - let m = model ?? ChangesModel(store, compact: compact) + let m = model ?? ChangesModel(store) model = m m.retarget() let off = store.selectedFeed?.appended.add { [weak m] events in @@ -243,9 +238,7 @@ struct ChangesView: View { m.stop() } .onChange(of: store.pinned) { - if !compact { - model?.retarget() - } + model?.retarget() } } } @@ -267,7 +260,7 @@ private struct ChangesHead: View { .textSelection(.enabled) } Spacer(minLength: 0) - if !model.compact, let taskId = store.selectedTaskId, let drafts = store.drafts { + if let taskId = store.selectedTaskId, let drafts = store.drafts { let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count if n > 0 { Button("Send \(n) \(n == 1 ? "comment" : "comments")") { @@ -317,29 +310,27 @@ private struct ChangesHead: View { @ViewBuilder private var toggle: some View { - if !model.compact { - HStack(spacing: 4) { - if model.pinned != nil { - RangeTabButton(label: "Since main", active: model.pinRange == .main) { - model.setPinRange(.main) - } - RangeTabButton(label: "This step", active: model.pinRange == .step) { - model.setPinRange(.step) - } - RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") { - store.pinSnapshot(nil) - } - } else { - RangeTabButton(label: "Since main", active: model.range == .main) { - model.setRange(.main) - } - RangeTabButton(label: "This turn", active: model.range == .turn) { - model.setRange(.turn) - } + HStack(spacing: 4) { + if model.pinned != nil { + RangeTabButton(label: "Since main", active: model.pinRange == .main) { + model.setPinRange(.main) + } + RangeTabButton(label: "This step", active: model.pinRange == .step) { + model.setPinRange(.step) + } + RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") { + store.pinSnapshot(nil) + } + } else { + RangeTabButton(label: "Since main", active: model.range == .main) { + model.setRange(.main) + } + RangeTabButton(label: "This turn", active: model.range == .turn) { + model.setRange(.turn) } } - .fixedSize() } + .fixedSize() } } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 0fa8ffe..423779e 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -7,6 +7,13 @@ enum ChatKind: Equatable { case task(String?) } +struct AttentionGroup: Identifiable { + let id: String + let label: String + let help: String + let rows: [Int] +} + @MainActor @Observable final class ChatModel { @@ -28,6 +35,7 @@ final class ChatModel { @ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken? @ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken? @ObservationIgnored private var started = false + @ObservationIgnored private var cursors: [String: Int] = [:] init(_ kind: ChatKind, _ store: Store) { self.kind = kind @@ -231,21 +239,112 @@ final class ChatModel { } func jump() { - guard let target = store.jump, target.taskId == taskId else { + guard let j = store.jump, j.taskId == taskId else { return } let rows = builder.root.rows - guard let i = rows.firstIndex(where: { $0.summarySeq == target.seq }) else { - return + switch j.target { + case .summary(let seq): + guard let i = rows.firstIndex(where: { $0.summarySeq == seq }) else { + return + } + store.jump = nil + stuck = false + switch i > 0 ? rows[i - 1].kind : nil { + case .agent, .subagentMessage: + requestScroll(rows[i - 1].id, .top) + default: + requestScroll(rows[i].id, .bottom) + } + case .question(let id): + guard let row = rows.first(where: { $0.question?.id == id }) else { + return + } + store.jump = nil + scrollTo(row.id) + case .review(let n): + guard let row = reviewRow(n) else { + return + } + store.jump = nil + scrollTo(row.id) } - store.jump = nil + } + + private func reviewRow(_ n: Int) -> Row? { + let rows = builder.root.rows + return rows.first { $0.reviewOf == n } ?? rows.first { r in + if case .snapshot(let s) = r.kind { + return s.n == n + } + return false + } + } + + func showReview(_ n: Int) { + if let row = reviewRow(n) { + scrollTo(row.id) + } + } + + func showFinding(_ reviewId: String, _ index: Int) { + let row = builder.root.rows.first { r in + if case .finding(let i) = r.kind { + return i == index && r.review?.id == reviewId + } + return false + } + if let row { + scrollTo(row.id) + } + } + + func attention() -> [AttentionGroup] { + guard let taskId else { + return [] + } + let latest = latestReview(store.transcript) + let active = store.findTask(taskId)?.status == .active + var questions: [Int] = [] + var open: [Int] = [] + var ready: [Int] = [] + for row in builder.root.rows where !row.hidden { + switch row.kind { + case .question where row.question?.state == .open: + questions.append(row.id) + case .finding(let i): + if let r = row.review, r.id == latest?.id, findingOpen(r, i) { + open.append(row.id) + } + case .ready where row.id == builder.lastReady && active: + ready.append(row.id) + default: + break + } + } + var groups: [AttentionGroup] = [] + if !questions.isEmpty { + groups.append(AttentionGroup(id: "questions", label: questions.count == 1 ? "1 question" : "\(questions.count) questions", help: "Scroll to the next open question", rows: questions)) + } + if !open.isEmpty { + groups.append(AttentionGroup(id: "findings", label: open.count == 1 ? "1 finding" : "\(open.count) findings", help: "Scroll to the next finding of the latest review that nobody has answered", rows: open)) + } + if !ready.isEmpty { + groups.append(AttentionGroup(id: "ready", label: "ready to merge", help: "Scroll to the agent's ready-for-merge overview", rows: ready)) + } + return groups + } + + func cycle(_ g: AttentionGroup) { + let next = (cursors[g.id] ?? -1) + 1 + let i = next < g.rows.count ? next : 0 + cursors[g.id] = i + scrollTo(g.rows[i]) + } + + private func scrollTo(_ id: Int) { stuck = false - switch i > 0 ? rows[i - 1].kind : nil { - case .agent, .subagentMessage: - requestScroll(rows[i - 1].id, .top) - default: - requestScroll(rows[i].id, .bottom) - } + requestScroll(id, .top) } private func requestScroll(_ id: Int, _ anchor: UnitPoint) { @@ -360,7 +459,7 @@ struct ChatView: View { .onDisappear { model?.stop() } - .onChange(of: store.jump?.seq) { + .onChange(of: store.jump) { model?.jump() } } @@ -702,6 +801,57 @@ private struct TranscriptScroll: View { Rectangle().stroke(running ? accent : .clear, lineWidth: 1) .animation(.easeInOut(duration: 0.3), value: running) } + .overlay(alignment: .topTrailing) { + AttentionBar(model: model) + } + } +} + +private struct AttentionBar: View { + let model: ChatModel + + var body: some View { + let groups = model.attention() + if !groups.isEmpty { + VStack(alignment: .trailing, spacing: 6) { + ForEach(groups) { g in + AttentionButton(group: g) { + model.cycle(g) + } + } + } + .padding(.top, 8) + .padding(.trailing, 14) + } + } +} + +private struct AttentionButton: View { + let group: AttentionGroup + let action: () -> Void + @ViewState private var hover = false + + var body: some View { + Button(action: action) { + Text(group.label) + .font(Fonts.font(11, weight: 700)) + .foregroundStyle(Palette.warn) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background { + ZStack { + Palette.bg + Palette.warnTint + } + .clipShape(Capsule()) + } + .overlay(Capsule().stroke(hover ? Palette.accent : Palette.warn.opacity(0.55))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .fixedSize() + .help("\(group.help); click again for the next") + .onHover { hover = $0 } } } @@ -1034,13 +1184,24 @@ struct RowView: View { 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) - } + VStack(alignment: .leading, spacing: 4) { + MarkdownView(blocks: blocks, ctx: md) + ReviewImages(value: row.review?.result?.value["images"] ?? nil) + } + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) + } } + case .finding(let index): + if let review = row.review { + FindingRow(review: review, index: index, taskId: row.taskId) + } + + case .findingResponse(let snapshot, let index, let response, let reason): + FindingResponseView(row: row, model: model, snapshot: snapshot, index: index, response: response, reason: reason) + case .ready(let when, let blocks): ReadyBubble(when: when, blocks: blocks, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged) @@ -1061,7 +1222,9 @@ struct RowView: View { } case .snapshot(let snap): - SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) + SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) { + model.showReview(snap.n) + } case .question: if let item = row.question { @@ -1073,39 +1236,34 @@ struct RowView: View { private struct QuestionBubble: View { let item: InboxItem - @Environment(Store.self) private var store - @ViewState private var hover = false var body: some View { let open = item.state == .open - let tone = open ? Palette.warn : Palette.muted - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text("asked") - WhenText(when: item.createdAt) + Group { + if open { + QuestionCard(item: item) + .id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)") + } else { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("asked") + WhenText(when: item.createdAt) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) + Text(status) + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } } - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) - MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) - Text(status) - .font(Fonts.font(11, weight: open ? 700 : 400)) - .foregroundStyle(tone) - .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .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 { - store.showQuestion(item.taskId, item.id) - } - .environment(\.choiceTap, { [store, item] in - store.showQuestion(item.taskId, item.id) - }) - .onHover { hover = $0 } - .help("Show this question in the Questions tab") + .overlay(RoundedRectangle(cornerRadius: 10).stroke(open ? Palette.warn.opacity(0.55) : Palette.border)) } private var status: String { @@ -1132,6 +1290,37 @@ private struct QuestionBubble: View { } } +private struct FindingResponseView: View { + let row: Row + let model: ChatModel + let snapshot: Int + let index: Int + let response: String + let reason: String + + var body: some View { + let title = row.review.flatMap { r in findings(r).indices.contains(index) ? str(findings(r)[index], "title") : nil } ?? "" + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(response) finding \(index + 1)\(title.isEmpty ? "" : ", \(title),") of snapshot #\(snapshot)'s review: \(reason)") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + if let review = row.review, findings(review).indices.contains(index) { + Button("show") { + model.showFinding(review.id, index) + } + .buttonStyle(SmallLinkButtonStyle()) + .help("Scroll to the finding") + if response == FindingResponse.ResponsePayload.ignoring.rawValue, let taskId = row.taskId { + FindingActions(taskId: taskId, review: review, index: index) + } + } + } + } +} + private struct ReadyBubble: View { let when: Date let blocks: [MDBlock] @@ -1328,6 +1517,7 @@ private struct SnapshotRow: View { let snap: Snapshot let review: Review? let taskId: String? + let showReview: () -> Void @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var forking = false @@ -1363,6 +1553,9 @@ private struct SnapshotRow: View { .help("Start a new task from this snapshot, its agent continuing the conversation as it stood here") if snap.trigger != .answer { reviewButton + if let review, review.state == .running { + StopReviewButton(reviewId: review.id) + } } Rectangle().fill(Palette.border).frame(height: 1) } @@ -1374,7 +1567,7 @@ private struct SnapshotRow: View { private var reviewButton: some View { let n = review.map(findingCount) ?? 0 return Button { - store.showReview(snap.n) + showReview() } label: { Text("review") .font(Fonts.font(11, weight: n > 0 ? 700 : 400)) @@ -1408,6 +1601,7 @@ private struct SnapshotRow: View { } } .buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, fill: review?.state == .running ? Palette.accentTint : .clear)) - .help(review?.state == .running ? "Review in progress; show snapshot \(snap.n)'s review in the Reviews tab" : "Show the review of snapshot \(snap.n) in the Reviews tab") + .disabled(review == nil || review?.state == .running) + .help(review?.state == .running ? "Review in progress" : review == nil ? "Not reviewed" : "Scroll to the review of snapshot \(snap.n)") } } diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 88f3408..f747a4d 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -4,7 +4,6 @@ import SwiftUI struct MenuEntry: Identifiable { let id: String let label: String - let badge: TabBadge let action: () -> Void } @@ -312,15 +311,12 @@ private struct MenuRow: View { var body: some View { Button(action: action) { - HStack(spacing: 6) { - Text(entry.label) - TabBadgeView(badge: entry.badge) - } - .padding(.horizontal, 12) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) + Text(entry.label) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { hover = $0 } diff --git a/mac/Sources/Byre/MergeDialog.swift b/mac/Sources/Byre/MergeDialog.swift index 51b1c8d..2e285b5 100644 --- a/mac/Sources/Byre/MergeDialog.swift +++ b/mac/Sources/Byre/MergeDialog.swift @@ -166,8 +166,7 @@ private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping var items: [MergeWarning] = [] let open: @MainActor () -> Void = { leave { - store.focusTask(taskId, .reviews) - store.pickReview(latest.n) + store.jumpTo(taskId, .review(latest.n)) } } let r = reviews.first { $0.snapshotN == latest.n } @@ -233,7 +232,7 @@ private struct MergeContent: View { let leave = model.leave warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: { leave { - store.focusTask(taskId, .questions) + store.showQuestion(taskId) } })) } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index e2ba139..baddf77 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -2,33 +2,12 @@ import AppKit import ByreAPI import SwiftUI -enum TabBadge { - case none - case count(Int, String) -} - -struct TabBadgeView: View { - let badge: TabBadge - - var body: some View { - switch badge { - case .none: - EmptyView() - case .count(let n, let help): - Badge(count: n) - .help(help) - } - } -} - let tabLabels: [TaskTab: String] = [ .transcript: "Transcript", .board: "Board", .changes: "Changes", .files: "Files", .terminal: "Terminal", - .questions: "Questions", - .reviews: "Reviews", .workflow: "Workflow", .project: "Project", ] @@ -94,30 +73,24 @@ struct MainView: View { struct PaneTabButton: View { let label: String var active = false - var attention = false - var badge = TabBadge.none let action: () -> Void @Environment(\.paneAccent) private var accent @ViewState private var hover = false var body: some View { Button(action: action) { - HStack(spacing: 6) { - Text(label) - TabBadgeView(badge: badge) - } - .font(Fonts.font(13, weight: attention ? 700 : 400)) - .foregroundStyle(hover ? Palette.text : attention ? Palette.warn : active ? Palette.text : Palette.muted) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .background(attention ? Palette.warnTint : .clear) - .overlay(alignment: .bottom) { - Rectangle() - .fill(active ? accent : attention ? Palette.warn : .clear) - .frame(height: 2) - } - .contentShape(Rectangle()) + Text(label) + .font(Fonts.font(13)) + .foregroundStyle(hover || active ? Palette.text : Palette.muted) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(alignment: .bottom) { + Rectangle() + .fill(active ? accent : .clear) + .frame(height: 2) + } + .contentShape(Rectangle()) } .buttonStyle(.plain) .fixedSize() @@ -196,7 +169,6 @@ private struct LeftPane: View { private struct TaskPane: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var seen: Set<String> = [] var body: some View { let selected = store.selectedTaskId.flatMap { store.findTask($0) } @@ -219,18 +191,6 @@ private struct TaskPane: View { } action: { frame in overlays.paneFrame = frame } - .onAppear { - seen = Set(store.inbox.map(\.id)) - } - .onChange(of: store.inbox.map(\.id)) { - let fresh = store.inbox.filter { !seen.contains($0.id) } - for item in fresh { - seen.insert(item.id) - } - if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.taskId == store.selectedTaskId }) { - store.focusTab(.questions) - } - } } @ViewBuilder @@ -247,10 +207,6 @@ private struct TaskPane: View { FilesView() case .terminal: TerminalTab() - case .questions: - QuestionsView() - case .reviews: - ReviewsView() case .workflow: WorkflowView() case .project: @@ -381,27 +337,10 @@ private struct TabStrip: View { private struct Info { var order: [TaskTab] var tab: TaskTab - var waiting: Int - var findings: Int - var reviewing: Bool - - func count(_ t: TaskTab) -> Int { - t == .questions ? waiting : t == .reviews && !reviewing ? findings : 0 - } - - func badge(_ t: TaskTab) -> TabBadge { - count(t) > 0 ? .count(count(t), t == .questions ? "open questions" :"open findings in the latest review") : .none - } } private func info() -> Info { let tab = store.focus.tab - let waiting = store.inbox.filter { $0.taskId == store.selectedTaskId }.count - let transcript = store.transcript - let latest = latestReview(transcript) - let reviewing = latest?.state == .running - let findings = latest.map(findingCount) ?? 0 - let hasReviews = transcript.contains(where: isReviewEvent) let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil let team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false let order = taskTabs.filter { t in @@ -410,13 +349,11 @@ private struct TabStrip: View { } switch t { case .board: return team - case .questions: return waiting > 0 - case .reviews: return hasReviews case .workflow: return workflow default: return true } } - return Info(order: order, tab: tab, waiting: waiting, findings: findings, reviewing: reviewing) + return Info(order: order, tab: tab) } private func kept(_ info: Info) -> [TaskTab] { @@ -444,17 +381,16 @@ private struct TabStrip: View { let info = info() let kept = kept(info) let hidden = info.order.filter { !kept.contains($0) } - let flagged = hidden.filter { info.count($0) > 0 } HStack(spacing: 4) { ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in - PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) { + PaneTabButton(label: tabLabels[t]!, active: t == info.tab) { store.focusTab(t) } } if !hidden.isEmpty { - MoreButton(badges: flagged.map(info.badge)) { + MoreButton { overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in - MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) { + MenuEntry(id: t.rawValue, label: tabLabels[t]!) { store.focusTab(t) } }) @@ -473,14 +409,14 @@ private struct TabStrip: View { .background { HStack(spacing: 4) { ForEach(info.order, id: \.self) { t in - PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {} + PaneTabButton(label: tabLabels[t]!, active: t == info.tab) {} .onGeometryChange(for: CGFloat.self) { g in g.size.width } action: { w in widths[t] = w } } - MoreButton(badges: []) {} + MoreButton {} .onGeometryChange(for: CGFloat.self) { g in g.size.width } action: { w in @@ -502,28 +438,18 @@ private struct TabStrip: View { } private struct MoreButton: View { - let badges: [TabBadge] let action: () -> Void @ViewState private var hover = false var body: some View { Button(action: action) { - HStack(spacing: 6) { - Text("More ▾") - ForEach(Array(badges.enumerated()), id: \.offset) { _, b in - TabBadgeView(badge: b) - } - } - .font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700)) - .foregroundStyle(hover ? Palette.text : badges.isEmpty ? Palette.muted : Palette.warn) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .background(badges.isEmpty ? .clear : Palette.warnTint) - .overlay(alignment: .bottom) { - Rectangle().fill(badges.isEmpty ? .clear : Palette.warn).frame(height: 2) - } - .contentShape(Rectangle()) + Text("More ▾") + .font(Fonts.font(13)) + .foregroundStyle(hover ? Palette.text : Palette.muted) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 6) + .contentShape(Rectangle()) } .buttonStyle(.plain) .fixedSize() diff --git a/mac/Sources/Byre/Questions.swift b/mac/Sources/Byre/Questions.swift index 0af2e2d..b309b4f 100644 --- a/mac/Sources/Byre/Questions.swift +++ b/mac/Sources/Byre/Questions.swift @@ -9,35 +9,6 @@ private let delegateText = "Use your judgment for this decision and continue wit private let otherStop = "\u{0}other" private let httpURL = Re(#"^https?://"#, caseless: true) -struct QuestionsView: View { - @Environment(Store.self) private var store - - var body: some View { - let taskId = store.selectedTaskId - let picked = store.question.flatMap { $0.taskId == taskId ? store.questionItem($0.itemId) : nil } - let shown = picked ?? store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first - VStack(spacing: 0) { - ChangesView(compact: true) - if taskId == nil { - EmptyText(text: "No task selected.") - } else if let shown { - let key = "\(shown.id)/\(shown.state.rawValue)/\(shown.responses.count)" - ScrollView { - QuestionCard(item: shown) - .id(key) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 16) - } - .id(shown.id) - } else { - EmptyText(text: "Nothing waiting on you in this task.") - } - } - .frame(maxHeight: .infinity, alignment: .top) - } -} - private func numberChoices(_ blocks: [MDBlock], _ next: inout Int) -> [MDBlock] { blocks.map { block in switch block { @@ -179,13 +150,6 @@ private final class CardModel { _ = try await store.api.client.respondInbox(body: .json(.init(responses: [r]))).ok store.drafts?.clearAnswer(item.id) store.paneFocus.setDraft(key, false) - if store.question?.itemId == item.id { - store.question = nil - } - let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id } - if !others && store.focus.tab == .questions && store.selectedTaskId == item.taskId { - store.focusTab(.transcript) - } } catch { store.images.restore(key, imgs) overlays.showAlert(errorText(error)) @@ -206,7 +170,7 @@ private final class CardModel { } } -private struct QuestionCard: View { +struct QuestionCard: View { let item: InboxItem @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift index 4cf63c9..e952f62 100644 --- a/mac/Sources/Byre/Reviews.swift +++ b/mac/Sources/Byre/Reviews.swift @@ -2,18 +2,10 @@ import ByreAPI import SwiftUI typealias FindingState = Components.Schemas.FindingState +typealias FindingResponse = Components.Schemas.FindingResponse private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#) -private func stateText(_ r: Review) -> String { - switch r.state { - case .running: "reviewing on \(r.vendor) (\(r.model))…" - case .done: reviewCounts(r) - case .failed: "failed" - case .skipped: "skipped: no changes" - } -} - @MainActor private enum SnapshotPatches { static var changes: [String: Task<TaskChanges, any Error>] = [:] @@ -78,160 +70,30 @@ private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: Pa return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 } } -struct ReviewsView: View { - @Environment(Store.self) private var store - @ViewState private var reviews: [Review]? - @ViewState private var error = "" - @ViewState private var target = 0 - - var body: some View { - VStack(spacing: 0) { - content - } - .frame(maxHeight: .infinity, alignment: .top) - .task(id: store.selectedTaskId) { - target += 1 - reviews = nil - error = "" - await load() - let off = store.selectedFeed?.appended.add { events in - if events.contains(where: isReviewEvent) { - Task { - await load() - } - } - } - await untilCancelled() - off?() - target += 1 - } - } - - private func load() async { - guard let taskId = store.selectedTaskId else { - return - } - target += 1 - let started = target - do { - let fresh = try await store.api.client.listReviews(path: .init(taskId: taskId)).ok.body.json - if started == target { - reviews = fresh - error = "" - } - } catch { - if started == target { - reviews = nil - self.error = errorText(error) - } - } - } - - @ViewBuilder - private var content: some View { - if let taskId = store.selectedTaskId { - if !error.isEmpty { - EmptyText(text: error, error: true) - } else if let reviews { - if let lastReview = reviews.last { - let n = store.reviewed ?? lastReview.snapshotN - let shown = reviews.last { $0.snapshotN == n } - let ns = Array(Set([n] + reviews.map(\.snapshotN))).sorted(by: >) - let next = ns.filter { $0 > n }.last - let prev = ns.first { $0 < n } - HStack(spacing: 8) { - Picker("", selection: Binding(get: { n }, set: { store.pickReview($0) })) { - ForEach(ns, id: \.self) { s in - Text("snapshot #\(s)").tag(s) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .help("Snapshot whose review to show") - step(prev ?? n - 1, "← #\(prev ?? n - 1)", ns) - step(next ?? n + 1, "#\(next ?? n + 1) →", ns) - } - .modifier(HeaderBar()) - ScrollView { - VStack(alignment: .leading, spacing: 12) { - if let shown { - ReviewCard(review: shown, taskId: taskId) - .id(shown.id) - } else { - Muted(text: "No review of snapshot #\(n) yet.") - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 16) - } - } else { - EmptyText(text: "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor.") - } - } else { - EmptyText(text: "Loading…") - } - } else { - EmptyText(text: "No task selected.") - } - } - - private func step(_ to: Int, _ label: String, _ ns: [Int]) -> some View { - Button(label) { - store.pickReview(to) - } - .buttonStyle(SmallLinkButtonStyle(color: Palette.text)) - .disabled(!ns.contains(to)) - } +func findingResponse(_ r: Review, _ index: Int) -> FindingResponse? { + r.findingResponses.additionalProperties[String(index)] } -struct ReviewCard: View { - let review: Review - let taskId: String? - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("snapshot #\(review.snapshotN)") - .font(Fonts.font(11, weight: 700)) - .foregroundStyle(Palette.text) - Text(stateText(review)) - .foregroundStyle(review.state == .failed ? Palette.warn : Palette.muted) - Spacer(minLength: 0) - Text(ago(review.createdAt)) - .help(isoText(review.createdAt)) - if review.state == .running { - StopReviewButton(reviewId: review.id) - } - } - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) - if review.state == .failed { - Text(review.error) - .foregroundStyle(Palette.danger) - .textSelection(.enabled) - } - if review.state == .done { - ReviewResult(review: review, taskId: taskId) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) - } +func findingOpen(_ r: Review, _ index: Int) -> Bool { + r.findingStates.additionalProperties[String(index)] == nil && findingResponse(r, index) == nil } -private struct StopReviewButton: View { +func findingWhere(_ f: JSONDict) -> String { + let path = str(f, "path") ?? "" + let line = max(0, int(f, "line") ?? 0) + let endRaw = int(f, "end_line") ?? 0 + let end = endRaw > line ? endRaw : line + return path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")" +} + +struct StopReviewButton: View { let reviewId: String @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var busy = false var body: some View { - Button("Stop") { + Button("stop") { busy = true Task { do { @@ -242,51 +104,32 @@ private struct StopReviewButton: View { } } } - .buttonStyle(WebButtonStyle(danger: true)) + .buttonStyle(SmallLinkButtonStyle(color: Palette.danger)) .disabled(busy) .help("End the reviewer and record this review as failed") } } -private struct ReviewResult: View { - let review: Review - let taskId: String? - - var body: some View { - let res = review.result?.value ?? [:] - let fs = findings(review) - let md = MDContext(taskId: taskId) - VStack(alignment: .leading, spacing: 8) { - MarkdownView(blocks: parseMarkdown(jsString(res["summary"] ?? "")), ctx: md) - ReviewImages(value: res["images"] ?? nil) - if !fs.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(fs.enumerated()), id: \.offset) { i, f in - FindingRow(review: review, index: i, finding: f, taskId: taskId) - } - } - } - } - } -} - -private struct FindingRow: View { +struct FindingRow: View { let review: Review let index: Int - let finding: JSONDict let taskId: String? var body: some View { - let f = finding + let f = findings(review)[index] let severity = jsString(f["severity"] ?? "") let path = str(f, "path") ?? "" let line = max(0, int(f, "line") ?? 0) let endRaw = int(f, "end_line") ?? 0 let end = endRaw > line ? endRaw : line - let whereText = path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")" + let whereText = findingWhere(f) let md = MDContext(taskId: taskId) + let response = findingResponse(review, index) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(index + 1).") + .font(Fonts.font(11, weight: 700)) + .foregroundStyle(Palette.muted) Text(severity) .font(Fonts.font(11, weight: 700)) .foregroundStyle(severity == "critical" || severity == "high" ? Palette.danger : severity == "medium" ? Palette.warn : Palette.muted) @@ -301,23 +144,33 @@ private struct FindingRow: View { } Spacer(minLength: 0) if let taskId { - FindingActions(taskId: taskId, review: review, index: index, finding: f, whereText: whereText) + FindingActions(taskId: taskId, review: review, index: index) } } - if let taskId, !path.isEmpty, line > 0 { - FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end) + if let response { + Text("agent \(response.response.rawValue): \(response.reason)") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) } - MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md) - ReviewImages(value: f["images"] ?? nil) + Group { + if let taskId, !path.isEmpty, line > 0 { + FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end) + } + MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md) + ReviewImages(value: f["images"] ?? nil) + } + .opacity(response == nil ? 1 : 0.5) } - .padding(.top, 6) - .overlay(alignment: .top) { - Rectangle().fill(Palette.border).frame(height: 1) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) } } } -private struct ReviewImages: View { +struct ReviewImages: View { let value: (any Sendable)? var body: some View { @@ -399,36 +252,42 @@ private struct DiffBox: ViewModifier { } } -private struct FindingActions: View { +struct FindingActions: View { let taskId: String let review: Review let index: Int - let finding: JSONDict - let whereText: String @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var done: FindingState? @ViewState private var busy = false var body: some View { - if let s = done ?? review.findingStates.additionalProperties[String(index)] { - Text(s == .fixed ? "fix sent" : "ignored") - .font(Fonts.font(11)) - .foregroundStyle(Palette.muted) - } else { - HStack(spacing: 6) { - Button("fix") { - fix() + let response = findingResponse(review, index)?.response + let state = review.findingStates.additionalProperties[String(index)] + Group { + if let state { + Text(state == .fixed ? "fix sent" : "ignored") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + } else if response != .fixing { + HStack(spacing: 6) { + Button("fix") { + fix() + } + .buttonStyle(SmallLinkButtonStyle()) + .help(response == .ignoring ? "Overrule the agent: send this finding to it to fix" : "Send this finding to the task's agent to fix") + if response == nil { + Button("ignore") { + ignore() + } + .buttonStyle(SmallLinkButtonStyle()) + .help("Mark this finding done without fixing it; Merge won't warn about it") + } } - .buttonStyle(SmallLinkButtonStyle()) - .help("Send this finding to the task's agent to fix") - Button("ignore") { - ignore() - } - .buttonStyle(SmallLinkButtonStyle()) - .help("Mark this finding done without fixing it; Merge won't warn about it") + .disabled(busy) } - .disabled(busy) + } + .onChange(of: state) { + busy = false } } @@ -437,8 +296,9 @@ private struct FindingActions: View { } private func fix() { - let f = finding - let text = "Fix this finding from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))" + let f = findings(review)[index] + let whereText = findingWhere(f) + let text = "Fix finding \(index + 1) from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))" busy = true Task { do { @@ -448,10 +308,10 @@ private struct FindingActions: View { overlays.showAlert(errorText(error)) return } - done = .fixed do { try await mark(.fixed) } catch { + busy = false overlays.showAlert("fix sent, but not recorded: \(errorText(error))") } } @@ -462,7 +322,6 @@ private struct FindingActions: View { Task { do { try await mark(.ignored) - done = .ignored } catch { busy = false overlays.showAlert(errorText(error)) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index fa6f444..6c77722 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -2,7 +2,19 @@ import Foundation import Observation import SwiftData -let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .questions, .reviews, .workflow, .project] +let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .workflow, .project] + +enum JumpTarget: Equatable { + case summary(Int64) + case question(String) + case review(Int) +} + +struct Jump: Equatable { + let taskId: String + let target: JumpTarget + let n: Int +} private let historyPage = 1000 private let leadPage = 50 @@ -36,13 +48,12 @@ final class Store { var inbox: [InboxItem] = [] private(set) var selectedTaskId: String? var pinned: Int? - var reviewed: Int? - var question: (taskId: String, itemId: String)? var connected = false private(set) var focus = Focus(tab: .transcript) var leadSessions: [AgentSession] = [] let leadFeed: Feed - var jump: (taskId: String, seq: Int64)? + var jump: Jump? + @ObservationIgnored private var jumps = 0 var composerFocus: String? var summaryReads: [String: Int64] = [:] private(set) var leadMore = false @@ -224,28 +235,21 @@ final class Store { pinned = n } - func pickReview(_ n: Int) { - reviewed = n + func jumpTo(_ taskId: String, _ target: JumpTarget) { + jumps += 1 + jump = Jump(taskId: taskId, target: target, n: jumps) + focusTask(taskId, .transcript) } - func showReview(_ n: Int) { - pickReview(n) - focusTab(.reviews) - } - - func showQuestion(_ taskId: String, _ itemId: String) { - focusTask(taskId, .questions) - question = (taskId, itemId) - } - - func questionItem(_ itemId: String) -> InboxItem? { - if let open = inbox.first(where: { $0.id == itemId }) { - return open + func showQuestion(_ taskId: String) { + guard let item = inbox.filter({ $0.taskId == taskId }).min(by: { $0.createdAt < $1.createdAt }) else { + focusTask(taskId, .transcript) + return } - guard let ev = transcript.last(where: { ($0.kind == "inbox.created" || $0.kind == "inbox.updated") && str($0.payload.value, "id") == itemId }) else { - return nil + if let member = sessions[taskId]?.first(where: { $0.id == item.agentSessionId })?.member, !member.isEmpty { + pickedMembers[taskId] = member } - return try! decodePayload(ev.payload, as: InboxItem.self) + jumpTo(taskId, .question(item.id)) } var defaultModel: String { @@ -360,7 +364,6 @@ final class Store { } func focusTask(_ taskId: String, _ tab: TaskTab = .transcript) { - question = nil spawn("set focus") { try await self.setFocus(taskId: taskId, tab: tab) } @@ -373,7 +376,6 @@ final class Store { } func focusTab(_ tab: TaskTab) { - question = nil spawn("set focus") { try await self.setFocus(tab: tab) } @@ -386,7 +388,6 @@ final class Store { let name = m?[3] ?? nil let tab = taskTabs.first { $0.rawValue == name } if kind == "task", let id { - question = nil if id != focus.taskId || (tab != nil && tab != focus.tab) { spawn("set focus") { try await self.setFocus(taskId: id, tab: tab) @@ -507,8 +508,7 @@ final class Store { let seq = task.summarySeq if let ev = taskFeeds[task.id]?.events.first(where: { $0.seq == seq }) { pickMember(task.id, ev) - jump = (task.id, seq) - focusTask(task.id, .transcript) + jumpTo(task.id, .summary(seq)) return } Task { @@ -520,8 +520,7 @@ final class Store { } catch { log.error("load summary event: \(errorText(error), privacy: .public)") } - jump = (task.id, seq) - focusTask(task.id, .transcript) + jumpTo(task.id, .summary(seq)) } } @@ -539,7 +538,6 @@ final class Store { _ = taskFeed(taskId) } pinned = nil - reviewed = nil dropClosedFeeds() } diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 63abce3..63f9022 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -262,7 +262,7 @@ final class SwitcherModel { private func go(_ taskId: String) { close() if store.inbox.contains(where: { $0.taskId == taskId }) { - store.focusTask(taskId, .questions) + store.showQuestion(taskId) } else if let task = store.findTask(taskId), store.unreadSummary(task) { store.composerFocus = taskId store.openSummary(task) diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index cec9fdf..7e0a00e 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -51,6 +51,8 @@ enum RowKind { case turnEnd(error: String?) case note(Note) case review(note: Note, md: [MDBlock]) + case finding(index: Int) + case findingResponse(snapshot: Int, index: Int, response: String, reason: String) case ready(when: Date, md: [MDBlock]) case handoff case snapshot(Snapshot) @@ -84,6 +86,7 @@ final class Row: Identifiable { var calls: [Call] = [] var summary: Note? var review: Review? + var reviewOf: Int? var question: InboxItem? var text = "" var md: [MDBlock] = [] @@ -175,6 +178,8 @@ private final class Ctx { var subagents: [String: Subagent] = [:] var reviewers: Set<String> = [] var reviewButtons: [Int: Row] = [:] + var reviews: [Int: Review] = [:] + var reviewRows: [String: [Row]] = [:] var questions: [String: Row] = [:] var run: Run? var held: RowList? @@ -471,6 +476,17 @@ final class TranscriptBuilder { if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" { let r = try! decodePayload(ev.payload, as: Review.self) ctx.reviewButtons[r.snapshotN]?.review = r + ctx.reviews[r.snapshotN] = r + for row in ctx.reviewRows[r.id] ?? [] { + row.review = r + } + if ev.kind == "review.finished" { + ctx.run = nil + for row in renderReview(r, ev.createdAt) { + put(row) + } + continue + } } if ev.kind == "inbox.updated" { let item = try! decodePayload(ev.payload, as: InboxItem.self) @@ -490,6 +506,20 @@ final class TranscriptBuilder { put(row, keepHeld: true) continue } + if ev.kind == "agent.tool_call" && sub == nil && str(p, "name") == "mcp__byre__respond_to_finding" { + let input = dict(p, "input") ?? [:] + let n = int(input, "snapshot") ?? 0 + let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: taskId) + row.started = ev.createdAt + ctx.tools[jsString(p["id"])] = row + if let r = ctx.reviews[n], r.state == .done { + row.review = r + ctx.reviewRows[r.id, default: []].append(row) + } + ctx.run = nil + put(row, keepHeld: true) + continue + } if ev.kind == "agent.tool_call" { let name = toolLabel(jsString(p["name"])) let row = renderToolCall(p) @@ -605,6 +635,22 @@ final class TranscriptBuilder { return row } + private func renderReview(_ r: Review, _ when: Date) -> [Row] { + let outcome = r.state == .failed ? "failed: \(r.error)" : r.state == .skipped ? "skipped: no changes" : reviewCounts(r) + let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")] + let fs = findings(r) + let head = fs.isEmpty + ? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: taskId) + : Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId) + let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: taskId) } + for row in rows { + row.review = r + row.reviewOf = r.snapshotN + } + ctx.reviewRows[r.id, default: []] += rows + return rows + } + private func renderEvent(_ ev: ByreEvent) -> Row? { let p = ev.payload.value let when = ev.createdAt @@ -654,6 +700,9 @@ final class TranscriptBuilder { return Row(.orphan(body), taskId: taskId) } target.result = body + if case .findingResponse = target.kind, bool(p, "is_error") { + target.hidden = true + } return nil case "turn.ended": @@ -709,15 +758,6 @@ final class TranscriptBuilder { case "task.merge_title": return note([.text("\(ev.actor) set the merge commit title to \"\(jsString(p["merge_title"]))\"")]) - 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) - 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")]) @@ -778,7 +818,7 @@ func findings(_ r: Review) -> [JSONDict] { } func findingCount(_ r: Review) -> Int { - r.state == .done ? findings(r).indices.filter { r.findingStates.additionalProperties[String($0)] == nil }.count : 0 + r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0 } func latestReview(_ events: [ByreEvent]) -> Review? {