eleven / messenger Public

mac: rescue the stubborn feed wedge — jolt a dematerialised stack, never park blank (#494)

The blank-on-send returned in a form the verified pin couldn't cure: the
composer-inset shrink clamps the offset past the content end with ZERO
rows materialised, and scrollTo(bottom) against a fully dematerialised
lazy stack computes against nothing every retry — 0.6s of identical
misses, then a graceful give-up that leaves the pane blank until the next
arrival. Two additions to pinUntilSettled: a verify round that finds the
true wedge (no rows at all) first scrolls to the FIRST message (top),
forcing materialisation and un-clamping the offset, then resumes pinning;
and exhausting fast retries while still truly wedged parks a slower
bounded rescue instead of giving up. Scrolled-back readers are untouched
(their rows are materialised, so neither path engages).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell db845489e06eac1a671f71c52ad521a8c0d92992 parent 75067d7
1 file changed, +27 −3
  • ios/LChatMac/NativeFeedView.swift +27 −3
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 5860ba4..300419e 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -443,7 +443,18 @@ struct NativeFeedView: View {
// 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
// the last message changes underneath (the chat moved on).
- private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12) {
+ //
+ // The WEDGE needs more than retries: after a send, the composer-inset
+ // shrink can clamp the scroll offset past the content's end with ZERO rows
+ // materialised — and against a fully dematerialised lazy stack, the same
+ // scrollTo(bottom) computes against nothing every round (the blank-feed
+ // bug's stubborn form). When a verify round finds that state, jolt the
+ // offset back into range by scrolling to the FIRST message (top) — which
+ // forces materialisation — then resume pinning to the bottom. And never
+ // 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) {
guard model.ready else { return } // the ready flip re-enters
guard let id = model.messages.last?.id else {
settled = true // nothing to pin (empty chat)
@@ -451,7 +462,16 @@ struct NativeFeedView: View {
}
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
guard attempts > 0 else {
- settled = true // give up gracefully rather than hide forever
+ // 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 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+ pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1)
+ }
+ } else {
+ settled = true // give up gracefully rather than hide forever
+ }
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
@@ -463,7 +483,11 @@ struct NativeFeedView: View {
settled = true // flush — reveal
return
}
- pinUntilSettled(proxy, attempts: attempts - 1)
+ if frames.rows.isEmpty, let first = model.messages.first?.id {
+ // Wedged: un-clamp by landing somewhere valid first.
+ proxy.scrollTo(first, anchor: .top)
+ }
+ pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues)
}
}