diff --git a/docs/macos.md b/docs/macos.md index b9d57a1..982c067 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -97,7 +97,7 @@ 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 (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; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. 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, and when one of those rows closes the transcript scrolls to the next remaining one (`ChatModel.closed`). 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; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. 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.** 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. diff --git a/docs/operations.md b/docs/operations.md index f6b37e0..7842819 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -297,7 +297,7 @@ Agents ask questions with the `ask_human` MCP tool. Each question carries a `con `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. Where the transcript would follow the bottom, it follows only until the first of these rows in transcript order (`ChatModel.held`) reaches the top, and stays there as rows arrive; so a transcript opens at that row, and one that a new question arrives in stops with the question at the top. Scrolling by hand or sending a message lets that row go (`ChatModel.release`), and the transcript follows the bottom again until a different row becomes 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. +**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. Where the transcript would follow the bottom, it follows only until the first of these rows in transcript order (`ChatModel.held`) reaches the top, and stays there as rows arrive; so a transcript opens at that row, and one that a new question arrives in stops with the question at the top. Scrolling by hand or sending a message lets that row go (`ChatModel.release`), and the transcript follows the bottom again until a different row becomes the first. A button goes away once nothing of its kind is left. When one of these rows stops needing the user (a question answered, a finding answered, the overview no longer current) and others remain, the transcript scrolls to the next remaining one after it in transcript order, wrapping to the first (`ChatModel.closed`). 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: diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 2a1f187..bed6563 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -337,14 +337,27 @@ final class ChatModel { return groups } - var held: Int? { + var attentionRows: [Int] { let ids = Set(attention().flatMap(\.rows)) - guard let first = builder.root.rows.first(where: { ids.contains($0.id) })?.id, first != released else { + return builder.root.rows.filter { ids.contains($0.id) }.map(\.id) + } + + var held: Int? { + guard let first = attentionRows.first, first != released else { return nil } return first } + func closed(_ old: [Int], _ new: [Int]) { + guard let gone = old.first(where: { !new.contains($0) }), let first = new.first else { + return + } + let rows = builder.root.rows + let at = rows.firstIndex { $0.id == gone } ?? rows.endIndex + scrollTo(rows[at...].first { new.contains($0.id) }?.id ?? first) + } + func release() { released = held ?? released } @@ -812,6 +825,9 @@ private struct TranscriptScroll: View { position.scrollTo(edge: .bottom) } } + .onChange(of: model.attentionRows) { old, new in + model.closed(old, new) + } .onChange(of: model.older == nil) { if !scrolling { model.prependOlder()