eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 34fff3c1141d8135807ac9bc628d7b04c59f32df parent c684158
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)
}
}
}