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
/// 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<NucleicSessionAttributes>.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)
}
@@ -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)
}
}
@@ -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<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
/// 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,
@@ -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
@@ -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 —