mac: fix #900 blank feed — pin verify accepted a scrolled-past-the-end offset (#1007)
On open, a layout race can leave the feed's scroll offset past the content's end (every row above the viewport, negative maxY). The pin's flush check was `maxY <= viewport.height + 2`, which a negative maxY satisfies trivially — so it declared "flush", revealed empty space, and never re-pinned: a blank feed until you scrolled (#900). Reproduces on switch-in as well as cold launch. Replace the check with lastRowFlush(): a tall chat counts as flush only when the newest row's bottom sits in the lower half of the viewport (never the negative maxY of the stranded state); short chats get a fits-and-top-aligned branch. The un-strand re-pin now fires whenever not flush — it was gated on zero rows materialised, so a stranded-but-materialised feed never recovered — and jolts to the top, then re-pins the bottom a frame later so the top scroll actually takes. Recursing with no gap let the same estimated-height overshoot recur every round, which is what left a stranded open blank for ~a second before the slow rescue path finally landed it. Sits on top of the scroll-memory / settle-gate work (#917/#923), doesn't replace it. Mac-only; iOS pushes a screen per chat and isn't affected.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 file changed,
+62
−14
ios/LChatMac/NativeFeedView.swift+62 −14
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 5eed7fc..3e32b61 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -710,6 +710,36 @@ struct NativeFeedView: View { |
| // A parked one starts settled (see init) so switching chats is instant. |
| @State private var settled: Bool |
| + // Whether the newest row is genuinely resting at the viewport bottom (a |
| + // tall chat pinned) or, for a chat too short to fill the viewport, fully |
| + // visible from the top. This replaces the bare `maxY <= viewport.height + 2` |
| + // verify, which ALSO matched a feed stranded ABOVE the viewport — bug #900: |
| + // on an open, a layout race can leave the scroll offset past the content's |
| + // end, every row with a NEGATIVE maxY (last row maxY ≈ -696), and |
| + // `-696 <= 855` "revealed" empty space beyond the last message (blank until |
| + // you scrolled). A genuinely-pinned last row has a POSITIVE maxY near the |
| + // bottom; a stranded one is negative. (The bottom safe-area inset — the |
| + // composer — means a pinned maxY lands SHORT of viewport.height, so we |
| + // can't require maxY ≈ height; "in the lower half and not below the bottom |
| + // edge" is the robust band for a tall chat.) |
| + private func lastRowFlush() -> Bool { |
| + guard let id = model.messages.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.messages.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 |
| + } |
| + |
| // Pin to the bottom sentinel and VERIFY it landed: the frame store knows |
| // where the last row actually is, so re-pin (a few frames apart) until its |
| // bottom sits at the viewport's bottom or the attempts run out. Bails if |
| @@ -733,15 +763,20 @@ struct NativeFeedView: View { |
| } |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| guard attempts > 0 else { |
| - // Out of fast retries. A scrolled-back reader has materialised |
| - // rows — reveal and stop. A true wedge (nothing rendered at all) |
| - // gets a slower bounded retry instead of a permanent blank. |
| - if rescues > 0, frames.rows.isEmpty, !model.messages.isEmpty { |
| + // Out of fast retries. If the newest row is genuinely at the |
| + // bottom, reveal. Otherwise the feed is still stranded (rows above/ |
| + // below the viewport, or none materialised) — jolt to a valid |
| + // anchor and retry on a slower cadence (bounded by `rescues`) |
| + // rather than revealing a blank pane. |
| + if rescues > 0, !lastRowFlush(), !model.messages.isEmpty { |
| + if let first = model.messages.first?.id { |
| + proxy.scrollTo(first, anchor: .top) |
| + } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1) |
| } |
| } else { |
| - settled = true // give up gracefully rather than hide forever |
| + settled = true // flush, or out of rescues — reveal rather than hide forever |
| } |
| return |
| } |
| @@ -750,8 +785,8 @@ struct NativeFeedView: View { |
| settled = true |
| return |
| } |
| - if let f = frames.rows[id], f.maxY <= frames.viewport.height + 2 { |
| - settled = true // flush — reveal |
| + if lastRowFlush() { |
| + settled = true // genuinely pinned to the bottom — reveal |
| // A verified bottom IS the no-anchor state: an own-send (or |
| // arrival) pin that yanked a scrolled-back reader down makes |
| // any saved anchor stale — the next open should land here, |
| @@ -761,20 +796,33 @@ struct NativeFeedView: View { |
| // strip a moment after this verify passes, the composer inset |
| // shrinks, and the content shifts under the satisfied pin — |
| // an image bubble lands below the fold (Paul's screenshot). |
| - // Confirm the pin HELD; if the layout moved, keep pinning. |
| + // Confirm the pin HELD; if it slipped either way (pushed below |
| + // OR stranded above), keep pinning. |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { |
| - guard model.messages.last?.id == id, |
| - let f2 = frames.rows[id], |
| - f2.maxY > frames.viewport.height + 2 else { return } |
| + guard model.messages.last?.id == id, !lastRowFlush() else { return } |
| pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues) |
| } |
| return |
| } |
| - if frames.rows.isEmpty, let first = model.messages.first?.id { |
| - // Wedged: un-clamp by landing somewhere valid first. |
| + // Not flush: the offset is stranded — rows above/below the viewport |
| + // (the #900 blank: rows materialised but all with negative maxY), or |
| + // none materialised. scrollTo(sentinel) can't pull a past-the-end |
| + // offset back on its own, so jolt to a materialised anchor (the |
| + // first message, at the top) to force a valid offset + real row |
| + // heights, THEN — a beat later, so the top scroll actually takes — |
| + // re-pin the bottom. Recursing with no gap lets the same |
| + // estimated-height overshoot recur every round, which is what made |
| + // the stranded open sit blank for ~a second before the slow rescue |
| + // path finally landed it. (Previously gated on frames.rows.isEmpty, |
| + // so a stranded-but-materialised feed never recovered at all.) |
| + if let first = model.messages.first?.id { |
| proxy.scrollTo(first, anchor: .top) |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| + } |
| + } else { |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| } |
| - pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| } |
| } |