Merge branch 'dev' into canary

This commit is contained in:
2026-07-06 13:05:48 -07:00
9 changed files with 297 additions and 12 deletions
+8
View File
@@ -20,6 +20,14 @@
target's "Stamp git commit into Info.plist" build phase (git rev-parse --short HEAD). --> target's "Stamp git commit into Info.plist" build phase (git rev-parse --short HEAD). -->
<key>NucleicCommit</key> <key>NucleicCommit</key>
<string></string> <string></string>
<!-- Wake the app in the background for the relay's silent "clear" push, which recalls the
"waiting for your approval" tickle once the approval is resolved on another device
(docs/CLOUD_INFRA.md §3). Without this mode iOS drops the content-available push and the
stale notification lingers until the next launch. -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<!-- Let the Macs' APNs pushes refresh the glance promptly while the phone is locked — without <!-- Let the Macs' APNs pushes refresh the glance promptly while the phone is locked — without
@@ -15,6 +15,13 @@ final class LiveActivityManager {
/// still summarize everything, this just bounds the per-session list. /// still summarize everything, this just bounds the per-session list.
private static let maxLines = 3 private static let maxLines = 3
/// How long the terminal "all clear / Done" glance lingers before the Activity dismisses. Long
/// enough to read at a glance, short enough not to loiter on the lock screen.
private static let doneLinger: Duration = .seconds(4)
/// The delayed dismissal after the Done glance is shown, cancelled if work resumes first — so a
/// completed run flashes "Done" and then clears instead of vanishing the instant it finishes.
private var dismissTask: Task<Void, Never>?
private var activity: Activity<NucleicSessionAttributes>? private var activity: Activity<NucleicSessionAttributes>?
/// The last content we pushed. Updates that don't change it are skipped so we don't spend /// The last content we pushed. Updates that don't change it are skipped so we don't spend
@@ -22,6 +29,15 @@ final class LiveActivityManager {
/// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the /// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the
/// budget on those is exactly what makes a *real* change land late and the glance read stale. /// budget on those is exactly what makes a *real* change land late and the glance read stale.
private var lastState: NucleicSessionAttributes.ContentState? private var lastState: NucleicSessionAttributes.ContentState?
/// When the last update was applied — the clock for the mid-turn churn refresh. A status change
/// (attention-signature shift) updates immediately regardless; a churn-only change (diff totals
/// ticking as the agent edits) only re-applies once `churnRefreshInterval` has elapsed, so a busy
/// turn refreshes the numbers slowly instead of on every transcript delta. A passive timestamp
/// check on the normal path — no timer sits in front of a status update.
private var lastPushAt: Date?
/// How slowly the mid-turn diff totals refresh while nothing else about a session changes —
/// mirrors the host's `SyncHost.churnRefreshInterval` so the local and pushed glances agree.
private static let churnRefreshInterval: TimeInterval = 45
/// The newest state waiting to be applied, and the single task draining it. Coalescing to the /// The newest state waiting to be applied, and the single task draining it. Coalescing to the
/// latest through one serial task means the newest data always wins — firing an unstructured /// latest through one serial task means the newest data always wins — firing an unstructured
/// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance. /// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
@@ -44,9 +60,13 @@ final class LiveActivityManager {
let needsYou = live.filter { $0.status.needsYou($0.disposition) } let needsYou = live.filter { $0.status.needsYou($0.disposition) }
guard !running.isEmpty || !needsYou.isEmpty else { guard !running.isEmpty || !needsYou.isEmpty else {
end() finishWithDoneGlance(hostName: hostName, live: live)
return return
} }
// Work is active again — abort any pending "Done" dismissal so the glance doesn't clear
// out from under a run that just resumed (or a new one that just started).
dismissTask?.cancel()
dismissTask = nil
// Everything in flight or waiting on the user, attention-first (approvals, then waiting // Everything in flight or waiting on the user, attention-first (approvals, then waiting
// input, then running), freshest within a rank. This is both the detail-row source and // input, then running), freshest within a rank. This is both the detail-row source and
@@ -76,11 +96,58 @@ final class LiveActivityManager {
push(state, hostName: hostName) push(state, hostName: hostName)
} }
/// Apply `state` to the Activity — deduped against the last push and coalesced through one /// Nothing is running or waiting. If the glance was showing active work, flash a brief "Done"
/// serial task so the newest state always wins. /// summary of the just-completed sessions and then dismiss (UX_IOS §5.3 — a run should read as
/// *finished*, not just vanish). If nothing was on screen, there's nothing to close.
private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
// Already flashing "Done" and scheduled to dismiss — let that run rather than restart it.
guard dismissTask == nil else { return }
// Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering.
guard activity != nil else {
if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() }
return
}
// The sessions that just finished — completed conversational turns and finished runs. These
// are exactly the ones the active-work filter above drops, surfaced now as `.done` rows.
let done = live
.filter {
$0.status == .finished
|| ($0.status == .awaitingInput && $0.disposition == .completed)
}
.sorted(by: StatusStyle.attentionThenRecency)
.prefix(Self.maxLines)
.map(Self.line(for:))
// Nothing to celebrate (e.g. the work was discarded/deleted) → just dismiss.
guard !done.isEmpty else { end(); return }
let state = NucleicSessionAttributes.ContentState(
runningCount: 0, needsYouCount: 0, approvalCount: 0,
filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
push(state, hostName: hostName)
dismissTask = Task { [weak self] in
try? await Task.sleep(for: Self.doneLinger)
guard !Task.isCancelled else { return }
self?.end()
}
}
/// Apply `state` to the Activity — coalesced through one serial task so the newest state always
/// wins, and gated on the attention signature so a mid-turn churn tick (which changes the diff
/// 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) { private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
guard state != lastState else { return } // 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 lastState = state
lastPushAt = Date()
guard let activity else { guard let activity else {
// Recover an Activity that survived an app relaunch before starting a new one; the // Recover an Activity that survived an app relaunch before starting a new one; the
@@ -88,6 +155,12 @@ final class LiveActivityManager {
if let existing = Activity<NucleicSessionAttributes>.activities.first { if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing activity = existing
observePushToken(existing) observePushToken(existing)
// Dismiss any duplicates so only the adopted Activity renders. iOS stacks multiple
// Activities of one type in the Dynamic Island — an orphan (from a prior launch that
// was killed before `end()`) then shows *its* stale state in the collapsed pill while
// 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)
enqueue(state) enqueue(state)
} else { } else {
// `pushType: .token` opts the activity into APNs updates — the Macs push new // `pushType: .token` opts the activity into APNs updates — the Macs push new
@@ -135,17 +208,38 @@ final class LiveActivityManager {
updateTask = nil updateTask = nil
tokenObservation?.cancel() tokenObservation?.cancel()
tokenObservation = nil tokenObservation = nil
dismissTask?.cancel()
dismissTask = nil
pendingState = nil pendingState = nil
lastState = nil lastState = nil
guard let activity else { return } lastPushAt = nil
let tracked = activity
self.activity = nil self.activity = nil
onActivityEnded?(activity.id) // let the Macs stop pushing to this token if let tracked { onActivityEnded?(tracked.id) } // let the Macs stop pushing to this token
// Dismiss the tracked Activity *and any strays*: ending only the one we track would leave an
// orphan (from a prior launch killed before `end()`) rendering a stale "needs attention"
// glance in the Dynamic Island after the work it described has finished. Clear them all so
// "everything idle" can't get stuck on screen.
Task { Task {
for activity in Activity<NucleicSessionAttributes>.activities {
await activity.end( await activity.end(
ActivityContent(state: activity.content.state, staleDate: nil), ActivityContent(state: activity.content.state, staleDate: nil),
dismissalPolicy: .immediate) dismissalPolicy: .immediate)
} }
} }
}
/// Dismiss every live Activity except `keep` — collapses accidental duplicates down to one so
/// iOS can't render a stale orphan alongside the tracked glance.
private func endStrays(keeping keep: String) {
for stray in Activity<NucleicSessionAttributes>.activities where stray.id != keep {
Task {
await stray.end(
ActivityContent(state: stray.content.state, staleDate: nil),
dismissalPolicy: .immediate)
}
}
}
// MARK: - Projection // MARK: - Projection
@@ -115,6 +115,7 @@ final class HostConnection {
private var eventTask: Task<Void, Never>? private var eventTask: Task<Void, Never>?
private var connectTask: Task<Void, Never>? private var connectTask: Task<Void, Never>?
private var retryTask: Task<Void, Never>? private var retryTask: Task<Void, Never>?
private var revalidateTask: Task<Void, Never>?
private var lanConnectTimeout: Task<Void, Never>? private var lanConnectTimeout: Task<Void, Never>?
private var reconnectAttempts = 0 private var reconnectAttempts = 0
private var seenSeq: Set<UInt64> = [] private var seenSeq: Set<UInt64> = []
@@ -688,6 +689,36 @@ final class HostConnection {
} }
} }
/// Foreground liveness check — the fast-reconnect path. iOS suspends the app when it's
/// backgrounded, which silently kills the socket; but no `.closed`/`.failed` reaches us while
/// we're frozen, so this connection can wake still marked `.connected`. Trusting that stale
/// state means nothing reconnects until the 55s keepalive deadline (or ~15s TCP keepalive)
/// finally notices — the several-second (or worse) stall on return. Instead: if we're already
/// not live, re-dial now with no backoff; if we *look* live, actively ping and re-dial the
/// instant the link fails to answer within a tight deadline. A pinned host only (a host
/// mid-pairing runs its own one-shot chain and has no `pinnedHost`).
func revalidate() {
guard let host = pinnedHost else { return }
guard connectivity.isLive, let client else {
reconnectAttempts = 0
reconnect(to: host)
return
}
revalidateTask?.cancel()
revalidateTask = Task { [weak self] in
// A generous bound on a pong round-trip (LAN <10ms, relay <200ms) — only paid in full
// when the link is actually dead; a live one answers and exits in ~1 RTT. Kept well
// under the follow-on re-dial so the whole dead-link reconnect stays sub-second.
let alive = await client.probeAlive(within: .milliseconds(600))
guard !Task.isCancelled, let self, self.client === client,
self.connectivity.isLive else { return }
if !alive {
self.reconnectAttempts = 0
self.reconnect(to: host)
}
}
}
private func scheduleRetry(host: PairedHost?) { private func scheduleRetry(host: PairedHost?) {
guard let host else { return } guard let host else { return }
reconnectAttempts += 1 reconnectAttempts += 1
@@ -702,6 +733,7 @@ final class HostConnection {
func teardown() { func teardown() {
retryTask?.cancel(); retryTask = nil retryTask?.cancel(); retryTask = nil
revalidateTask?.cancel(); revalidateTask = nil
connectTask?.cancel(); connectTask = nil connectTask?.cancel(); connectTask = nil
connectPlan = nil connectPlan = nil
teardownClient() teardownClient()
@@ -3,6 +3,9 @@ import Network
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
import NucleicTailnet import NucleicTailnet
#if canImport(UIKit)
import UIKit
#endif
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire /// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same. /// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
@@ -73,6 +76,19 @@ final class RemoteStore: ObservableObject {
private var openSessionHostID: String? private var openSessionHostID: String?
@Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = [] @Published private(set) var openApprovals: [ApprovalRequest] = []
/// The open session is blocked on an approval whose details haven't arrived yet — the state
/// right after opening from a Live Activity or notification while the channel is still
/// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled
/// from the live snapshot) is empty because there's no live channel yet. Drives a lightweight
/// "loading approval" placeholder so a tap from the lock screen lands on the request-in-progress
/// instead of a blank transcript; it flips off the instant the real card arrives (sub-second,
/// thanks to the fast foreground reconnect). Never shown once live — then the snapshot is truth.
var openApprovalLoading: Bool {
guard !connectivity.isLive, openApprovals.isEmpty, let id = openSessionID,
let summary = sessions.first(where: { $0.sessionID == id }) else { return false }
return summary.status == .awaitingApproval || summary.pendingApprovalCount > 0
}
/// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct /// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
/// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's /// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
/// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the /// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
@@ -317,12 +333,48 @@ final class RemoteStore: ObservableObject {
} }
/// Returning to the foreground: the network may have changed while the app was suspended (and /// Returning to the foreground: the network may have changed while the app was suspended (and
/// path/keepalive callbacks were frozen). Re-read the path and re-dial anything not live so the /// path/keepalive callbacks were frozen). Re-read the path, re-dial anything not live, then
/// transport is correct by the time the UI is on screen, rather than after a backoff. /// actively revalidate the connections that still *look* live — a socket the OS killed while we
/// were suspended wakes marked `.connected`, and trusting that would stall the reconnect until
/// the 55s keepalive deadline. `revalidate` pings each such link and re-dials the instant it
/// fails to answer, so the transport is correct (and fast) by the time the UI is on screen.
func onForeground() { func onForeground() {
endBackgroundHold()
guard !demoMode, isPaired else { return } guard !demoMode, isPaired else { return }
pathMonitor.refresh() pathMonitor.refresh()
// Ensure a connection exists for every paired host (and drop unpaired); this re-dials the
// ones already known to be offline.
reconnect() reconnect()
// The zombies from suspension still read `.connected` — probe and re-dial the dead ones.
for conn in connections.values where conn.connectivity.isLive { conn.revalidate() }
}
#if canImport(UIKit)
/// Held while backgrounded so iOS keeps the process (and thus the live sockets + 20s keepalive)
/// running for its short grant, instead of suspending us the moment we background.
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
#endif
/// Entering the background: ask iOS to keep us running briefly so a quick app-switch (glance at
/// another app, tap a Live Activity, answer a banner) doesn't tear the connection down and force
/// a cold reconnect on return. The grant is short (~30s) and best-effort; once it lapses the OS
/// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast.
func onBackground() {
guard !demoMode else { return }
#if canImport(UIKit)
endBackgroundHold()
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") {
[weak self] in self?.endBackgroundHold()
}
#endif
}
private func endBackgroundHold() {
#if canImport(UIKit)
guard backgroundTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
#endif
} }
private func seedDemo() { private func seedDemo() {
@@ -721,6 +773,17 @@ final class RemoteStore: ObservableObject {
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate. /// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
private func refreshAggregate() { private func refreshAggregate() {
NotificationRouter.shared.updateBadge(needsYouCount) NotificationRouter.shared.updateBadge(needsYouCount)
// When no session is blocked on an approval anymore, recall the "waiting for your approval"
// wake tickle a prior push may have left on the lock screen. Complements the relay's silent
// clear (which covers phones that were away): this catches the phone that saw the tickle and
// then connected, so the resolution arrives as a broadcast rather than a push. Idempotent —
// a no-op when the tickle isn't there. Per-approval locals are withdrawn as each resolves.
let approvalsPending = liveSessions.contains {
$0.pendingApprovalCount > 0 || $0.status == .awaitingApproval
}
if !approvalsPending {
NotificationRouter.shared.withdrawApprovalAttention()
}
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions) LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
} }
@@ -20,6 +20,12 @@ final class NotificationRouter: NSObject {
/// The store notifications act on; set once at app start. /// The store notifications act on; set once at app start.
weak var store: RemoteStore? weak var store: RemoteStore?
/// The delivered identifier of the remote wake tickle ("A session is waiting for your
/// approval"). A remote notification's identifier equals its `apns-collapse-id`, so this must
/// match the relay's `APPROVAL_COLLAPSE_ID` (cloud/nucleic-edge/src/apns.ts) for the recall to
/// target the right notification.
static let approvalTickleIdentifier = "approval-pending"
// Category / action identifiers (also referenced from the notification service side). // Category / action identifiers (also referenced from the notification service side).
static let approvalCategory = "NUCLEIC_APPROVAL" static let approvalCategory = "NUCLEIC_APPROVAL"
static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE" static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE"
@@ -102,6 +108,23 @@ final class NotificationRouter: NSObject {
center.removePendingNotificationRequests(withIdentifiers: [identifier]) center.removePendingNotificationRequests(withIdentifiers: [identifier])
} }
/// Recall the shared "waiting for your approval" attention notification once nothing is pending
/// anywhere. This is the remote wake tickle (delivered under `approvalTickleIdentifier`) that a
/// prior push left on the lock screen — the counterpart to the per-approval `withdrawApproval`.
/// Arrives two ways: the relay's silent `clear` push (phone was away), or the store's own
/// reconcile when a live socket sees the last approval resolve. Also sweeps any per-approval
/// locals a live socket posted but hasn't individually withdrawn, so the lock screen ends clean.
func withdrawApprovalAttention() {
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier])
center.removePendingNotificationRequests(withIdentifiers: [Self.approvalTickleIdentifier])
center.getDeliveredNotifications { delivered in
let stale = delivered.map(\.request.identifier).filter { $0.hasPrefix("approval-") }
guard !stale.isEmpty else { return }
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: stale)
}
}
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8). /// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
func updateBadge(_ count: Int) { func updateBadge(_ count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count) UNUserNotificationCenter.current().setBadgeCount(count)
@@ -31,11 +31,18 @@ struct NucleicRemoteApp: App {
// activity flag current so it only notifies for background transitions. // activity flag current so it only notifies for background transitions.
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
store.setScenePhaseActive(phase == .active) store.setScenePhaseActive(phase == .active)
if phase == .active { switch phase {
case .active:
// Re-plan transports immediately on foreground — the network may have // Re-plan transports immediately on foreground — the network may have
// changed while suspended, so don't wait for a dead socket to notice. // changed while suspended, so don't wait for a dead socket to notice.
store.onForeground() store.onForeground()
Task { await HeartbeatReporter.reportIfDue() } Task { await HeartbeatReporter.reportIfDue() }
case .background:
// Hold the process (and its live sockets) briefly so a quick app-switch
// doesn't drop the connection and force a cold reconnect on return.
store.onBackground()
default:
break
} }
} }
} }
@@ -45,4 +45,21 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate {
) { ) {
// Best-effort: no token → the phone still works on LAN, just no remote wake. // Best-effort: no token → the phone still works on LAN, just no remote wake.
} }
/// Silent pushes. The relay sends a content-free `clear` (content-available) to recall the
/// "waiting for your approval" tickle once the approval is resolved on another device — we
/// wake in the background just long enough to withdraw the stale notification. Other silent
/// pushes (e.g. the wake tickle itself, which is an alert) don't route here.
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
if userInfo["clear"] != nil {
// Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a
// quick UNUserNotificationCenter call, so we can report .noData right away.
Task { @MainActor in NotificationRouter.shared.withdrawApprovalAttention() }
}
completionHandler(.noData)
}
} }
@@ -310,6 +310,12 @@ struct SessionDetailView: View {
} else { } else {
ApprovalCardView(approval: approval, availableHeight: availableHeight) ApprovalCardView(approval: approval, availableHeight: availableHeight)
} }
} else if store.openApprovalLoading {
// Opened from a Live Activity / notification for a session blocked on approval, but
// the channel is still (re)connecting — bridge the gap with a placeholder so the tap
// lands on the request-in-progress, not a blank transcript. Replaced by the real
// ApprovalCardView the instant the snapshot arrives (sub-second reconnect).
approvalLoadingCard
} else if let status = summary?.status { } else if let status = summary?.status {
let running = status == .running let running = status == .running
let canCompose = running || status == .awaitingInput || status.isTerminal let canCompose = running || status == .awaitingInput || status.isTerminal
@@ -369,6 +375,28 @@ struct SessionDetailView: View {
} }
} }
/// The placeholder shown while an approval's details are still loading — mirrors the approval
/// card's frame (orange warning header, glass surface) so the real `ApprovalCardView` swaps in
/// without a jump. Non-actionable: the real approval id isn't known yet, so there's nothing to
/// Allow/Deny until the snapshot lands.
private var approvalLoadingCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Permission requested").font(.subheadline.weight(.semibold))
}
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Connecting to your Mac to load this approval…")
.font(.footnote).foregroundStyle(.secondary)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14).padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
}
/// A queued follow-up shown above the composer: its text (or attachment names) with the /// A queued follow-up shown above the composer: its text (or attachment names) with the
/// attachments as chips, and — at control scope — a ✕ that cancels just this message. /// attachments as chips, and — at control scope — a ✕ that cancels just this message.
@ViewBuilder @ViewBuilder
@@ -64,6 +64,19 @@ struct NucleicSessionAttributes: ActivityAttributes {
/// The most urgent sessions, attention-first (capped, typically ≤3): the detail rows. /// The most urgent sessions, attention-first (capped, typically ≤3): the detail rows.
var lines: [SessionLine] var lines: [SessionLine]
/// The attention-bearing fingerprint of the glance: the headline counts plus each row's
/// identity and `kind`. Deliberately EXCLUDES the mid-turn churn (files/±lines and the
/// churn-derived detail), which ticks on every transcript edit while the agent works.
/// `LiveActivityManager` gates its `activity.update` on this so the glance refreshes on a
/// status change — a session starting, finishing, needing approval/input — not on every diff
/// delta mid-turn (which would burn ActivityKit's update budget for an illegible number).
/// Mirrors the host's `LiveActivitySnapshot.attentionSignature`.
var attentionSignature: [String] {
var parts = ["run:\(runningCount)", "need:\(needsYouCount)", "appr:\(approvalCount)"]
parts.append(contentsOf: lines.map { "\($0.id):\($0.kind.rawValue)" })
return parts
}
/// The single most urgent session (drives the compact/minimal regions). /// The single most urgent session (drives the compact/minimal regions).
var top: SessionLine? { lines.first } var top: SessionLine? { lines.first }
/// Whether anything needs the user (waiting session or a pending approval). /// Whether anything needs the user (waiting session or a pending approval).