eleven / ocho Public

mac: feed scroll memory, second pass — capture during scroll, settle-gate every reveal (#923)

Paul's retest still jumped, for two real holes in #917:

1. The anchor was captured only on a 250ms trailing debounce, and teardown
   CANCELLED the pending capture — scroll up, click another chat within a
   beat, and no anchor was ever saved. Now the capture also runs throttled
   (~150ms) DURING scrolling plus once at teardown (no-op if the row frames
   already cleared), so the anchor is never more than a few frames stale.

2. Anchorless (at-bottom) reopens kept the parked instant-first-frame reveal,
   and with tall code-block bubbles the estimated-height pin corrections were
   a visible jump anyway. Every open now starts hidden until its position
   verifiably lands — bottom pin and anchor restore alike. A parked model
   settles in a verify round or two (~50-100ms of pane background), which
   reads as a fast render; a visible jump reads as broken. No jumps > instant.

Also learned during diagnosis: a parallel session's install clobbered
/Applications two minutes after the first fix landed (the known collision) —
verify the INSTALLED dylib carries the change (strings | grep ScrollAnchor),
not just the build product.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9deedbb25029d7c7ddb327b6ef76620e3bb452bb parent 5c527ed
1 file changed, +30 −14
  • ios/LChatMac/NativeFeedView.swift +30 −14
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 6113823..7ad0827 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -95,6 +95,13 @@ struct NativeFeedView: View {
// wheel monitor fires per tick, and reading the row frames mid-fling
// is churn for nothing — capture once the scroll goes quiet.
var anchorDebounce: DispatchWorkItem?
+ // Throttle stamp for the DURING-scroll captures. The trailing
+ // debounce alone lost the race that mattered: scroll up, click
+ // another chat within a beat — teardown cancelled the pending
+ // capture and no anchor was ever saved (Paul's "state isn't being
+ // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor
+ // never more than a few frames stale, whatever happens next.
+ var lastCaptureAt: TimeInterval = 0
}
@StateObject private var frames = FrameStore()
@@ -115,12 +122,15 @@ struct NativeFeedView: View {
// The model arrives PARKED (NativeFeedModelCache) — folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
_model = StateObject(wrappedValue: model)
- // A parked model shows from the FIRST frame: history is folded and
- // .defaultScrollAnchor(.bottom) makes the first layout land at the
- // bottom, so there's nothing to hide — holding it behind the settle
- // verify (a ~50ms round-trip) blinked on every chat switch. Only a
- // cold model starts hidden; the pin still verifies either way.
- _settled = State(initialValue: model.ready)
+ // EVERY open starts hidden until its position verifiably lands —
+ // bottom pin or anchor restore alike. The first cut of this revealed
+ // parked models from the first frame ("instant"), but rows
+ // materialise with real heights after that frame, and with tall
+ // bubbles (code blocks) the correction was a visible jump on every
+ // switch — Paul's report, twice. A parked model settles in a verify
+ // round or two (~50–100ms of pane background), which reads as a fast
+ // render; a visible jump reads as broken. No jumps > instant.
+ _settled = State(initialValue: false)
self.isGroup = isGroup
self.isActive = isActive
self.roster = roster
@@ -557,11 +567,13 @@ struct NativeFeedView: View {
// (the FeedWebCache rule); the cache's eviction stops them.
if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
swipeMonitor = nil
- // A capture racing the teardown would read half-cleared row
- // frames; the anchor is already current (captured on the scroll
- // that put the reader wherever they are).
+ // One last capture at teardown, belt to the throttle's braces —
+ // captureAnchor no-ops if the row frames have already been
+ // cleared by the children's own teardown (order isn't
+ // guaranteed), in which case the throttled capture stands.
frames.anchorDebounce?.cancel()
frames.anchorDebounce = nil
+ captureAnchor()
if let key = instance?.origin {
PaneDropRouter.shared.unregister(key, token: dropToken)
}
@@ -766,11 +778,15 @@ struct NativeFeedView: View {
let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
guard paneFrame.rect.contains(point) else { return event }
- // Any scroll over this pane re-arms the position capture: once
- // the wheel goes quiet for a beat, remember where the reader is
- // (or that they're back at the bottom) for the next thread
- // switch. Debounced — reading row frames per tick mid-fling is
- // churn for nothing.
+ // Any scroll over this pane captures the position: throttled
+ // DURING the scroll (so a switch right after a fling still has a
+ // near-current anchor — the trailing capture alone lost that
+ // race), plus a trailing debounce for the exact resting spot.
+ let now = ProcessInfo.processInfo.systemUptime
+ if now - frames.lastCaptureAt > 0.15 {
+ frames.lastCaptureAt = now
+ captureAnchor()
+ }
frames.anchorDebounce?.cancel()
let capture = DispatchWorkItem { captureAnchor() }
frames.anchorDebounce = capture