nvrsion: Add: Fix chat chevron button sticking, improve transcript bottom jump logic, ensure live activity launches consistently, address separate push-to-start gating for live activity launch, fix live activity launch consistency, and audit interface stability.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 20:10:06 -07:00
co-authored by Nucleic
parent 5021783f7a
commit 378f07efca
5 changed files with 257 additions and 33 deletions
@@ -148,22 +148,15 @@ final class LiveActivityManager {
/// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// 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. /// fresh churn still rides along on the next status-driven push.
private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
// Apply immediately when the attention signature shifts (start/finish/approval/input — the guard activity != nil else {
// alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The
// that refresh on a slow cadence so it stays roughly current without spending ActivityKit's // dedup gate below only guards *updates* to a live Activity; creation must never sit
// budget on every delta. Elapsed-time gate, not a sleeping timer — a status change is never // behind it. ActivityKit refuses a start unless the app has a foreground/background-
// held behind it. // assertion window, and committing the dedup state *before* the request meant a refused
let signatureChanged = state.attentionSignature != lastState?.attentionSignature // start left `lastState` matching the aggregate — so the next `sync` deduped the glance
let churnRefreshDue = lastPushAt.map { // away and it never activated until the session's state changed. Record the pushed state
Date().timeIntervalSince($0) >= Self.churnRefreshInterval // only once an Activity actually exists; a refused start leaves `lastState` untouched so
} ?? true // the next sync simply retries creation.
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.
if let existing = Activity<NucleicSessionAttributes>.activities.first { if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing activity = existing
observePushToken(existing) observePushToken(existing)
@@ -173,19 +166,35 @@ final class LiveActivityManager {
// expanding reveals the fresh one, and `end()` on completion would leave it lingering // expanding reveals the fresh one, and `end()` on completion would leave it lingering
// on the last "needs attention" glance. Keep exactly one. // on the last "needs attention" glance. Keep exactly one.
endStrays(keeping: existing.id) endStrays(keeping: existing.id)
lastState = state
lastPushAt = Date()
enqueue(state) enqueue(state)
} else { } else if let started = try? Activity.request(
// `pushType: .token` opts the activity into APNs updates — the Macs push new // `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. // content-state to the token so the glance stays fresh while the phone is locked.
let started = try? Activity.request( attributes: NucleicSessionAttributes(hostName: hostName),
attributes: NucleicSessionAttributes(hostName: hostName), content: ActivityContent(state: state, staleDate: nil),
content: ActivityContent(state: state, staleDate: nil), pushType: .token) {
pushType: .token)
activity = started activity = started
if let started { observePushToken(started) } observePushToken(started)
lastState = state
lastPushAt = Date()
} }
return 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) enqueue(state)
} }
@@ -61,6 +61,15 @@ final class HostConnection {
private var transcriptFetchRequestID: String? private var transcriptFetchRequestID: String?
private var didStartFullFetch = false 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) // MARK: Callbacks up to RemoteStore (aggregate concerns)
/// How a `HostConnection` talks back to `RemoteStore`. All fire on the main actor. /// 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 /// 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. /// transcript by seq, not appended — these events precede the tail already on screen.
var openBackfill: (EventBatch) -> Void = { _ in } 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. /// The open session's full diff arrived.
var openDiff: (WireSessionDiff) -> Void = { _ in } var openDiff: (WireSessionDiff) -> Void = { _ in }
/// An approval was requested (with the resolved session title) — post the notification and, /// 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. // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break break
case .transcriptChunk(let chunk): 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 // 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. // a stale batch from a previous open (session switched underneath us) is ignored.
guard chunk.requestID == transcriptFetchRequestID, chunk.sessionID == openSessionID else { break } 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 let maxSeq = chunk.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) }
if !fresh.isEmpty { callbacks.openBackfill(EventBatch(sessionID: chunk.sessionID, events: fresh)) } if !fresh.isEmpty { callbacks.openBackfill(EventBatch(sessionID: chunk.sessionID, events: fresh)) }
case .transcriptFetchComplete(let done): 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 // Terminal success — every batch shipped. Clear the in-flight id; the cursor/dedup state
// is already advanced by the chunks above. // is already advanced by the chunks above.
if done.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } if done.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil }
case .transcriptUnavailable(let un): 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 // 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. // still shows; there's just no deeper history to add. Clear the in-flight id.
if un.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil } if un.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil }
@@ -671,6 +692,39 @@ final class HostConnection {
send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: 0))) 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 /// 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 /// 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) /// 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 eventTask?.cancel(); eventTask = nil
if let client { Task { await client.disconnect() } } if let client { Task { await client.disconnect() } }
client = nil 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)
} }
} }
@@ -359,6 +359,10 @@ final class RemoteStore: ObservableObject {
func onForeground() { func onForeground() {
endBackgroundHold() endBackgroundHold()
guard !demoMode, isPaired else { return } 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() pathMonitor.refresh()
// Ensure a connection exists for every paired host (and drop unpaired); this re-dials the // Ensure a connection exists for every paired host (and drop unpaired); this re-dials the
// ones already known to be offline. // 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. /// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast.
func onBackground() { func onBackground() {
guard !demoMode else { return } 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) #if canImport(UIKit)
endBackgroundHold() endBackgroundHold()
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") { backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") {
@@ -406,6 +414,10 @@ final class RemoteStore: ObservableObject {
/// state until the app next runs. /// state until the app next runs.
func handleLiveActivityAdoptWake(completion: @escaping () -> Void) { func handleLiveActivityAdoptWake(completion: @escaping () -> Void) {
guard !demoMode, isPaired else { completion(); return } 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 setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already
startNetworkingIfNeeded() startNetworkingIfNeeded()
#if canImport(UIKit) #if canImport(UIKit)
@@ -634,6 +646,9 @@ final class RemoteStore: ObservableObject {
// the glance cold when work starts before the app is opened. // the glance cold when work starts before the app is opened.
self.syncLiveActivityRegistration() self.syncLiveActivityRegistration()
self.syncPushToStartRegistration() 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 cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } 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.openEvents = Self.mergedEvents(self.openEvents, batch.events)
self.persistOpenTranscript() 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 cb.openDiff = { [weak self] diff in
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return } guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
self.openDiff = diff self.openDiff = diff
@@ -736,6 +764,9 @@ final class RemoteStore: ObservableObject {
sessions = live sessions = live
cachedSummaries = live cachedSummaries = live
persistSummaries(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 }) { } else if connections.values.contains(where: { $0.connectivity.isLive }) {
// Connected, but the host genuinely has no sessions — reflect that honestly. // 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 // MARK: - Streaming delta coalescing
/// Streaming transcript deltas arrive one wire frame at a time — often dozens per second /// 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` are sent straight to the chosen host by
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode, .respondMacPair: .requestPairingCode, .cancelPairingCode, .respondMacPair:
break break
@@ -1432,6 +1526,14 @@ final class RemoteStore: ObservableObject {
private var pushToStartToken: String? private var pushToStartToken: String?
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`). /// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
private var pushToStartSentTo: Set<String> = [] private var pushToStartSentTo: Set<String> = []
/// 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<String> = []
/// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt /// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt
/// wake, and installing the callbacks / observers once is enough. /// 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) // MARK: - Demo simulator (offline, interactive)
// //
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the // 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, .addressUpdate, .meshRoster,
// Live Activity push registration is a real-connection concern (there's no host to // Live Activity push registration is a real-connection concern (there's no host to
// push in demo), so it's inert here. // 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, // 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. // and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .transferOffer, .transferChunk, .transferCommit, .transferCancel,
@@ -22,8 +22,9 @@ enum SessionCache {
/// files) are pruned on every save so the cache stays bounded. /// files) are pruned on every save so the cache stays bounded.
private static let sessionLimit = 50 private static let sessionLimit = 50
/// Keep the newest N events per session — the tail is what a returning reader wants, and it /// Keep the newest N events per session — the tail is what a returning reader wants, and it
/// bounds a long-running session's file. /// bounds a long-running session's file. Internal (not private) so the background transcript
private static let eventLimit = 1500 /// prefetch can bound a cold session's pull to the same window this cache would keep anyway.
static let eventLimit = 1500
// MARK: - Paths // MARK: - Paths
@@ -600,16 +600,33 @@ struct TranscriptList: View {
/// The band is wider than any bar-height delta, so only a deliberate scroll crosses it. /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it.
private let bottomUnfollowThreshold: CGFloat = 64 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 /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a
/// user-driven phase may disengage bottom-following — a programmatic perturbation can only /// user-driven phase may disengage bottom-following — a programmatic perturbation can only
/// ever re-engage it — which structurally breaks every geometry→state→geometry feedback /// 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. /// 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 @State private var scrollPhase: ScrollPhase = .idle
private var isUserScrolling: Bool { 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 { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
@@ -647,7 +664,12 @@ struct TranscriptList: View {
// they scroll away, which lets new content land off-screen below instead of dragging // 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. // the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) .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 // 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 // content still sits below the viewport bottom. Parked within the follow threshold
// means live output keeps following; a *user* scroll past the (wider) unfollow // means live output keeps following; a *user* scroll past the (wider) unfollow
@@ -663,7 +685,9 @@ struct TranscriptList: View {
} action: { _, distance in } action: { _, distance in
if distance <= bottomFollowThreshold { if distance <= bottomFollowThreshold {
isScrolledToBottom = true 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 // 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 // lags each growth by a frame — those frames read "not at bottom" even though
// `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts // `.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, // 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 // 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 // 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 // bottom anchor alone keeps content pinned, without a scroll grabbing the viewport
// viewport back out of their hand). // 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) { .onChange(of: events.count) {
if isScrolledToBottom, !isUserScrolling { 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 // 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. // disengages it as usual.
.onChange(of: scrollToBottomRequest) { .onChange(of: scrollToBottomRequest) {
isScrolledToBottom = true isScrolledToBottom = true
jumpingToBottom = true
scrollToEnd(proxy) scrollToEnd(proxy)
} }
// Warm the Markdown parse caches off the main thread whenever the row set grows — // Warm the Markdown parse caches off the main thread whenever the row set grows —