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>
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) |
| } |
| } |