mac: re-pin the feed whenever the composer band resizes (#1488)
Staging a photo grows the composer TWICE — the upload box, then the decoded thumbnail — and the re-pin was keyed on `staged.isEmpty`, one boolean edge. It caught the first growth and missed the second, so the newest message was left below the fold behind the band: the jump on uploading a photo. A reply banner arming, a draft wrapping to a second line and a window resize move the band too, and none of them re-pinned either. Keyed on the viewport's own measured height now, which changes for all of them — a bottom safeAreaInset shrinks this ScrollView's frame, so the viewport IS the band-reduced height. "Was the reader at the bottom" is read against the OLD height, since the rows have not moved yet when the change lands, and the pin is coalesced so a live divider drag can't spawn a verify loop per pixel. lastRowFlush moves to FeedGeometry with tests, semantics unchanged. Its doc now records the measured fact that cost a broken build: the viewport is already inset-reduced (a 500pt pane with a 120pt composer reports 380 and pins its last row at 379), so nothing may be subtracted for the band — double-counting it makes the verify never settle, which reads as a feed jumping for a few seconds and a chat that opens blank.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+118
−23
ios/LChatCore/Sources/LChatCore/FeedGeometry.swift+35 −0ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift+50 −0ios/LChatMac/NativeFeedView.swift+33 −23
diff --git a/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift b/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift| index 9373000..17df0c9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/FeedGeometry.swift |
| @@ -44,6 +44,41 @@ public enum FeedGeometry { |
| return .init(rowID: ref.key, fraction: fraction) |
| } |
| + // Whether the newest row is genuinely resting at the viewport's bottom (a |
| + // tall chat pinned) or, for a chat too short to fill it, fully visible from |
| + // the top. The pin verify's question — and the one that decides whether a |
| + // cold feed is revealed or kept hidden for another round. |
| + // |
| + // `viewportHeight` is the SCROLL VIEW's own height, which on the Mac feed |
| + // is already the composer-reduced one: a bottom `safeAreaInset` shrinks the |
| + // scroll view's frame, so a pinned last row sits at ≈ viewportHeight and |
| + // nothing has to be subtracted for the band. (Measured off-screen, 2026-08: |
| + // a 500pt pane with a 120pt inset reports 380 and pins its last row at |
| + // 379.) Do not "correct" for the inset here — that double-count is what |
| + // made the verify never settle. |
| + // |
| + // Both rects are in the scroll content's space (a row on screen has |
| + // 0 <= minY <= viewportHeight). The rule's job is to tell a genuine pin |
| + // apart from the #900 wedge: a layout race can leave the offset past the |
| + // content's end with every row ABOVE the viewport at a NEGATIVE maxY, and |
| + // the bare `maxY <= height` test called that flush and revealed a blank |
| + // pane. A pinned last row has a POSITIVE maxY near the bottom; the lower |
| + // bound stays loose because a row still materialising at an estimated |
| + // height (and the typing bubble sitting below the newest row) must not flap |
| + // the verify. |
| + public static func lastRowIsFlush(last: CGRect?, first: CGRect?, |
| + viewportHeight: CGFloat) -> Bool { |
| + guard let last, viewportHeight > 0 else { return false } |
| + // Short chat: the whole log fits (content height = last.maxY - |
| + // first.minY, independent of the offset), top-aligned — flush means the |
| + // first row starts at/after the top and the last row's bottom isn't |
| + // shoved past the viewport bottom. |
| + if let first, last.maxY - first.minY <= viewportHeight { |
| + return first.minY >= -2 && last.maxY <= viewportHeight + 2 |
| + } |
| + return last.maxY > viewportHeight * 0.5 && last.maxY <= viewportHeight + 2 |
| + } |
| + |
| // 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. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift| index ec5c475..a8879b5 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/FeedGeometryTests.swift |
| @@ -71,6 +71,56 @@ final class FeedGeometryTests: XCTestCase { |
| XCTAssertNil(FeedGeometry.centreRow(rows: above, viewportHeight: 500)) |
| } |
| + // ---- lastRowIsFlush: the pin verify's states ------------------------ |
| + |
| + // A tall chat pinned: the newest row rests at the viewport's bottom. The |
| + // viewport is already the composer-reduced height (safeAreaInset shrinks |
| + // the scroll view's frame), so nothing is subtracted for the band. |
| + func testPinnedTallChatIsFlush() { |
| + let last = CGRect(x: 0, y: 300, width: 400, height: 79) // maxY 379 of 380 |
| + XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380)) |
| + } |
| + |
| + // The typing bubble sits between the newest row and the bottom — still |
| + // flush, or the verify would never settle in a live conversation. |
| + func testTypingBubbleGapIsStillFlush() { |
| + let last = CGRect(x: 0, y: 240, width: 400, height: 90) // maxY 330 of 380 |
| + XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380)) |
| + } |
| + |
| + // Bug #900: the offset outlived the content and every row sits ABOVE the |
| + // viewport with a negative maxY. Never flush — revealing it shows blank. |
| + func testStrandedAboveIsNotFlush() { |
| + let last = CGRect(x: 0, y: -800, width: 400, height: 100) // maxY -700 |
| + XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380)) |
| + } |
| + |
| + // Scrolled back: the newest row is below the fold. |
| + func testScrolledBackIsNotFlush() { |
| + let last = CGRect(x: 0, y: 900, width: 400, height: 100) |
| + XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: last, first: nil, viewportHeight: 380)) |
| + } |
| + |
| + // A chat too short to fill the pane reads from the TOP: flush means the |
| + // first row starts at the top edge and the last one is inside the viewport. |
| + func testShortChatIsFlushWhenTopAligned() { |
| + let first = CGRect(x: 0, y: 0, width: 400, height: 60) |
| + let last = CGRect(x: 0, y: 70, width: 400, height: 60) // maxY 130 |
| + XCTAssertTrue(FeedGeometry.lastRowIsFlush(last: last, first: first, viewportHeight: 380)) |
| + // Dragged ABOVE the top edge — the offset outran the content, which is |
| + // the short-chat form of the #900 wedge. |
| + let stranded = CGRect(x: 0, y: -30, width: 400, height: 60) |
| + XCTAssertFalse(FeedGeometry.lastRowIsFlush( |
| + last: CGRect(x: 0, y: 40, width: 400, height: 60), first: stranded, |
| + viewportHeight: 380)) |
| + } |
| + |
| + func testNoGeometryIsNeverFlush() { |
| + XCTAssertFalse(FeedGeometry.lastRowIsFlush(last: nil, first: nil, viewportHeight: 380)) |
| + XCTAssertFalse(FeedGeometry.lastRowIsFlush( |
| + last: CGRect(x: 0, y: 0, width: 400, height: 60), first: nil, viewportHeight: 0)) |
| + } |
| + |
| func testNearBottomSlack() { |
| // The newest row resting just under the viewport bottom (behind the |
| // composer inset) still counts as at-the-bottom. |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 57abb60..4577bfe 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -123,6 +123,10 @@ struct NativeFeedView: View { |
| // scrolled-away-by-itself feed). Stale only while nothing is moving — |
| // when it is stale, it is also still correct. |
| var centreRef: NativeFeedModelCache.FeedScrollAnchor? |
| + // Coalesces the re-pin that follows a viewport resize: a live divider |
| + // drag fires per pixel, and one verify loop per tick would fight |
| + // itself. One pin, a beat after the size settles. |
| + var resizePinDebounce: DispatchWorkItem? |
| } |
| @StateObject private var frames = FrameStore() |
| @@ -661,14 +665,30 @@ struct NativeFeedView: View { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| } |
| } |
| - // Staged attachments live INSIDE the input pill, so staging and |
| - // unstaging resize the composer inset — keep the newest message |
| - // pinned through it when the reader was at the bottom. |
| - .onChange(of: staged.isEmpty) { _, _ in |
| - if let last = model.feedMessages.last?.id, |
| - let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| - pinUntilSettled(proxy) |
| - } |
| + // The composer band is part of this pane's layout, and it moves for |
| + // more reasons than the old `staged.isEmpty` gate (one boolean edge) |
| + // could see: a staged photo grows it TWICE — the upload box, then |
| + // the decoded thumbnail — and a reply banner arming, a draft |
| + // wrapping to a second line or the window resizing all move it too. |
| + // Every one of those shrinks the scroll viewport, and SwiftUI keeps |
| + // the content's offset from the top, so the newest message slides |
| + // below the fold behind the band. The photo's SECOND growth had no |
| + // re-pin at all: the jump on uploading a photo. The viewport's own |
| + // measured height (it is already the inset-reduced height — |
| + // safeAreaInset shrinks this ScrollView's frame) catches all of |
| + // them. |
| + .onChange(of: viewportHeight) { old, new in |
| + guard old > 0, new != old, let last = model.feedMessages.last?.id, |
| + let f = frames.rows[last] else { return } |
| + // Was the reader at the bottom BEFORE the viewport changed? The |
| + // rows have not moved yet, so the OLD height is the honest |
| + // reading — against the new one, a reader the band has just |
| + // covered reads as "scrolled away" and gets left there. |
| + guard f.minY < old + 150 else { return } |
| + frames.resizePinDebounce?.cancel() |
| + let pin = DispatchWorkItem { pinUntilSettled(proxy) } |
| + frames.resizePinDebounce = pin |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: pin) |
| } |
| // Opening a chat lands at the newest message — and STAYS there: |
| // one pin isn't enough, because rows materialise with their real |
| @@ -1004,21 +1024,11 @@ struct NativeFeedView: View { |
| // newest message but has no row here — keying the verify on it would |
| // never settle, and the feed would stay hidden. |
| private func lastRowFlush() -> Bool { |
| - guard let id = model.feedMessages.last?.id, let f = frames.rows[id] else { return false } |
| - let vh = frames.viewport.height |
| - guard vh > 0 else { return false } |
| - // Short chat: the whole log fits (content height = last.maxY - |
| - // first.minY, independent of the offset), top-aligned — flush means the |
| - // first row starts at/after the top and the last row's bottom isn't |
| - // shoved past the viewport bottom. |
| - if let firstID = model.feedMessages.first?.id, let ff = frames.rows[firstID], |
| - f.maxY - ff.minY <= vh { |
| - return ff.minY >= -2 && f.maxY <= vh + 2 |
| - } |
| - // Tall chat: the newest row's bottom must be in the lower half of the |
| - // viewport and not below its bottom edge — never the negative maxY of |
| - // the stranded-above wedge. |
| - return f.maxY > vh * 0.5 && f.maxY <= vh + 2 |
| + guard let id = model.feedMessages.last?.id else { return false } |
| + return FeedGeometry.lastRowIsFlush( |
| + last: frames.rows[id], |
| + first: model.feedMessages.first.flatMap { frames.rows[$0.id] }, |
| + viewportHeight: frames.viewport.height) |
| } |
| // Pin to the bottom sentinel and VERIFY it landed: the frame store knows |