eleven / ocho Public

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>
Dermot Daly 69d484a5aff7abc64c4ab4e3cf70ac7a59b1607d parent 704cac8
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)
}
}