Stop the macOS transcript jumping while scrolling

This commit is contained in:
Ian Gulliver
2026-10-17 10:14:06 -07:00
parent 1808ca8be6
commit ad458b3a8b
3 changed files with 38 additions and 16 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ Closing the window leaves the app running with its store and streams (`applicati
Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states. Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states.
- **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as 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 notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up, with pending sends shown at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips. - **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as 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 notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up. While the user is scrolling (`onScrollPhaseChange`), content height changes from rows the `LazyVStack` lays out late only update whether it is stuck and never pull it back to the bottom. 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 the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips.
- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), 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 up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), 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 up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error.
- **Inbox.** 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, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it. - **Inbox.** 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, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it.
- **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, 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. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, 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.
+9 -5
View File
@@ -495,6 +495,7 @@ private struct TranscriptScroll: View {
@Environment(Store.self) private var store @Environment(Store.self) private var store
@Environment(\.paneAccent) private var accent @Environment(\.paneAccent) private var accent
@ViewState private var position = ScrollPosition(edge: .bottom) @ViewState private var position = ScrollPosition(edge: .bottom)
@ViewState private var scrolling = false
var body: some View { var body: some View {
let running = model.running let running = model.running
@@ -536,13 +537,16 @@ private struct TranscriptScroll: View {
.onScrollGeometryChange(for: ScrollMetrics.self) { g in .onScrollGeometryChange(for: ScrollMetrics.self) { g in
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY) ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY)
} action: { old, new in } action: { old, new in
if new.content != old.content || new.visible != old.visible { if scrolling {
if model.stuck && new.distance > 1 { model.stuck = new.distance < 80
position.scrollTo(edge: .bottom)
}
return return
} }
model.stuck = new.distance < 80 if model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) {
position.scrollTo(edge: .bottom)
}
}
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
} }
.onChange(of: model.builder.pending.count) { .onChange(of: model.builder.pending.count) {
model.stuck = true model.stuck = true
+28 -10
View File
@@ -425,16 +425,7 @@ private struct FlowText: NSViewRepresentable {
} }
func sizeThatFits(_ proposal: ProposedViewSize, nsView v: FlowTextView, context: Context) -> CGSize? { func sizeThatFits(_ proposal: ProposedViewSize, nsView v: FlowTextView, context: Context) -> CGSize? {
let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } context.coordinator.size(text, proposal.width.flatMap { $0.isFinite ? $0 : nil })
let storage = NSTextStorage(attributedString: text)
let layout = NSLayoutManager()
let container = NSTextContainer(size: CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
layout.addTextContainer(container)
storage.addLayoutManager(layout)
layout.ensureLayout(for: container)
let used = layout.usedRect(for: container)
return CGSize(width: width ?? ceil(used.width), height: ceil(used.height))
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
@@ -443,6 +434,33 @@ private struct FlowText: NSViewRepresentable {
final class Coordinator: NSObject, NSTextViewDelegate { final class Coordinator: NSObject, NSTextViewDelegate {
var openURL: OpenURLAction? var openURL: OpenURLAction?
private let storage = NSTextStorage()
private let layout = NSLayoutManager()
private let container = NSTextContainer()
private var measured: (text: NSAttributedString, width: CGFloat?, size: CGSize)?
override init() {
super.init()
container.lineFragmentPadding = 0
layout.addTextContainer(container)
storage.addLayoutManager(layout)
}
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 {
return m.size
}
if !same {
storage.setAttributedString(text)
}
container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude)
layout.ensureLayout(for: container)
let used = layout.usedRect(for: container)
let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height))
measured = (text, width, size)
return size
}
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
guard let url = link as? URL, let openURL else { guard let url = link as? URL, let openURL else {