diff --git a/docs/operations.md b/docs/operations.md index 2e317d0..d1c777b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -190,7 +190,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the page loads every active task's whole history when it starts (see "Events"), so this is what a page load costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. -Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. 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 and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything; it is pure CSS (`:has(.tool-result.pending)` under `.transcript.running`). When a turn ends, 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. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is `.active`, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero (`.transcript.subagents`), 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 (`focusComposer` in `web/src/state.ts`, `Store.composerFocus` in the macOS app). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. +Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. The macOS app draws the scanner as an overlay on the transcript's scroll view rather than inside its content, at the view's bottom-left (16pt in, 8pt up) and pushed down by the scroll view's distance from the bottom (`ChatModel.scannerDrop`, set from `onScrollGeometryChange` and read only by `ScannerOverlay`), so it moves with the transcript and sits in the same place as on the web; its per-frame redraw then repaints only the scanner, where inside the scroll content each frame re-rendered the whole transcript and made long transcripts scroll slowly. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. 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 and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything; it is pure CSS (`:has(.tool-result.pending)` under `.transcript.running`). When a turn ends, 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. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is `.active`, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero (`.transcript.subagents`), 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 (`focusComposer` in `web/src/state.ts`, `Store.composerFocus` in the macOS app). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. **Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `web/src/drafts.ts` and mirrored to the browser's localStorage under `byre.drafts.`, one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced and flushed at once when a composer or answer box loses focus, when the transcript view is disposed, and on `pagehide`. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a reload mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A storage read that throws or holds malformed JSON (or entries of the wrong shape) is ignored, and a write that throws is logged; drafts then live only in memory. Pending images are not stored, only their count: after a reload the composer shows how many need re-attaching, until dismissed or the next successful send. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 26a4e27..e205c9f 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -1,6 +1,5 @@ import AppKit import ByreAPI -import os import SwiftUI enum ChatKind: Equatable { @@ -18,6 +17,7 @@ final class ChatModel { private(set) var fetching = false private(set) var olderGeneration = 0 private(set) var hasUnmerged = false + var scannerDrop: CGFloat = 0 @ObservationIgnored var stuck = true @ObservationIgnored private var unmergedTimer: Task? @ObservationIgnored private var offs: [() -> Void] = [] @@ -486,13 +486,10 @@ struct CompactButton: View { } } -let scrollLog = Logger(subsystem: "run.fc.byre.mac", category: "scroll") - private struct ScrollMetrics: Equatable { let content: CGFloat let visible: CGFloat let distance: CGFloat - let offset: CGFloat } private struct TranscriptScroll: View { @@ -532,44 +529,39 @@ private struct TranscriptScroll: View { } } .scrollTargetLayout() - .overlay(alignment: .bottomLeading) { - if running && model.key != nil { - Scanner(color: accent) - .offset(y: 8) - } - } .padding(16) } + .overlay(alignment: .bottomLeading) { + if running && model.key != nil { + ScannerOverlay(model: model, color: accent) + } + } .scrollPosition($position) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollMetrics.self) { g in - ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y) + ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY) } action: { old, new in - let snap = !scrolling && model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) - scrollLog.notice("geom rows=\(model.builder.root.rows.count) content=\(new.content) dContent=\(new.content - old.content) offset=\(new.offset) dOffset=\(new.offset - old.offset) visible=\(new.visible) distance=\(new.distance) scrolling=\(scrolling) stuck=\(model.stuck) snap=\(snap)") + model.scannerDrop = new.distance if scrolling { model.stuck = new.distance < 80 return } - if snap { + if model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) { position.scrollTo(edge: .bottom) } } - .onScrollPhaseChange { old, phase in - scrollLog.notice("phase \(String(describing: old), privacy: .public) -> \(String(describing: phase), privacy: .public)") + .onScrollPhaseChange { _, phase in scrolling = phase.isScrolling } .onChange(of: model.builder.pending.last?.id) { old, new in guard let new, new > old ?? 0 else { return } - scrollLog.notice("pending scrollTo bottom") model.stuck = true position.scrollTo(edge: .bottom) } .onChange(of: model.scrollRequest?.seq, initial: true) { if let r = model.scrollRequest { - scrollLog.notice("request scrollTo \(String(describing: r.id), privacy: .public)") position.scrollTo(id: r.id, anchor: r.anchor) } } @@ -580,6 +572,21 @@ private struct TranscriptScroll: View { } } +private struct ScannerOverlay: View { + let model: ChatModel + let color: Color + + var body: some View { + Scanner(color: color) + .padding(.leading, 16) + .padding(.bottom, 8) + .offset(y: model.scannerDrop) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .clipped() + .allowsHitTesting(false) + } +} + private struct Scanner: View { let color: Color diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 0f13353..6feef36 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -1,4 +1,3 @@ -import os import SwiftUI enum MDSegment { @@ -536,10 +535,8 @@ private struct FlowText: NSViewRepresentable { func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false if same, let m = measured, m.width == width { - scrollLog.notice("measure hit len=\(text.length) width=\(width ?? -1) height=\(m.size.height)") return m.size } - let start = ContinuousClock.now if !same { storage.setAttributedString(text) } @@ -547,7 +544,6 @@ private struct FlowText: NSViewRepresentable { layout.ensureLayout(for: container) let used = layout.usedRect(for: container) let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) - scrollLog.notice("measure miss len=\(text.length) width=\(width ?? -1) oldWidth=\(self.measured?.width ?? -1) newText=\(!same) height=\(size.height) ms=\((ContinuousClock.now - start) / .milliseconds(1))") measured = (text, width, size) return size }