eleven / ocho Public

mac: the feed stops moving under the reader's hands (#1484)

Two symptoms, one cause: the feed's self-correcting scroll loops move the
offset without knowing anyone is holding it.

- The prepend re-anchor read "where the reader is looking" BEFORE
  `await model.loadOlder()` — a network round-trip plus a decrypt — and
  applied it after. Anything scrolled or dragged through the fetch was
  undone: the pane scrolling itself back a screenful for no visible
  reason. It now restores the reading taken at the last moment the reader
  actually moved the feed, kept current by the event monitors.
- Nothing knew a selection drag was live. AppKit autoscrolls a drag with
  no scroll events at all, so a pin or a restore landing mid-drag jumped
  the content out from under the pointer and the drag read as a scroll
  instead of a selection. A drag monitor (watching only — every event
  passes through) tells the loops when the reader's hands are on the
  feed; pinUntilSettled waits for them to come off (bounded), the
  restore verify concedes the position, and the typing-bubble scroll
  stands down. A WEDGED feed is exempt: a blank pane has nothing on it
  to be reading.

The centre-row arithmetic both callers share moves to LChatCore as
FeedGeometry, with tests — a drifting reading is a jumping feed.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 3b395c84b62554e409a1d1e90c3790d1efe2b38c parent 79f0069
3 files changed, +278 −32
  • ios/LChatCore/Sources/LChatCore/FeedGeometry.swift +55 −0
  • ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift +86 −0
  • ios/LChatMac/NativeFeedView.swift +137 −32
