Stop the macOS transcript jumping while scrolling
This commit is contained in:
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user