eleven / ocho Public

mac: the feed remembers where you were — per-thread scroll position survives chat switches (#917)

Models are parked (NativeFeedModelCache) and whole space panes stay mounted
(RootView's ZStack), but the per-thread feed VIEW is rebuilt on every switch
— and with it the scroll offset: every switch-back landed at the bottom, with
the pin's corrections running on an already-visible feed (the jump Paul
reported, worst with tall code-block bubbles whose estimated heights are far
off).

The scroll position now lives where the model does: a per-thread anchor (the
row nearest the viewport's centre + its fractional travel — the same
reference the prepend re-anchor uses) stored in the model cache, captured on
a debounce after wheel scrolling goes quiet, and restored with a verified
scrollTo loop behind the settle gate, so the switch never shows the position
being corrected. No anchor = the reader left at the bottom = today's instant
first-frame pin, unchanged. A verified bottom pin (own send, arrival yank)
clears the anchor — being at the bottom IS the no-anchor state. Anchors share
the parked model's lifetime: eviction or a re-keyed rebuild drops both.

Mac-only by design: sidebar pane-switching is a desktop shape; iOS's
navigation keeps land-at-newest (platform convention). Changelog held for the
native release mint, per convention.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 385a6ac5a0bc813346ec4428d731a15b00a203be parent 31c09dd
2 files changed, +151 −2
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +29 −0
  • ios/LChatMac/NativeFeedView.swift +122 −2
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
index 4b9839a..d26400d 100644
--- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
+++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
@@ -21,6 +21,33 @@ public final class NativeFeedModelCache {
private var entries: [String: Entry] = [:] // "instanceID|threadID"
private let maxEntries = 8
+ // Where the reader left each thread's feed — the scroll position's half of
+ // "parking". The model survives a thread switch but the VIEW (and its
+ // scroll offset) is rebuilt per thread, so without this every switch-back
+ // landed at the bottom regardless of where the reader was. An absent entry
+ // means "was at the bottom": the next open pins to newest, exactly the
+ // pre-anchor behaviour. Same lifetime as the parked model on purpose —
+ // an evicted or rebuilt model drops its anchor too, so a cold reload
+ // never chases a row it may no longer hold.
+ public struct FeedScrollAnchor: Equatable {
+ public let rowID: Int64 // the message row to put back on screen
+ public let fraction: CGFloat // its top's 0…1 travel within the viewport
+ public init(rowID: Int64, fraction: CGFloat) {
+ self.rowID = rowID
+ self.fraction = fraction
+ }
+ }
+
+ private var anchors: [String: FeedScrollAnchor] = [:]
+
+ public func scrollAnchor(instance instanceID: String, thread threadID: String) -> FeedScrollAnchor? {
+ anchors[instanceID + "|" + threadID]
+ }
+
+ public func setScrollAnchor(_ anchor: FeedScrollAnchor?, instance instanceID: String, thread threadID: String) {
+ anchors[instanceID + "|" + threadID] = anchor
+ }
+
// One tamper-evidence monitor per instance (docs/tamper-evidence.md, #573),
// shared by every thread's model so gossip from any chat feeds one chain
// view. The app polls it (pollIntegrity) on connect/foreground and mounts
@@ -130,6 +157,7 @@ public final class NativeFeedModelCache {
return entry.model
}
entries.removeValue(forKey: key)?.model.stop()
+ anchors.removeValue(forKey: key) // a rebuilt model starts at the bottom, like new
let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId)
model.senderNames = senderNames[instance.id] ?? [:]
model.mutedPeople = mutedPeople[instance.id] ?? []
@@ -159,6 +187,7 @@ public final class NativeFeedModelCache {
while entries.count > maxEntries,
let oldest = entries.min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
entries.removeValue(forKey: oldest.key)?.model.stop()
+ anchors.removeValue(forKey: oldest.key)
}
}
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index c9cd0e6..6113823 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -91,6 +91,10 @@ struct NativeFeedView: View {
// only act for the pane actually on screen. A box (not view state) so
// the monitor closure reads the live value.
var isActive = true
+ // Debounces the scroll-position capture (the per-thread anchor): the
+ // 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?
}
@StateObject private var frames = FrameStore()
@@ -418,7 +422,11 @@ struct NativeFeedView: View {
// row is actually flush with the viewport bottom.
.onChange(of: model.ready) { _, ready in
guard ready else { return }
- pinUntilSettled(proxy)
+ if let anchor = savedAnchor() {
+ restoreUntilSettled(proxy, anchor)
+ } else {
+ pinUntilSettled(proxy)
+ }
consumeJump(proxy)
}
// Search hits land on their message: page history until the
@@ -444,7 +452,20 @@ struct NativeFeedView: View {
}
}
.onAppear {
- pinUntilSettled(proxy)
+ // Switching BACK to a thread the reader had scrolled away from
+ // restores their spot instead of pinning to the bottom — the
+ // anchor lives in the model cache (the view and its scroll
+ // offset are rebuilt per thread; the cache survives). Hidden
+ // while the restore lands (one or two verify rounds) so the
+ // switch never shows the position being corrected — the jump
+ // Paul reported. No anchor = the reader left at the bottom =
+ // today's pin, still visible from the first frame.
+ if let anchor = savedAnchor() {
+ if settled { settled = false }
+ restoreUntilSettled(proxy, anchor)
+ } else {
+ pinUntilSettled(proxy)
+ }
consumeJump(proxy)
}
// Anchor to the bottom BEFORE first layout — a parked/prewarmed
@@ -536,6 +557,11 @@ 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).
+ frames.anchorDebounce?.cancel()
+ frames.anchorDebounce = nil
if let key = instance?.origin {
PaneDropRouter.shared.unregister(key, token: dropToken)
}
@@ -570,6 +596,85 @@ struct NativeFeedView: View {
static let bottomSentinel = "bottom-sentinel"
+ // ---- per-thread scroll memory -------------------------------------------
+ // The anchor is the row nearest the viewport's centre plus its fractional
+ // travel — the same reference the prepend re-anchor uses — stored in the
+ // model cache so it outlives this view's per-thread teardown. Captured on
+ // a debounce after wheel scrolling goes quiet; cleared whenever a bottom
+ // pin verifiably lands (being at the bottom IS the no-anchor state).
+
+ private func savedAnchor() -> NativeFeedModelCache.FeedScrollAnchor? {
+ guard let iid = instance?.id else { return nil }
+ return NativeFeedModelCache.shared.scrollAnchor(instance: iid, thread: threadID)
+ }
+
+ private func storeAnchor(_ anchor: NativeFeedModelCache.FeedScrollAnchor?) {
+ guard let iid = instance?.id else { return }
+ NativeFeedModelCache.shared.setScrollAnchor(anchor, instance: iid, thread: threadID)
+ }
+
+ // Read the current position out of the frame store. Near the bottom
+ // (the newest row materialised by the viewport) counts as AT the bottom —
+ // clearing the anchor — so a reader hovering a few points up isn't
+ // "restored" away from arriving messages next time.
+ private func captureAnchor() {
+ guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return }
+ if let last = model.messages.last?.id, let f = frames.rows[last],
+ f.maxY <= frames.viewport.height + 150 {
+ storeAnchor(nil)
+ return
+ }
+ let mid = frames.viewport.height / 2
+ guard let ref = frames.rows
+ .filter({ $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height })
+ .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) })
+ else {
+ // No fully-visible row — one taller-than-the-viewport bubble (a
+ // long code block) fills the screen. Anchor its top; scrollTo
+ // can't express "row top minus 300pt", so this is the closest
+ // honest restore.
+ if let giant = frames.rows.first(where: { $0.value.minY < mid && $0.value.maxY > mid }) {
+ storeAnchor(.init(rowID: giant.key, fraction: 0))
+ }
+ return
+ }
+ let denom = frames.viewport.height - ref.value.height
+ let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0
+ storeAnchor(.init(rowID: ref.key, fraction: fraction))
+ }
+
+ // Put the saved anchor's row back where it was and VERIFY it landed —
+ // pinUntilSettled's shape, aimed at a row instead of the bottom. Rows
+ // materialise with real heights after the first jump, so one round is
+ // rarely enough. A vanished anchor row (evicted history, a deleted
+ // message) falls back to the ordinary bottom pin, anchor cleared.
+ private func restoreUntilSettled(_ proxy: ScrollViewProxy,
+ _ anchor: NativeFeedModelCache.FeedScrollAnchor,
+ attempts: Int = 12) {
+ guard model.ready else { return }
+ guard model.messages.contains(where: { $0.id == anchor.rowID }) else {
+ storeAnchor(nil)
+ pinUntilSettled(proxy)
+ return
+ }
+ proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction))
+ guard attempts > 0 else {
+ settled = true // close enough — reveal rather than hide forever
+ return
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+ 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
+ if abs(f.minY - want) <= 2 {
+ settled = true
+ return
+ }
+ }
+ restoreUntilSettled(proxy, anchor, attempts: attempts - 1)
+ }
+ }
+
// A COLD feed stays invisible until the pin is verified flush — the reader
// should find the chat already at the bottom, never watch it snap there.
// A parked one starts settled (see init) so switching chats is instant.
@@ -617,6 +722,11 @@ struct NativeFeedView: View {
}
if let f = frames.rows[id], f.maxY <= frames.viewport.height + 2 {
settled = true // flush — reveal
+ // A verified bottom IS the no-anchor state: an own-send (or
+ // arrival) pin that yanked a scrolled-back reader down makes
+ // any saved anchor stale — the next open should land here,
+ // at the newest, not back at the old spot.
+ storeAnchor(nil)
// Flush NOW isn't flush in a beat: a send clears the staged
// strip a moment after this verify passes, the composer inset
// shrinks, and the content shifts under the satisfied pin —
@@ -656,6 +766,16 @@ 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.
+ frames.anchorDebounce?.cancel()
+ let capture = DispatchWorkItem { captureAnchor() }
+ frames.anchorDebounce = capture
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: capture)
+
// The release event arrives with ZERO deltas, so it must be
// handled before any horizontal-dominance check — that guard
// silently ate it and the gutter froze wherever it was left.