diff --git a/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift b/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift
new file mode 100644
index 0000000..9373000
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift
@@ -0,0 +1,55 @@
+import CoreGraphics
+
+// 🤖 The feed's scroll-position arithmetic, pulled out of the view so it can be
+// tested without mounting one. Two callers on the Mac feed
+// (LChatMac/NativeFeedView.swift) share it: the per-thread scroll memory, and
+// the re-anchor that keeps the viewport still while older history splices in
+// above it. Both need the SAME reading — "which row is the reader looking at,
+// and where in the viewport does it sit" — so it lives in one place.
+public enum FeedGeometry {
+
+ // The row nearest the viewport's centre, plus its top's 0…1 travel within
+ // the viewport: scrollTo(rowID, anchor: .init(x: 0, y: fraction)) puts it
+ // back exactly where it was.
+ //
+ // `rows` are the materialised rows' frames in the scroll content's
+ // coordinate space (the feed's named space, so minY is measured from the
+ // viewport's top: a row on screen has 0 <= minY <= viewportHeight).
+ //
+ // Prefers a FULLY visible row — a partially-visible one's fraction is
+ // outside 0…1 and can't be expressed as a UnitPoint. When none is (one
+ // bubble taller than the viewport — a long code block — fills the screen)
+ // it anchors that giant row's top, which is the closest honest reading:
+ // scrollTo can't say "this row's top, minus 300pt".
+ public static func centreRow(rows: [Int64: CGRect],
+ viewportHeight: CGFloat) -> NativeFeedModelCache.FeedScrollAnchor? {
+ guard viewportHeight > 0, !rows.isEmpty else { return nil }
+ let mid = viewportHeight / 2
+ guard let ref = rows
+ .filter({ $0.value.minY >= 0 && $0.value.maxY <= viewportHeight })
+ .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) })
+ else {
+ // Deterministic pick: several rows can straddle the centre line
+ // only if they overlap, but `first(where:)` over a dictionary is
+ // order-dependent, so choose the topmost.
+ if let giant = rows
+ .filter({ $0.value.minY < mid && $0.value.maxY > mid })
+ .min(by: { $0.value.minY < $1.value.minY }) {
+ return .init(rowID: giant.key, fraction: 0)
+ }
+ return nil
+ }
+ let denom = viewportHeight - ref.value.height
+ let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0
+ return .init(rowID: ref.key, fraction: fraction)
+ }
+
+ // Whether the newest row is close enough to the bottom to count as "the
+ // reader is at the bottom" — the gate on following new arrivals, and (in
+ // the scroll memory) on storing no anchor at all.
+ public static func isNearBottom(lastRow: CGRect?, viewportHeight: CGFloat,
+ slack: CGFloat = 150) -> Bool {
+ guard let lastRow else { return false }
+ return lastRow.maxY <= viewportHeight + slack
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift
new file mode 100644
index 0000000..ec5c475
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift
@@ -0,0 +1,86 @@
+import CoreGraphics
+import XCTest
+@testable import LChatCore
+
+// 🤖 The Mac feed's scroll-position arithmetic (FeedGeometry): the reading the
+// per-thread scroll memory stores and the prepend re-anchor restores. Pinned
+// here because both callers must agree — a drifting reading is a jumping feed.
+final class FeedGeometryTests: XCTestCase {
+
+ // A stack of equal rows laid out from `top`, keyed 1…n.
+ private func rows(count: Int, height: CGFloat, top: CGFloat) -> [Int64: CGRect] {
+ var out: [Int64: CGRect] = [:]
+ for i in 0..<count {
+ out[Int64(i + 1)] = CGRect(x: 0, y: top + CGFloat(i) * height,
+ width: 400, height: height)
+ }
+ return out
+ }
+
+ func testPicksTheRowNearestTheViewportCentre() {
+ // 7 × 100pt rows from y=50, viewport 800 → centre 400. Row 4 spans
+ // 350…450, its midY exactly on the centre line: an unambiguous pick
+ // (a tie would only pin dictionary order, which is nothing to pin).
+ let r = rows(count: 7, height: 100, top: 50)
+ XCTAssertEqual(FeedGeometry.centreRow(rows: r, viewportHeight: 800)?.rowID, 4)
+ }
+
+ func testFractionRoundTripsThroughTheViewport() {
+ // One row at the very top of a viewport it doesn't fill → fraction 0.
+ let top: [Int64: CGRect] = [1: CGRect(x: 0, y: 0, width: 400, height: 100)]
+ XCTAssertEqual(FeedGeometry.centreRow(rows: top, viewportHeight: 500)?.fraction, 0)
+
+ // The same row resting on the bottom edge → fraction 1.
+ let bottom: [Int64: CGRect] = [1: CGRect(x: 0, y: 400, width: 400, height: 100)]
+ XCTAssertEqual(FeedGeometry.centreRow(rows: bottom, viewportHeight: 500)?.fraction, 1)
+
+ // Half way down → 0.5 of the travel (viewport 500 − row 100 = 400).
+ let mid: [Int64: CGRect] = [1: CGRect(x: 0, y: 200, width: 400, height: 100)]
+ XCTAssertEqual(FeedGeometry.centreRow(rows: mid, viewportHeight: 500)?.fraction ?? 0,
+ 0.5, accuracy: 0.0001)
+ }
+
+ func testIgnoresRowsOnlyPartlyOnScreen() {
+ // Row 1 straddles the top edge and row 3 the bottom; only the
+ // fully-visible row 2 is a valid anchor.
+ let r: [Int64: CGRect] = [
+ 1: CGRect(x: 0, y: -60, width: 400, height: 100),
+ 2: CGRect(x: 0, y: 40, width: 400, height: 100),
+ 3: CGRect(x: 0, y: 460, width: 400, height: 100),
+ ]
+ // Viewport 500: row 2 (40…140) is fully visible, row 3 (460…560) is not.
+ XCTAssertEqual(FeedGeometry.centreRow(rows: r, viewportHeight: 500)?.rowID, 2)
+ }
+
+ func testGiantRowFillingTheScreenAnchorsItsTop() {
+ // A code block taller than the viewport: no row is fully visible, so
+ // the reading is that row's top rather than nothing at all.
+ let r: [Int64: CGRect] = [9: CGRect(x: 0, y: -200, width: 400, height: 1200)]
+ let a = FeedGeometry.centreRow(rows: r, viewportHeight: 500)
+ XCTAssertEqual(a?.rowID, 9)
+ XCTAssertEqual(a?.fraction, 0)
+ }
+
+ func testNoReadingWithoutGeometry() {
+ XCTAssertNil(FeedGeometry.centreRow(rows: [:], viewportHeight: 500))
+ XCTAssertNil(FeedGeometry.centreRow(rows: rows(count: 3, height: 100, top: 0),
+ viewportHeight: 0))
+ // Every row above the viewport (the stranded-offset wedge) reads as
+ // nothing — the caller falls back to its own rescue.
+ let above: [Int64: CGRect] = [1: CGRect(x: 0, y: -900, width: 400, height: 100)]
+ XCTAssertNil(FeedGeometry.centreRow(rows: above, viewportHeight: 500))
+ }
+
+ func testNearBottomSlack() {
+ // The newest row resting just under the viewport bottom (behind the
+ // composer inset) still counts as at-the-bottom.
+ XCTAssertTrue(FeedGeometry.isNearBottom(
+ lastRow: CGRect(x: 0, y: 700, width: 400, height: 100), viewportHeight: 800))
+ XCTAssertTrue(FeedGeometry.isNearBottom(
+ lastRow: CGRect(x: 0, y: 840, width: 400, height: 100), viewportHeight: 800))
+ // A screenful further down is not.
+ XCTAssertFalse(FeedGeometry.isNearBottom(
+ lastRow: CGRect(x: 0, y: 1200, width: 400, height: 100), viewportHeight: 800))
+ XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: nil, viewportHeight: 800))
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 59b7261..57abb60 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -107,6 +107,22 @@ struct NativeFeedView: View {
// saved at all"). Capturing every ~150ms mid-scroll keeps the anchor
// never more than a few frames stale, whatever happens next.
var lastCaptureAt: TimeInterval = 0
+ // The reader's own hands on the feed: when they last moved it (a
+ // scroll, or a mouse drag — selecting text autoscrolls with no scroll
+ // events at all), and whether a drag is still down. The self-correcting
+ // scroll loops read these and hold off: moving the offset under a live
+ // drag turns a text selection into a scroll, and a quiet read into a
+ // jump.
+ var lastInputAt: TimeInterval = 0
+ var dragging = false
+ // The centre-row reading taken at that moment — the prepend
+ // re-anchor's reference. Kept CURRENT as the reader moves the feed
+ // rather than read once when a page fetch starts: the fetch is a real
+ // network round-trip plus a decrypt, and a reading from before it
+ // would put the reader back where they were when it began (a
+ // scrolled-away-by-itself feed). Stale only while nothing is moving —
+ // when it is stale, it is also still correct.
+ var centreRef: NativeFeedModelCache.FeedScrollAnchor?
}
@StateObject private var frames = FrameStore()
@@ -293,6 +309,10 @@ struct NativeFeedView: View {
// with a per-frame `onChange(of: CGRect)` (window-resize churn — #899).
@State private var paneFrame = LiveFrame()
@State private var swipeMonitor: Any?
+ // The drag monitor's twin handle: mouse input over the feed is how a text
+ // selection moves the offset (AppKit autoscrolls the drag), and it emits no
+ // scroll events, so the swipe monitor alone can't see it.
+ @State private var dragMonitor: Any?
// Identifies this feed's registration on the shared drop router, so a
// stale unregister (a thread switch's old feed disappearing after the new
// one registered the same pane key) can't evict the rightful receiver.
@@ -375,6 +395,7 @@ struct NativeFeedView: View {
model.start() // idempotent — a parked model is already live
frames.isActive = isActive
installSwipeMonitor()
+ installDragMonitor()
registerForDrops()
installProjector()
}
@@ -395,6 +416,9 @@ struct NativeFeedView: View {
// (the FeedWebCache rule); the cache's eviction stops them.
if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
swipeMonitor = nil
+ if let dragMonitor { NSEvent.removeMonitor(dragMonitor) }
+ dragMonitor = nil
+ frames.dragging = false
// One last capture at teardown, belt to the throttle's braces —
// captureAnchor no-ops if the row frames have already been
// cleared by the children's own teardown (order isn't
@@ -504,18 +528,23 @@ struct NativeFeedView: View {
// centre instead — solidly on screen, valid
// UnitPoint — and put IT back exactly where it
// was, after the prepend has laid out.
- let mid = frames.viewport.height / 2
- let ref = frames.rows
- .filter { $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height }
- .min { abs($0.value.midY - mid) < abs($1.value.midY - mid) }
+ refreshCentreRef()
Task {
await model.loadOlder()
guard model.feedMessages.first?.id != anchorID else { return }
+ // The reading is taken at the LAST moment the
+ // reader moved the feed (frames.centreRef),
+ // not at the moment this fetch started: a page
+ // is a network round-trip plus a decrypt, and
+ // whatever they scrolled or dragged through it
+ // would otherwise be undone — the feed
+ // scrolling itself back a screenful for no
+ // reason the reader can see.
DispatchQueue.main.async {
- if let ref, frames.viewport.height > ref.value.height {
- let f = max(0, min(1, ref.value.minY /
- (frames.viewport.height - ref.value.height)))
- proxy.scrollTo(ref.key, anchor: UnitPoint(x: 0, y: f))
+ if let ref = frames.centreRef,
+ model.feedMessages.contains(where: { $0.id == ref.rowID }) {
+ proxy.scrollTo(ref.rowID,
+ anchor: UnitPoint(x: 0, y: ref.fraction))
} else {
proxy.scrollTo(anchorID, anchor: .top)
}
@@ -624,7 +653,10 @@ struct NativeFeedView: View {
// The typing bubble appearing at the bottom should scroll into
// view when the reader is already there.
.onChange(of: model.typing.isEmpty) { _, empty in
- if !empty, let last = model.feedMessages.last?.id,
+ // Never while the reader's hands are on the feed: someone else
+ // starting to type is the weakest reason there is to move the
+ // offset, and it lands under live drags all day in a busy group.
+ if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id,
let f = frames.rows[last], f.minY < frames.viewport.height + 150 {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
}
@@ -896,28 +928,17 @@ struct NativeFeedView: View {
// "restored" away from arriving messages next time.
private func captureAnchor() {
guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return }
- if let last = model.feedMessages.last?.id, let f = frames.rows[last],
- f.maxY <= frames.viewport.height + 150 {
+ if FeedGeometry.isNearBottom(lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] },
+ viewportHeight: frames.viewport.height) {
storeAnchor(nil)
return
}
- let mid = frames.viewport.height / 2
- guard let ref = frames.rows
- .filter({ $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height })
- .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) })
- else {
- // No fully-visible row — one taller-than-the-viewport bubble (a
- // long code block) fills the screen. Anchor its top; scrollTo
- // can't express "row top minus 300pt", so this is the closest
- // honest restore.
- if let giant = frames.rows.first(where: { $0.value.minY < mid && $0.value.maxY > mid }) {
- storeAnchor(.init(rowID: giant.key, fraction: 0))
- }
- return
+ // A reading of nothing (every row off-screen mid-relayout) leaves the
+ // last good anchor alone rather than clearing it.
+ if let ref = FeedGeometry.centreRow(rows: frames.rows,
+ viewportHeight: frames.viewport.height) {
+ storeAnchor(ref)
}
- let denom = frames.viewport.height - ref.value.height
- let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0
- storeAnchor(.init(rowID: ref.key, fraction: fraction))
}
// Put the saved anchor's row back where it was and VERIFY it landed —
@@ -929,6 +950,11 @@ struct NativeFeedView: View {
_ anchor: NativeFeedModelCache.FeedScrollAnchor,
attempts: Int = 12) {
guard model.ready else { return }
+ // The reader got there first (they scrolled, or grabbed a selection,
+ // while the verify rounds were still landing) — where they are now IS
+ // the position, so stop correcting toward the old one. Driving implies
+ // settled, so the feed is already revealed at their spot.
+ if readerIsDriving() { return }
guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else {
storeAnchor(nil)
pinUntilSettled(proxy)
@@ -1010,8 +1036,20 @@ struct NativeFeedView: View {
// give up while genuinely wedged: park and retry on a slower cadence
// (bounded) instead of leaving a blank pane until the next arrival.
private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12,
- rescues: Int = 4) {
+ rescues: Int = 4, waits: Int = 12) {
guard model.ready else { return } // the ready flip re-enters
+ // Never pull the offset out from under a live scroll or selection
+ // drag: the pin lands mid-gesture, the content jumps, and a drag that
+ // was selecting text ends up scrolling instead. Wait for the reader's
+ // hands to come off (bounded — a stuck `dragging` must not wedge the
+ // feed blank forever) and pin then. A WEDGED feed is exempt: there is
+ // nothing on screen to be reading.
+ if readerIsDriving(), !feedIsWedged(), waits > 0 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1)
+ }
+ return
+ }
guard let id = model.feedMessages.last?.id else {
settled = true // nothing to pin (empty chat)
return
@@ -1028,7 +1066,7 @@ struct NativeFeedView: View {
proxy.scrollTo(first, anchor: .top)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
- pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1)
+ pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits)
}
} else {
settled = true // flush, or out of rescues — reveal rather than hide forever
@@ -1055,7 +1093,8 @@ struct NativeFeedView: View {
// OR stranded above), keep pinning.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
guard model.feedMessages.last?.id == id, !lastRowFlush() else { return }
- pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues)
+ pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues,
+ waits: waits)
}
return
}
@@ -1073,10 +1112,10 @@ struct NativeFeedView: View {
if let first = model.feedMessages.first?.id {
proxy.scrollTo(first, anchor: .top)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
- pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues)
+ pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits)
}
} else {
- pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues)
+ pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits)
}
}
}
@@ -1104,6 +1143,7 @@ struct NativeFeedView: View {
// near-current anchor — the trailing capture alone lost that
// race), plus a trailing debounce for the exact resting spot.
let now = ProcessInfo.processInfo.systemUptime
+ noteReaderInput(at: now)
if now - frames.lastCaptureAt > 0.15 {
frames.lastCaptureAt = now
captureAnchor()
@@ -1139,6 +1179,71 @@ struct NativeFeedView: View {
}
}
+ // Selecting text is the other way the reader moves this feed: a drag near
+ // the pane's edge autoscrolls it, and AppKit does that WITHOUT scroll
+ // events, so the swipe monitor never sees it. This monitor only watches
+ // (every event passes through untouched) — it exists so the scroll loops
+ // know a drag is live and hold off, and so the centre reading stays
+ // current through an autoscroll.
+ private func installDragMonitor() {
+ let frames = frames
+ dragMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]
+ ) { event in
+ // The mouse-up ends the drag wherever it happens — a selection
+ // routinely finishes outside the pane it started in — so it is
+ // cleared before the pane test, never after it.
+ if event.type == .leftMouseUp { frames.dragging = false; return event }
+ guard frames.isActive,
+ let window = event.window,
+ let content = window.contentView else { return event }
+ let p = event.locationInWindow
+ let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
+ guard paneFrame.rect.contains(point) else { return event }
+ if event.type == .leftMouseDown { frames.dragging = true }
+ noteReaderInput(at: ProcessInfo.processInfo.systemUptime)
+ return event
+ }
+ }
+
+ // One stamp + one fresh reading, from whichever monitor saw the input.
+ private func noteReaderInput(at now: TimeInterval) {
+ frames.lastInputAt = now
+ refreshCentreRef()
+ }
+
+ // Re-read where the reader is looking. A reading of nothing (mid-relayout,
+ // no row fully on screen) keeps the last good one rather than blanking it.
+ private func refreshCentreRef() {
+ if let ref = FeedGeometry.centreRow(rows: frames.rows,
+ viewportHeight: frames.viewport.height) {
+ frames.centreRef = ref
+ }
+ }
+
+ // True while the reader is working the feed — a scroll or a drag in the
+ // last beat. Every loop below that moves the offset defers to it.
+ // Only once the feed is SETTLED (on screen, and the reader's): while it's
+ // still hidden behind the opening pin, nothing they do is aimed at it.
+ private func readerIsDriving() -> Bool {
+ guard settled else { return false }
+ if frames.dragging {
+ // Trust the hardware over our own bookkeeping: a mouse-up can be
+ // swallowed (a menu, a drag ending in another app), and a stuck
+ // "dragging" would hold the feed's own corrections off forever.
+ if NSEvent.pressedMouseButtons & 1 != 0 { return true }
+ frames.dragging = false
+ }
+ return ProcessInfo.processInfo.systemUptime - frames.lastInputAt < 0.25
+ }
+
+ // A blank pane — the offset outlived a content shrink, no row materialised
+ // — always wins the offset back: there is nothing on screen for the reader
+ // to be reading, so the rescue runs whatever their hands are doing.
+ private func feedIsWedged() -> Bool {
+ frames.rows.isEmpty && !model.feedMessages.isEmpty
+ }
+
// Bubble row with run-grouping, mirroring the web feed: the sender name
// shows once per run (groups only), and bubbles inside a run sit tighter
// than bubbles that start one.