eleven / ocho Public

mac: every near-bottom gate reads the row's bottom, not its top (#1510)

#1488's re-pin gate was fixed in #1502 to go through the shared, tested
FeedGeometry.isNearBottom; three neighbouring triggers still carried the old
`minY < viewport.height + 150` shape, which is wrong in the same way. A newest
message TALLER than the viewport (a long code block) is the case that
separates the two readings: the reader can be a screenful into scroll-back
with that row's top still above the fold, so the old test called them "at the
bottom" and yanked them down to the newest message.

Swept: the typing-bubble follow, the link-card (unfurl) re-pin, and the
text-size change. No behaviour change when the newest row is short — which is
why this went unnoticed.

Two judgement calls kept, not swept flat:

- The unfurl re-pin now takes settleBottomGently instead of pinUntilSettled.
  A card growing one bubble is incremental content on a feed that is already
  revealed; the heavy loop's jolt to the top of loaded history is far too big
  an instrument for it, and it fires while the reader is reading.
- The text-size change KEEPS the heavy loop: every row re-lays out at a new
  height, which is exactly what its verify rounds and rescue exist for.

The arrival handler's remaining minY test is deliberately untouched — it reads
the PREVIOUS last row to ask "was the reader following the conversation before
this message landed", which is a different question.

FeedGeometryTests gains the tall-row case, asserting both readings so the old
shape can't creep back: a 900pt newest row in an 800pt viewport reads as at
the bottom when pinned and NOT at the bottom after 300pt of scroll-back, where
`minY < height + 150` would still have said yes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev fa85e4a160edd03c98f11bc9b6fe95d1cf2872e3 parent 340b330
2 files changed, +42 −6
  • ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift +22 −0
  • ios/LChatMac/NativeFeedView.swift +20 −6
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift
index a8879b5..8cfb76e 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift
@@ -133,4 +133,26 @@ final class FeedGeometryTests: XCTestCase {
lastRow: CGRect(x: 0, y: 1200, width: 400, height: 100), viewportHeight: 800))
XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: nil, viewportHeight: 800))
}
+
+ // Why every near-bottom gate in NativeFeedView reads the row's BOTTOM and
+ // not its top. A newest message TALLER than the viewport (a long code
+ // block) is the case that separates them: the reader can be a screenful
+ // into scroll-back and still have that row's top above the fold, so a
+ // `minY < height + slack` test called them "at the bottom" and every
+ // trigger keyed on it — a composer resize, a link card landing, the typing
+ // bubble, a text-size change — yanked them down to the newest message.
+ func testTallNewestRowDoesNotReadAsAtTheBottom() {
+ let viewport: CGFloat = 800
+ // One 900pt message, pinned: its bottom rests at the viewport bottom,
+ // its top is far above the fold (minY = -100).
+ let pinned = CGRect(x: 0, y: -100, width: 400, height: 900)
+ XCTAssertTrue(FeedGeometry.isNearBottom(lastRow: pinned, viewportHeight: viewport))
+
+ // The same row after scrolling 300pt back INTO it: still overlapping the
+ // viewport, top now on screen — and emphatically not at the bottom.
+ let scrolledBack = CGRect(x: 0, y: 200, width: 400, height: 900)
+ XCTAssertFalse(FeedGeometry.isNearBottom(lastRow: scrolledBack, viewportHeight: viewport))
+ // The reading the gates used to use would have said yes to that one.
+ XCTAssertLessThan(scrolledBack.minY, viewport + 150)
+ }
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index e456eba..78b13bf 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -770,8 +770,13 @@ struct NativeFeedView: View {
// 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.
+ // The shared reading (the row's BOTTOM within slack) — the
+ // row's TOP still counted a reader hundreds of points into
+ // scroll-back as "at the bottom" whenever the newest message
+ // was tall. Same correction as the viewport-resize gate above.
if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id,
- let f = frames.rows[last], f.minY < frames.viewport.height + 150 {
+ FeedGeometry.isNearBottom(lastRow: frames.rows[last],
+ viewportHeight: frames.viewport.height) {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
}
}
@@ -894,8 +899,12 @@ struct NativeFeedView: View {
// beat and rescue only if the viewport actually came up empty, so
// a scrolled-back reader isn't yanked to the newest message.
.onChange(of: chatFontSize) { _, _ in
- let nearBottom = model.feedMessages.last.flatMap { frames.rows[$0.id]?.minY }
- .map { $0 < frames.viewport.height + 150 } ?? false
+ // The heavy loop is right here — every row re-lays out at a
+ // new height, which is what its verify rounds exist for — but
+ // the gate is the shared one, tall-newest-row included.
+ let nearBottom = FeedGeometry.isNearBottom(
+ lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] },
+ viewportHeight: frames.viewport.height)
if nearBottom {
pinUntilSettled(proxy)
return
@@ -1688,9 +1697,14 @@ struct NativeFeedView: View {
// bottom, re-pin so the posted link stays
// visible (verified, like every other pin).
if card != nil, m.id == model.feedMessages.last?.id,
- let f = frames.rows[m.id],
- f.minY < frames.viewport.height + 150 {
- DispatchQueue.main.async { pinUntilSettled(proxy) }
+ FeedGeometry.isNearBottom(lastRow: frames.rows[m.id],
+ viewportHeight: frames.viewport.height) {
+ // Gentle, not the cold-open loop: a card
+ // growing one bubble is incremental content
+ // on a feed that is already revealed, and the
+ // heavy loop's jolt to the top of history is
+ // far too big an instrument for it (#1502).
+ DispatchQueue.main.async { settleBottomGently(proxy) }
}
return card
},