diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 73dddd9..44b1de3 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -148,22 +148,15 @@ final class LiveActivityManager { /// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// fresh churn still rides along on the next status-driven push. private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { - // Apply immediately when the attention signature shifts (start/finish/approval/input — the - // alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let - // that refresh on a slow cadence so it stays roughly current without spending ActivityKit's - // budget on every delta. Elapsed-time gate, not a sleeping timer — a status change is never - // held behind it. - let signatureChanged = state.attentionSignature != lastState?.attentionSignature - let churnRefreshDue = lastPushAt.map { - Date().timeIntervalSince($0) >= Self.churnRefreshInterval - } ?? true - guard signatureChanged || (state != lastState && churnRefreshDue) else { return } - lastState = state - lastPushAt = Date() - - guard let activity else { - // Recover an Activity that survived an app relaunch before starting a new one; the - // recovered one still needs the fresh state, so fall through to the update pipeline. + guard activity != nil else { + // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The + // dedup gate below only guards *updates* to a live Activity; creation must never sit + // behind it. ActivityKit refuses a start unless the app has a foreground/background- + // assertion window, and committing the dedup state *before* the request meant a refused + // start left `lastState` matching the aggregate — so the next `sync` deduped the glance + // away and it never activated until the session's state changed. Record the pushed state + // only once an Activity actually exists; a refused start leaves `lastState` untouched so + // the next sync simply retries creation. if let existing = Activity.activities.first { activity = existing observePushToken(existing) @@ -173,19 +166,35 @@ final class LiveActivityManager { // expanding reveals the fresh one, and `end()` on completion would leave it lingering // on the last "needs attention" glance. Keep exactly one. endStrays(keeping: existing.id) + lastState = state + lastPushAt = Date() enqueue(state) - } else { + } else if let started = try? Activity.request( // `pushType: .token` opts the activity into APNs updates — the Macs push new // content-state to the token so the glance stays fresh while the phone is locked. - let started = try? Activity.request( - attributes: NucleicSessionAttributes(hostName: hostName), - content: ActivityContent(state: state, staleDate: nil), - pushType: .token) + attributes: NucleicSessionAttributes(hostName: hostName), + content: ActivityContent(state: state, staleDate: nil), + pushType: .token) { activity = started - if let started { observePushToken(started) } + observePushToken(started) + lastState = state + lastPushAt = Date() } return } + + // A live Activity exists — spend ActivityKit's update budget only when the glance actually + // moved: immediately when the attention signature shifts (start/finish/approval/input — the + // alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking + // on every transcript delta). Elapsed-time gate, not a sleeping timer — a status change is + // never held behind it. + let signatureChanged = state.attentionSignature != lastState?.attentionSignature + let churnRefreshDue = lastPushAt.map { + Date().timeIntervalSince($0) >= Self.churnRefreshInterval + } ?? true + guard signatureChanged || (state != lastState && churnRefreshDue) else { return } + lastState = state + lastPushAt = Date() enqueue(state) } diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 6021e0a..ef0f7e1 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -61,6 +61,15 @@ final class HostConnection { private var transcriptFetchRequestID: String? private var didStartFullFetch = false + /// The in-flight *background* transcript prefetch (one at a time): its request id, target + /// session, and accumulated chunks. Independent of the open-session fetch above — replies + /// are matched on this id, so the two flows never mix, and prefetching keeps working while a + /// different session is open on screen. Driven by RemoteStore, which warms the offline cache + /// with the result so a never-before-opened session still opens at its end instantly. + private var prefetchRequestID: String? + private var prefetchSessionID: SessionID? + private var prefetchEvents: [AgentEvent] = [] + // MARK: Callbacks up to RemoteStore (aggregate concerns) /// How a `HostConnection` talks back to `RemoteStore`. All fire on the main actor. @@ -74,6 +83,10 @@ final class HostConnection { /// Backfilled history for the open session (the full-transcript fetch). Merged into the /// transcript by seq, not appended — these events precede the tail already on screen. var openBackfill: (EventBatch) -> Void = { _ in } + /// A background transcript prefetch finished — everything it fetched (empty when the + /// host had nothing / the fetch died with the connection). RemoteStore merges it into + /// the offline cache and starts the next queued prefetch either way. + var transcriptPrefetched: (SessionID, [AgentEvent]) -> Void = { _, _ in } /// The open session's full diff arrived. var openDiff: (WireSessionDiff) -> Void = { _ in } /// An approval was requested (with the resolved session title) — post the notification and, @@ -571,6 +584,12 @@ final class HostConnection { // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one. break case .transcriptChunk(let chunk): + // A slice of a *background prefetch*: accumulate it whole — it never touches the + // open transcript's cursor/dedup state, which belongs to the on-screen session. + if chunk.requestID == prefetchRequestID { + if chunk.sessionID == prefetchSessionID { prefetchEvents.append(contentsOf: chunk.events) } + break + } // One ordered slice of the full-history fetch we started on open. Match on request id so // a stale batch from a previous open (session switched underneath us) is ignored. guard chunk.requestID == transcriptFetchRequestID, chunk.sessionID == openSessionID else { break } @@ -582,10 +601,12 @@ final class HostConnection { if let maxSeq = chunk.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) } if !fresh.isEmpty { callbacks.openBackfill(EventBatch(sessionID: chunk.sessionID, events: fresh)) } case .transcriptFetchComplete(let done): + if done.requestID == prefetchRequestID { finishPrefetch(delivering: true); break } // Terminal success — every batch shipped. Clear the in-flight id; the cursor/dedup state // is already advanced by the chunks above. if done.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } case .transcriptUnavailable(let un): + if un.requestID == prefetchRequestID { finishPrefetch(delivering: false); break } // The host holds no transcript for this session (or can't serve the format). The cold tail // still shows; there's just no deeper history to add. Clear the in-flight id. if un.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } @@ -671,6 +692,39 @@ final class HostConnection { send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: 0))) } + /// Whether a background transcript prefetch can start now: live, the host can serve + /// transcript fetches, and no prefetch is already in flight (they run one at a time). + var canPrefetchTranscript: Bool { + connectivity.isLive && capabilities.canSyncTranscripts && client != nil && prefetchRequestID == nil + } + + /// Fetch a session's transcript in the background — no subscribe, no open — so the offline + /// cache is warm before the user ever opens the session. `afterSeq` bounds the pull to what + /// the cache is missing. The result (all chunks, merged) arrives via + /// `callbacks.transcriptPrefetched`; returns false when it can't start (caller retries on a + /// later session-list update). + @discardableResult + func prefetchTranscript(_ sessionID: SessionID, afterSeq: UInt64) -> Bool { + guard canPrefetchTranscript else { return false } + let requestID = UUID().uuidString + prefetchRequestID = requestID + prefetchSessionID = sessionID + prefetchEvents = [] + send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: afterSeq))) + return true + } + + /// Settle the in-flight prefetch: hand what arrived to RemoteStore (nothing on + /// unavailable/disconnect) and clear the slot so the next queued prefetch can start. + private func finishPrefetch(delivering: Bool) { + guard let sessionID = prefetchSessionID else { return } + let events = delivering ? prefetchEvents : [] + prefetchRequestID = nil + prefetchSessionID = nil + prefetchEvents = [] + callbacks.transcriptPrefetched(sessionID, events) + } + /// The device's network path changed (Wi-Fi ⇄ cellular, joined/left a network). React now rather /// than waiting for a zombie LAN socket to time out or the reconnect backoff to elapse — this is /// what makes the transport switch feel immediate. Only reconnect-managed hosts (a pinned host) @@ -818,6 +872,9 @@ final class HostConnection { eventTask?.cancel(); eventTask = nil if let client { Task { await client.disconnect() } } client = nil + // A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty + // so RemoteStore's queue isn't left waiting on a completion that will never arrive. + finishPrefetch(delivering: false) } } diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 5ccc3cb..1327198 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -359,6 +359,10 @@ final class RemoteStore: ObservableObject { func onForeground() { endBackgroundHold() guard !demoMode, isPaired else { return } + // Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop + // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get + // it via `didUpdate` once live. + setForegroundState(true) pathMonitor.refresh() // Ensure a connection exists for every paired host (and drop unpaired); this re-dials the // ones already known to be offline. @@ -379,6 +383,10 @@ final class RemoteStore: ObservableObject { /// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast. func onBackground() { guard !demoMode else { return } + // Heading to the background: the app can't reliably start a Live Activity anymore, so tell the + // hosts to push-to-start the glance themselves when work begins (UX_IOS §5.3). Sent now while + // the socket-hold below still keeps the connection live for a beat. + setForegroundState(false) #if canImport(UIKit) endBackgroundHold() backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") { @@ -406,6 +414,10 @@ final class RemoteStore: ObservableObject { /// state until the app next runs. func handleLiveActivityAdoptWake(completion: @escaping () -> Void) { guard !demoMode, isPaired else { completion(); return } + // A silent adopt wake means we're background (possibly cold-launched with no scene, where + // `isForeground`'s default would otherwise read stale-true) — record that so the reconnect + // below reports background and the host keeps owning the glance via push-to-start (§5.3). + setForegroundState(false) setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already startNetworkingIfNeeded() #if canImport(UIKit) @@ -634,6 +646,9 @@ final class RemoteStore: ObservableObject { // the glance cold when work starts before the app is opened. self.syncLiveActivityRegistration() self.syncPushToStartRegistration() + // …and the current foreground state, so it push-to-starts the glance itself when the app + // is backgrounded rather than waiting on a self-create that can't happen (UX_IOS §5.3). + self.syncForegroundState() } cb.openSnapshot = { [weak self] snap in guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } @@ -659,6 +674,19 @@ final class RemoteStore: ObservableObject { self.openEvents = Self.mergedEvents(self.openEvents, batch.events) self.persistOpenTranscript() } + cb.transcriptPrefetched = { [weak self] sessionID, events in + guard let self else { return } + self.prefetchInFlight = nil + if !events.isEmpty { + // Merge into whatever the cache already holds (the fetch pulled only the gap); + // `saveEvents` re-caps to the tail window on write. + Task { + let cached = await SessionCache.loadEvents(sessionID) + await SessionCache.saveEvents(Self.mergedEvents(cached, events), for: sessionID) + } + } + self.pumpTranscriptPrefetch() + } cb.openDiff = { [weak self] diff in guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } self.openDiff = diff @@ -736,6 +764,9 @@ final class RemoteStore: ObservableObject { sessions = live cachedSummaries = live persistSummaries(live) + // The list moved — new or advanced sessions may need their transcript cache + // warmed so a first open starts at the end like a revisit does. + scheduleTranscriptPrefetch() } } else if connections.values.contains(where: { $0.connectivity.isLive }) { // Connected, but the host genuinely has no sessions — reflect that honestly. @@ -812,6 +843,69 @@ final class RemoteStore: ObservableObject { } } + // MARK: - Background transcript prefetch + + /// Warm the offline transcript cache for recent sessions *before* they're ever opened. A + /// fresh open otherwise starts from an empty transcript and waits on the host's snapshot, so + /// the view renders at the top and visibly drops to the end as history lands; a session + /// opened before seeds instantly from `SessionCache` and opens at its end. Prefetching runs + /// the same `fetchTranscript` flow the open path uses — one session at a time, newest first, + /// pulling only what the cache is missing — so every recent session opens like a warm one. + private var prefetchQueue: [SessionID] = [] + private var prefetchInFlight: SessionID? + /// The summary `lastSeq` each session was last prefetched (or attempted) at, so a session is + /// re-queued only after it has moved on — not on every aggregate rebuild. + private var prefetchedSeq: [SessionID: UInt64] = [:] + + /// Rebuild the prefetch queue from the freshest summaries. Called when the session list + /// actually changes (connect, session churn); cheap when nothing needs pulling. + private func scheduleTranscriptPrefetch() { + guard !demoMode else { return } + prefetchQueue = sessions + .filter { !$0.archived && $0.sessionID != openSessionID && $0.sessionID != prefetchInFlight } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(8) + .filter { $0.lastSeq > (prefetchedSeq[$0.sessionID] ?? 0) } + .map(\.sessionID) + pumpTranscriptPrefetch() + } + + /// Start the next queued prefetch if none is in flight. Serial on purpose — background work + /// must trickle behind the live stream, not contend with it. + private func pumpTranscriptPrefetch() { + guard prefetchInFlight == nil, !prefetchQueue.isEmpty else { return } + let id = prefetchQueue.removeFirst() + guard let summary = sessions.first(where: { $0.sessionID == id }), + let conn = connection(owningSession: id), conn.canPrefetchTranscript + else { + // Gone / connection busy or incapable — skip; a later list update re-queues it. + pumpTranscriptPrefetch() + return + } + prefetchInFlight = id + // Mark the attempt at this watermark now, so an empty/unavailable result doesn't + // re-queue in a loop; the session re-qualifies once its lastSeq advances. + prefetchedSeq[id] = summary.lastSeq + Task { [weak self] in + // Pull only the gap beyond what's cached; a cold session is bounded to the same + // tail window the cache would keep anyway. + let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0 + guard let self else { return } + if cachedLast >= summary.lastSeq { + self.prefetchInFlight = nil // cache already current + self.pumpTranscriptPrefetch() + return + } + let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit) + ? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0 + let afterSeq = cachedLast > 0 ? cachedLast : coldFloor + if !conn.prefetchTranscript(id, afterSeq: afterSeq) { + self.prefetchInFlight = nil // couldn't start (connection changed) — move on + self.pumpTranscriptPrefetch() + } + } + } + // MARK: - Streaming delta coalescing /// Streaming transcript deltas arrive one wire frame at a time — often dozens per second @@ -1409,7 +1503,7 @@ final class RemoteStore: ObservableObject { // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, - .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair: break @@ -1432,6 +1526,14 @@ final class RemoteStore: ObservableObject { private var pushToStartToken: String? /// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`). private var pushToStartSentTo: Set = [] + /// Whether the app is currently foreground. Reported to capable Macs (`ClientMsg.setForeground`) + /// so they know whether to defer to the phone's own Live Activity creation (foreground) or + /// push-to-start the glance themselves (backgrounded — the phone can't reliably start one). Starts + /// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3). + private var isForeground = true + /// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the + /// next sync re-notifies every host (mirrors `pushToStartSentTo`). + private var foregroundSentTo: Set = [] /// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt /// wake, and installing the callbacks / observers once is enough. @@ -1497,6 +1599,32 @@ final class RemoteStore: ObservableObject { } } + /// Tell every capable, live host the current foreground state so it can pick the Live Activity + /// path: defer to the phone's own creation while foreground, or push-to-start the glance while + /// backgrounded (UX_IOS §5.3). De-duped per host against the value already sent; the set is + /// cleared by `setForegroundState` when the value flips, and a host that dropped re-learns it when + /// it returns (via the `didUpdate` re-sync). Gated on the dedicated capability bit — an older host + /// (even one advertising push-to-start) throws on the unknown `setForeground` tag. + private func syncForegroundState() { + foregroundSentTo = foregroundSentTo.filter { connections[$0]?.connectivity.isLive == true } + for (id, conn) in connections { + guard conn.connectivity.isLive, conn.capabilities.canReceiveForegroundState, + !foregroundSentTo.contains(id) else { continue } + conn.send(.setForeground(isForeground)) + foregroundSentTo.insert(id) + } + } + + /// Record a foreground/background transition and push it to the hosts. No-op when unchanged so an + /// `inactive`↔`active` flutter (or a repeat call) doesn't re-notify; on a real flip it clears the + /// per-host dedup so `syncForegroundState` re-sends to everyone. + private func setForegroundState(_ foreground: Bool) { + guard isForeground != foreground else { return } + isForeground = foreground + foregroundSentTo.removeAll() + syncForegroundState() + } + // MARK: - Demo simulator (offline, interactive) // // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the @@ -1554,7 +1682,7 @@ final class RemoteStore: ObservableObject { .addressUpdate, .meshRoster, // Live Activity push registration is a real-connection concern (there's no host to // push in demo), so it's inert here. - .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these, // and demo has no peer Macs, so they're inert here. .transferOffer, .transferChunk, .transferCommit, .transferCancel, diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index fa8b0f2..ed68dba 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -22,8 +22,9 @@ enum SessionCache { /// files) are pruned on every save so the cache stays bounded. private static let sessionLimit = 50 /// Keep the newest N events per session — the tail is what a returning reader wants, and it - /// bounds a long-running session's file. - private static let eventLimit = 1500 + /// bounds a long-running session's file. Internal (not private) so the background transcript + /// prefetch can bound a cold session's pull to the same window this cache would keep anyway. + static let eventLimit = 1500 // MARK: - Paths diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 678c49b..73323df 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -600,16 +600,33 @@ struct TranscriptList: View { /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it. private let bottomUnfollowThreshold: CGFloat = 64 - /// The live scroll phase, used to tell *user* scrolling (finger down / flick decelerating) + /// The live scroll phase, used to tell *user* scrolling (an active drag / flick decelerating) /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a /// user-driven phase may disengage bottom-following — a programmatic perturbation can only /// ever re-engage it — which structurally breaks every geometry→state→geometry feedback /// cycle: no chain of layout reactions can take the gate false and sustain itself. + /// + /// `.tracking` (finger down, no displacement yet) deliberately does NOT count: a finger + /// resting on the transcript scrolls nothing, but touching down *stops a live deceleration*, + /// and that stop emits a final far-from-bottom geometry reading under `.tracking`. Tapping + /// the chevron mid-deceleration raced exactly that emission against the tap's "follow again" + /// — when the stop reading landed after it, following disengaged right back and the chevron + /// stuck until a second tap. Real scroll-aways always pass through `.interacting`. @State private var scrollPhase: ScrollPhase = .idle private var isUserScrolling: Bool { - scrollPhase == .tracking || scrollPhase == .interacting || scrollPhase == .decelerating + scrollPhase == .interacting || scrollPhase == .decelerating } + /// True from an explicit jump (chevron / send) until the scroll actually reaches the bottom. + /// While set, the unfollow branch is suppressed entirely: phase changes and geometry + /// emissions are delivered on separate callbacks with no ordering guarantee, so a stopping + /// fling could emit one last far-from-bottom reading whose *recorded* phase was still + /// user-driven (`.decelerating`) — landing after the tap's "follow again" and disengaging it, + /// which left the chevron up until a second tap. The latch outlives any stale emission and + /// clears on arrival at the bottom, or the moment the user genuinely grabs the transcript + /// again (`.interacting`), so a mid-jump scroll-away still works. + @State private var jumpingToBottom = false + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -647,7 +664,12 @@ struct TranscriptList: View { // they scroll away, which lets new content land off-screen below instead of dragging // the viewport, and restore `.bottom` once they're back at the end. .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) - .onScrollPhaseChange { _, newPhase in scrollPhase = newPhase } + .onScrollPhaseChange { _, newPhase in + scrollPhase = newPhase + // A real grab (drag displacement, not a mere touch-down) takes over from an + // in-flight programmatic jump: the user may scroll away again immediately. + if newPhase == .interacting { jumpingToBottom = false } + } // Track the live scroll position straight from the scroll view's geometry: how much // content still sits below the viewport bottom. Parked within the follow threshold // means live output keeps following; a *user* scroll past the (wider) unfollow @@ -663,7 +685,9 @@ struct TranscriptList: View { } action: { _, distance in if distance <= bottomFollowThreshold { isScrolledToBottom = true - } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling { + jumpingToBottom = false // arrived — the jump is complete + } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling, + !jumpingToBottom { // While the chat is opening the layout grows over a few passes and the offset // lags each growth by a frame — those frames read "not at bottom" even though // `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts @@ -700,11 +724,15 @@ struct TranscriptList: View { // user is already parked there. Scrolling up to read history is never yanked down, // and a finger actively on the transcript is never fought mid-drag (the drag that // takes them past the unfollow threshold flips the gate; until then the native - // bottom anchor alone keeps content pinned, without an animated scroll grabbing the - // viewport back out of their hand). + // bottom anchor alone keeps content pinned, without a scroll grabbing the viewport + // back out of their hand). Never animated: while pinned, the bottom anchor provides + // the visual continuity and this call only closes the last few points — but a + // session still *opening* keeps receiving history merges (cached transcript, + // reconnect backfill) after the settle window closes, and animating those rode the + // viewport visibly down through the whole transcript instead of landing at the end. .onChange(of: events.count) { if isScrolledToBottom, !isUserScrolling { - scrollToEnd(proxy, animated: !transcriptSettling) + scrollToEnd(proxy, animated: false) } } // An explicit jump — the chevron or sending a message — always wins, and it *is* the @@ -717,6 +745,7 @@ struct TranscriptList: View { // disengages it as usual. .onChange(of: scrollToBottomRequest) { isScrolledToBottom = true + jumpingToBottom = true scrollToEnd(proxy) } // Warm the Markdown parse caches off the main thread whenever the row set grows —