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:
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user