nvrsion: Add: Increase sidebar width in Settings panel by ~30% in Sources/NucleicApp/SettingsView.swift; fix iOS app network reconnection delay on app close; update iOS Live Activity Updates to reduce unnecessary push notifications in Sync, Protocol, and Remote modules; adjust iOS notifications to recall “A session is waiting for your approval” push when approval is received on another device; adjust iOS Live Activity Updates to send updates only on transcript changes in Sources/NucleicCore/Sync/SyncHost.swift, Sources/NucleicProtocol/Sync/LiveActivity.swift, ios/NucleicRemote/NucleicRemote/LiveActivityManager.swift, and Tests/NucleicProtocolTests/LiveActivitySnapshotTests.swift.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -20,6 +20,14 @@
|
||||
target's "Stamp git commit into Info.plist" build phase (git rev-parse --short HEAD). -->
|
||||
<key>NucleicCommit</key>
|
||||
<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>
|
||||
<true/>
|
||||
<!-- 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.
|
||||
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>?
|
||||
|
||||
/// The last content we pushed. Updates that don't change it are skipped so we don't spend
|
||||
@@ -44,9 +51,13 @@ final class LiveActivityManager {
|
||||
let needsYou = live.filter { $0.status.needsYou($0.disposition) }
|
||||
|
||||
guard !running.isEmpty || !needsYou.isEmpty else {
|
||||
end()
|
||||
finishWithDoneGlance(hostName: hostName, live: live)
|
||||
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
|
||||
// input, then running), freshest within a rank. This is both the detail-row source and
|
||||
@@ -76,10 +87,47 @@ final class LiveActivityManager {
|
||||
push(state, hostName: hostName)
|
||||
}
|
||||
|
||||
/// Apply `state` to the Activity — deduped against the last push and coalesced through one
|
||||
/// serial task so the newest state always wins.
|
||||
/// Nothing is running or waiting. If the glance was showing active work, flash a brief "Done"
|
||||
/// 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) {
|
||||
guard state != lastState else { return }
|
||||
guard state.attentionSignature != lastState?.attentionSignature else { return }
|
||||
lastState = state
|
||||
|
||||
guard let activity else {
|
||||
@@ -88,6 +136,12 @@ final class LiveActivityManager {
|
||||
if let existing = Activity<NucleicSessionAttributes>.activities.first {
|
||||
activity = 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)
|
||||
} else {
|
||||
// `pushType: .token` opts the activity into APNs updates — the Macs push new
|
||||
@@ -135,15 +189,35 @@ final class LiveActivityManager {
|
||||
updateTask = nil
|
||||
tokenObservation?.cancel()
|
||||
tokenObservation = nil
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
pendingState = nil
|
||||
lastState = nil
|
||||
guard let activity else { return }
|
||||
let tracked = activity
|
||||
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 {
|
||||
await activity.end(
|
||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
for activity in Activity<NucleicSessionAttributes>.activities {
|
||||
await activity.end(
|
||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ final class HostConnection {
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var connectTask: Task<Void, Never>?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var revalidateTask: Task<Void, Never>?
|
||||
private var lanConnectTimeout: Task<Void, Never>?
|
||||
private var reconnectAttempts = 0
|
||||
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?) {
|
||||
guard let host else { return }
|
||||
reconnectAttempts += 1
|
||||
@@ -702,6 +733,7 @@ final class HostConnection {
|
||||
|
||||
func teardown() {
|
||||
retryTask?.cancel(); retryTask = nil
|
||||
revalidateTask?.cancel(); revalidateTask = nil
|
||||
connectTask?.cancel(); connectTask = nil
|
||||
connectPlan = nil
|
||||
teardownClient()
|
||||
|
||||
@@ -3,6 +3,9 @@ import Network
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
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
|
||||
/// 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?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@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
|
||||
/// 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
|
||||
@@ -317,12 +333,48 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// transport is correct by the time the UI is on screen, rather than after a backoff.
|
||||
/// path/keepalive callbacks were frozen). Re-read the path, re-dial anything not live, then
|
||||
/// 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() {
|
||||
endBackgroundHold()
|
||||
guard !demoMode, isPaired else { return }
|
||||
pathMonitor.refresh()
|
||||
// Ensure a connection exists for every paired host (and drop unpaired); this re-dials the
|
||||
// ones already known to be offline.
|
||||
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() {
|
||||
@@ -721,6 +773,17 @@ final class RemoteStore: ObservableObject {
|
||||
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
|
||||
private func refreshAggregate() {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ final class NotificationRouter: NSObject {
|
||||
/// The store notifications act on; set once at app start.
|
||||
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).
|
||||
static let approvalCategory = "NUCLEIC_APPROVAL"
|
||||
static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE"
|
||||
@@ -102,6 +108,23 @@ final class NotificationRouter: NSObject {
|
||||
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).
|
||||
func updateBadge(_ count: Int) {
|
||||
UNUserNotificationCenter.current().setBadgeCount(count)
|
||||
|
||||
@@ -31,11 +31,18 @@ struct NucleicRemoteApp: App {
|
||||
// activity flag current so it only notifies for background transitions.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
store.setScenePhaseActive(phase == .active)
|
||||
if phase == .active {
|
||||
switch phase {
|
||||
case .active:
|
||||
// Re-plan transports immediately on foreground — the network may have
|
||||
// changed while suspended, so don't wait for a dead socket to notice.
|
||||
store.onForeground()
|
||||
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.
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 {
|
||||
let running = status == .running
|
||||
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
|
||||
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
|
||||
@ViewBuilder
|
||||
|
||||
@@ -64,6 +64,19 @@ struct NucleicSessionAttributes: ActivityAttributes {
|
||||
/// The most urgent sessions, attention-first (capped, typically ≤3): the detail rows.
|
||||
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).
|
||||
var top: SessionLine? { lines.first }
|
||||
/// Whether anything needs the user (waiting session or a pending approval).
|
||||
|
||||
Reference in New Issue
Block a user