mac: the feed stops jumping while you type (#1502)
* mac: the feed stops jumping while you type #1488 widened the re-pin trigger from `staged.isEmpty` to any change in the scroll viewport's height, which is right — the band moves for a staged photo, a reply banner, a window resize. But the composer band also carries the TYPEAHEAD STRIP, which opens, re-ranks and closes as you type, and a draft wrapping a line moves it too. So the re-pin now fires on almost every keystroke, and what it fired was pinUntilSettled: a verify loop whose failure path scrolls to the TOP of loaded history to force a dematerialised stack to lay out, then pins back. That jolt is correct for a wedged pane and is a full-height jump under someone who is only typing. Three things, all in the same failure: - A revealed feed whose band moved gets settleBottomGently — pin the bottom, verify once, at most three pins, never the top jolt, never a touch of `settled`. An unrevealed feed still gets the full machinery. - Pin loops carry a GENERATION. resizePinDebounce only ever cancelled a pin that had not started, so a second trigger stacked a second loop beside the first and the two corrected the same feed from different rounds of geometry. The newest loop wins; older continuations stand down. - The near-bottom gate reads the newest row's BOTTOM through the shared FeedGeometry.isNearBottom, not `minY < old + 150`: with a tall newest message the old test still held a couple of hundred points into scroll-back, so typing yanked a reader who had scrolled up a little back down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * mac: close the review's holes in the gentle scroll correction Review of the previous commit found one way it could strand a BLANK pane, the failure this file has been bitten by twice — plus three smaller ones. - A WEDGE (offset clamped past the content's end, no rows materialised — the classic post-send state) reached settleBottomGently, which took the generation, retired the arrival pin's rescue, and then spent three no-op pins: scrollTo(sentinel) computes against nothing when the stack is fully dematerialised. Nothing re-triggered afterwards, so the feed stayed blank until the next message. It now hands the generation to pinUntilSettled instead, and escalates there too when its rounds run out still not flush (which also restores the post-send "did the pin HOLD" watch the gentle path had displaced). - Preconditions now run BEFORE the generation bump in both loops, so a call that does nothing can no longer retire a live one. `settled` was already guarded, but after the side effect — the protection lived in the call site, not the function. - The resize correction does nothing at all while the feed is unrevealed: the open/restore machinery owns the offset until `settled`, and a band moving during a restore used to start a bottom pin BESIDE it — two loops aimed at different targets, the pin clearing the reader's saved anchor if it verified first. - restoreUntilSettled joins the generation scheme, so the claim that every continuation checks is true of all three loops rather than two. - A driving reader now defers the correction (bounded) rather than losing it: a trackpad fling holds `driving` for its whole momentum tail, and dropping outright left the newest message behind the band. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * mac: a keystroke can never reach the top jolt Second review pass caught that the escalation's justification was simply wrong: pinUntilSettled jolts to the top of loaded history on its FIRST failed verify, not after a dozen bottom pins. So "three gentle rounds, then escalate" put a top jolt ~0.35s behind any keystroke whose content was still reflowing — the very jump this branch exists to remove. And the other half of the claim, that sustained typing keeps retiring the chain, only holds for keystrokes that actually CHANGE the band's height; typing mid-line changes nothing and retires nothing. Gentle now owns the whole non-wedge case: six rounds, three fast then three patient (~1.1s, outlasting the +0.2s held-recheck it displaced), and running out simply stops. The pane is revealed either way — the worst case is the newest message sitting a little behind the band until the next trigger, which is a far better failure than a full-height jump under someone typing. Only a WEDGE escalates, and a wedge has nothing on screen to disturb. Also from the pass: - The near-bottom gate is re-read every round against LIVE geometry, not once at the trigger. With the deferral the correction can now be ~a second old, and a reader who scrolled away in that window was getting snapped back. - restoreUntilSettled's driving check joins the other preconditions, above the generation bump, so it too retires nothing when it does nothing. - Comment indentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> ---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+154
−14
ios/LChatMac/NativeFeedView.swift+154 −14
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 7179825..6c6fc0d 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -141,6 +141,17 @@ struct NativeFeedView: View { |
| // drag fires per pixel, and one verify loop per tick would fight |
| // itself. One pin, a beat after the size settles. |
| var resizePinDebounce: DispatchWorkItem? |
| + // The live scroll loop's generation. All three of this file's |
| + // self-correcting loops — pinUntilSettled, settleBottomGently and |
| + // restoreUntilSettled — run across many runloop turns, and the resize |
| + // debounce above only cancels a pin that has not STARTED. So a second |
| + // trigger (another keystroke widening the composer, a message landing |
| + // mid-restore) used to start a second loop beside the first, each |
| + // issuing its own scrollTo from its own round of geometry — which is |
| + // the "jumping around" the reader sees. A fresh loop takes the next |
| + // generation; every continuation in all three re-checks that it still |
| + // holds the current one and stands down if it doesn't. |
| + var pinGeneration = 0 |
| } |
| @StateObject private var frames = FrameStore() |
| @@ -782,10 +793,28 @@ struct NativeFeedView: View { |
| // 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 } |
| + // covered reads as "scrolled away" and gets left there. The |
| + // shared reading (the row's BOTTOM within slack), not the row's |
| + // top: for a tall newest message `minY < old + 150` still held |
| + // a couple of hundred points into scroll-back, so typing yanked |
| + // a reader who had scrolled up a little back down. |
| + guard FeedGeometry.isNearBottom(lastRow: f, viewportHeight: old) else { return } |
| frames.resizePinDebounce?.cancel() |
| - let pin = DispatchWorkItem { pinUntilSettled(proxy) } |
| + // The GENTLE correction, never the cold-open loop: that loop's |
| + // rescue jolts to the top of loaded history to force |
| + // materialisation, which is right for a wedged pane and |
| + // catastrophic here — the composer band moves on almost every |
| + // keystroke (the typeahead strip opening, re-ranking and |
| + // closing; a draft wrapping a line), and each jolt is a |
| + // full-height jump under someone who is only typing. |
| + // And nothing at all while the feed is still unrevealed: the |
| + // open/restore machinery owns the offset until `settled`, and a |
| + // band that moves during a restore (a draft rehydrating, a |
| + // reply banner re-arming) used to start a bottom pin BESIDE the |
| + // restore — two loops scrolling to different targets, the pin |
| + // wiping the reader's saved anchor if it verified first. |
| + // settleBottomGently's own `settled` guard enforces this. |
| + let pin = DispatchWorkItem { settleBottomGently(proxy) } |
| frames.resizePinDebounce = pin |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: pin) |
| } |
| @@ -1117,16 +1146,31 @@ struct NativeFeedView: View { |
| // message) falls back to the ordinary bottom pin, anchor cleared. |
| private func restoreUntilSettled(_ proxy: ScrollViewProxy, |
| _ anchor: NativeFeedModelCache.FeedScrollAnchor, |
| - attempts: Int = 12) { |
| + attempts: Int = 12, generation: Int? = nil) { |
| guard model.ready else { return } |
| // The reader got there first (they scrolled, or grabbed a selection, |
| // while the verify rounds were still landing) — where they are now IS |
| // the position, so stop correcting toward the old one. Driving implies |
| - // settled, so the feed is already revealed at their spot. |
| + // settled, so the feed is already revealed at their spot. Checked with |
| + // the other preconditions, BEFORE the generation bump, so a call that |
| + // does nothing retires nothing. |
| if readerIsDriving() { return } |
| + // The third loop on this feed, under the same generation discipline as |
| + // the other two — it aims at a ROW while they aim at the bottom, so two |
| + // of them live at once is the worst version of the fight: an arrival |
| + // pin landing mid-restore scrolls to the bottom and, if it verifies |
| + // first, clears the reader's saved anchor on the way past. |
| + let gen: Int |
| + if let generation { |
| + guard generation == frames.pinGeneration else { return } |
| + gen = generation |
| + } else { |
| + frames.pinGeneration &+= 1 |
| + gen = frames.pinGeneration |
| + } |
| guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else { |
| storeAnchor(nil) |
| - pinUntilSettled(proxy) |
| + pinUntilSettled(proxy, generation: gen) |
| return |
| } |
| proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction)) |
| @@ -1135,6 +1179,7 @@ struct NativeFeedView: View { |
| return |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| + guard gen == frames.pinGeneration else { return } |
| if let f = frames.rows[anchor.rowID], frames.viewport.height > 0 { |
| let denom = frames.viewport.height - f.height |
| let want = denom > 0 ? anchor.fraction * denom : 0 |
| @@ -1143,7 +1188,7 @@ struct NativeFeedView: View { |
| return |
| } |
| } |
| - restoreUntilSettled(proxy, anchor, attempts: attempts - 1) |
| + restoreUntilSettled(proxy, anchor, attempts: attempts - 1, generation: gen) |
| } |
| } |
| @@ -1180,6 +1225,81 @@ struct NativeFeedView: View { |
| viewportHeight: frames.viewport.height) |
| } |
| + // The GENTLE bottom correction: the feed is revealed, its rows are on |
| + // screen, and something moved the composer band underneath them (a staged |
| + // photo, a reply banner arming, the typeahead strip, a draft wrapping a |
| + // line). All that is owed is "put the newest message back against the |
| + // bottom edge" — a pin and, if the content settles a little differently, at |
| + // most a couple more. Deliberately NOT pinUntilSettled: that loop's rescue |
| + // scrolls to the TOP of loaded history to force a dematerialised stack to |
| + // lay out, which is the right medicine for a wedged pane and, on a band |
| + // that moved 18pt while someone types, a full-height jump for nothing. It |
| + // also never touches `settled` — this is a correction, not a reveal. |
| + // Six rounds — three fast, then three patient (~1.1s in all), which |
| + // outlasts the +0.2s held-recheck the full loop would have run. Running out |
| + // simply STOPS: the pane is revealed and the worst case is the newest |
| + // message sitting a little behind the band until the next trigger. It must |
| + // not escalate to pinUntilSettled, which jolts to the top on its FIRST |
| + // failed verify — a keystroke must never be able to reach that. Only a |
| + // WEDGE escalates, and a wedge has nothing on screen to disturb. |
| + private func settleBottomGently(_ proxy: ScrollViewProxy, attempts: Int = 6, |
| + generation: Int? = nil) { |
| + // `settled` is a HARD precondition, and it is checked BEFORE taking a |
| + // generation: taking one retires any pin loop in flight, and this |
| + // function never reveals — so entering it unrevealed would retire the |
| + // loop that was going to reveal and leave the pane hidden. Preconditions |
| + // first, side effect second. |
| + guard settled, model.ready, let id = model.feedMessages.last?.id else { return } |
| + // The newest correction wins and older ones stand down, so keystroke |
| + // after keystroke can't stack loops that fight each other. |
| + let gen: Int |
| + if let generation { |
| + guard generation == frames.pinGeneration else { return } |
| + gen = generation |
| + } else { |
| + frames.pinGeneration &+= 1 |
| + gen = frames.pinGeneration |
| + } |
| + // A WEDGE is not a gentle problem. With no rows materialised, |
| + // scrollTo(sentinel) computes against nothing every round (see |
| + // pinUntilSettled) — only the full loop's jolt to a real row gets the |
| + // offset back, and with a blank pane there is nothing on screen for |
| + // that jolt to disturb. Hand the generation over rather than spending |
| + // a round of no-op pins and leaving the feed blank until the next |
| + // arrival. |
| + guard !feedIsWedged() else { return pinUntilSettled(proxy, generation: gen) } |
| + // Is the reader still AT the bottom? Re-read every round against LIVE |
| + // geometry, not once at the trigger: between the debounce and a |
| + // deferred round this correction can be up to ~a second old, and a |
| + // reader who scrolled away in that window must be left where they are. |
| + // The 150pt slack is wider than any band move, so the row this exists |
| + // to rescue — pushed down behind a grown composer — still qualifies. |
| + guard FeedGeometry.isNearBottom(lastRow: frames.rows[id], |
| + viewportHeight: frames.viewport.height) else { return } |
| + // Never under the reader's hands — but hold the correction rather than |
| + // losing it: a trackpad fling keeps `driving` true through its whole |
| + // momentum tail, and dropping outright left the newest message behind |
| + // the band until something else happened to re-pin. |
| + guard !readerIsDriving() else { |
| + guard attempts > 1 else { return } |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| + settleBottomGently(proxy, attempts: attempts - 1, generation: gen) |
| + } |
| + return |
| + } |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| + guard attempts > 1 else { return } |
| + // Fast while the band is settling, then patient: content still |
| + // reflowing (a card finishing layout in the newest message) wants time, |
| + // not more pins. |
| + let delay = attempts > 4 ? 0.05 : 0.3 |
| + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
| + guard gen == frames.pinGeneration, |
| + model.feedMessages.last?.id == id, !lastRowFlush() else { return } |
| + settleBottomGently(proxy, attempts: attempts - 1, generation: gen) |
| + } |
| + } |
| + |
| // 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 |
| @@ -1195,8 +1315,22 @@ struct NativeFeedView: View { |
| // 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, waits: Int = 12) { |
| + rescues: Int = 4, waits: Int = 12, |
| + generation: Int? = nil) { |
| guard model.ready else { return } // the ready flip re-enters |
| + // A fresh pin supersedes whatever was still in flight: these loops span |
| + // many runloop turns, so without this a resize, an arrival and a |
| + // reveal each ran their own — three scrollTo streams, from three |
| + // different rounds of geometry, on one feed. Taken after the `ready` |
| + // guard, so a call that does nothing retires nothing. |
| + let gen: Int |
| + if let generation { |
| + guard generation == frames.pinGeneration else { return } |
| + gen = generation |
| + } else { |
| + frames.pinGeneration &+= 1 |
| + gen = frames.pinGeneration |
| + } |
| // Never pull the offset out from under a live scroll or selection |
| // drag: the pin lands mid-gesture, the content jumps, and a drag that |
| // was selecting text ends up scrolling instead. Wait for the reader's |
| @@ -1205,7 +1339,8 @@ struct NativeFeedView: View { |
| // nothing on screen to be reading. |
| if readerIsDriving(), !feedIsWedged(), waits > 0 { |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| - pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1) |
| + pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1, |
| + generation: gen) |
| } |
| return |
| } |
| @@ -1225,7 +1360,8 @@ struct NativeFeedView: View { |
| proxy.scrollTo(first, anchor: .top) |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| - pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits) |
| + pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits, |
| + generation: gen) |
| } |
| } else { |
| settled = true // flush, or out of rescues — reveal rather than hide forever |
| @@ -1233,6 +1369,7 @@ struct NativeFeedView: View { |
| return |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| + guard gen == frames.pinGeneration else { return } |
| guard model.feedMessages.last?.id == id else { |
| settled = true |
| return |
| @@ -1251,9 +1388,10 @@ struct NativeFeedView: View { |
| // 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.feedMessages.last?.id == id, !lastRowFlush() else { return } |
| + guard gen == frames.pinGeneration, |
| + model.feedMessages.last?.id == id, !lastRowFlush() else { return } |
| pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues, |
| - waits: waits) |
| + waits: waits, generation: gen) |
| } |
| return |
| } |
| @@ -1271,10 +1409,12 @@ struct NativeFeedView: View { |
| if let first = model.feedMessages.first?.id { |
| proxy.scrollTo(first, anchor: .top) |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| - pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits) |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits, |
| + generation: gen) |
| } |
| } else { |
| - pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits) |
| + pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits, |
| + generation: gen) |
| } |
| } |
| } |