Merge branch 'dev' into canary

This commit is contained in:
2026-07-06 20:39:57 -07:00
21 changed files with 1837 additions and 208 deletions
@@ -357,6 +357,7 @@
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -400,6 +401,7 @@
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) NUCLEIC_APP";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -0,0 +1,138 @@
import AppIntents
import NucleicProtocol
// MARK: - Decision option (§6 enums)
/// The decision an approval intent can carry, mapped from the wire `Decision` (`Approval.swift`).
/// The `allowAlways` cases are the safe subset (session / this-tool); pattern scopes and any allow
/// on a destructive request are handled by the risk gate, not offered here.
enum ApprovalDecisionOption: String, AppEnum {
case allow
case deny
case allowAlwaysSession
case allowAlwaysTool
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Decision")
static let caseDisplayRepresentations: [ApprovalDecisionOption: DisplayRepresentation] = [
.allow: "Allow",
.deny: "Deny",
.allowAlwaysSession: "Always Allow (this session)",
.allowAlwaysTool: "Always Allow (this tool)",
]
/// Whether this decision grants the request (everything but `deny`) — the half the risk gate
/// forbids inline on a high-risk approval.
var isAllow: Bool { self != .deny }
/// The wire `Decision` this option resolves to.
func decision() -> Decision {
switch self {
case .allow: .allow(updatedInput: nil)
case .deny: .deny(reason: nil)
case .allowAlwaysSession: .allowAlways(.session)
case .allowAlwaysTool: .allowAlways(.toolName)
}
}
}
// MARK: - Approval entity (§4.6)
/// A pending approval exposed to Shortcuts/Siri. Backed by `RemoteStore.openApprovals`, which the
/// phone holds in full (id, risk, title) only for the *subscribed* session — so today this surfaces
/// the approvals of the session you're looking at. (A global pending-approvals feed would need the
/// host to carry the top approval's id/risk on the wire summary; see §4.1 / the Live Activity note.)
struct ApprovalEntity: AppEntity, Identifiable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Approval")
static let defaultQuery = ApprovalEntityQuery()
/// `ApprovalID.rawValue`.
var id: String
/// `SessionID.rawValue` of the owning session — routes the decision to the right Mac.
var sessionID: String
var toolName: String
var requestTitle: String
/// `Risk.rawValue`, kept for display; the gate uses `isHighRisk`.
var riskLabel: String
/// Destructive / network / host-exec — never allowed inline (§3.3).
var isHighRisk: Bool
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(toolName): \(requestTitle)",
subtitle: "\(riskLabel)")
}
}
extension ApprovalEntity {
init(_ r: ApprovalRequest) {
self.init(
id: r.id.rawValue,
sessionID: r.sessionID.rawValue,
toolName: r.toolName,
requestTitle: r.title,
riskLabel: r.risk.label,
isHighRisk: r.risk.isHigh)
}
}
struct ApprovalEntityQuery: EntityQuery {
@MainActor
func entities(for identifiers: [ApprovalEntity.ID]) async throws -> [ApprovalEntity] {
let wanted = Set(identifiers)
return RemoteStore.shared.openApprovals
.filter { wanted.contains($0.id.rawValue) }
.map(ApprovalEntity.init)
}
@MainActor
func suggestedEntities() async throws -> [ApprovalEntity] {
RemoteStore.shared.openApprovals.map(ApprovalEntity.init)
}
}
// MARK: - Answer an approval (§4.1, the flagship)
/// Allow or deny what an agent is asking to do. The risk gate (§3.3) is enforced here so an intent
/// can never be a softer approval path than the UI: a high-risk request (destructive / network /
/// host-exec) is never granted inline — it routes to the app's guarded card. Deny always goes
/// straight through. "Already resolved elsewhere" is a friendly no-op (§3.4), not an error, because
/// the host de-dupes a lost first-responder race.
struct AnswerApprovalIntent: AppIntent {
static let title: LocalizedStringResource = "Answer Approval"
static let description = IntentDescription(
"Allow or deny what a Nucleic agent is asking to do.")
@Parameter(title: "Approval")
var approval: ApprovalEntity
@Parameter(title: "Decision", default: .allow)
var decision: ApprovalDecisionOption
init() {}
init(approval: ApprovalEntity, decision: ApprovalDecisionOption) {
self.approval = approval
self.decision = decision
}
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let store = RemoteStore.shared
guard store.isPaired else { throw IntentError.notPaired }
let approvalID = ApprovalID(rawValue: approval.id)
let sessionID = SessionID(rawValue: approval.sessionID)
// §3.3 — high-risk can't be granted inline; route to the app's biometric-gated card.
if approval.isHighRisk, decision.isAllow {
store.route(to: sessionID)
throw IntentError.needsAppConfirmation
}
guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable }
store.respondToApproval(id: approvalID, sessionID: sessionID, decision: decision.decision())
return .result(dialog: decision.isAllow ? "Allowed." : "Denied.")
}
static var parameterSummary: some ParameterSummary {
Summary("\(\.$decision) \(\.$approval)")
}
}
@@ -0,0 +1,29 @@
import AppIntents
/// Friendly, spoken-safe failures for Nucleic's App Intents. The phone is a thin client with no
/// local authority (docs/APP_INTENTS_OPPORTUNITIES §3.1): an intent that can't reach the paired
/// Mac must fail *clean* with a dialog, never silently or with a raw error.
enum IntentError: Error, CustomLocalizedStringResourceConvertible {
/// No live channel to any paired Mac (and none came up within the wait window).
case macUnreachable
/// Not paired with a Mac yet — nothing to act on.
case notPaired
/// The action needs `control` scope, which this device hasn't been granted.
case controlScopeRequired
/// A high-risk approval (destructive/network/host-exec) can't be allowed inline — it must be
/// confirmed on the app's guarded card (docs/APP_INTENTS_OPPORTUNITIES §3.3).
case needsAppConfirmation
var localizedStringResource: LocalizedStringResource {
switch self {
case .macUnreachable:
"Your Mac isn't reachable right now. Try again when it's online."
case .notPaired:
"Pair this iPhone with your Mac in Nucleic first."
case .controlScopeRequired:
"This device can view and approve, but isn't allowed to control sessions."
case .needsAppConfirmation:
"This one's high-risk — open Nucleic to confirm it on the approval card."
}
}
}
@@ -0,0 +1,45 @@
import AppIntents
/// Curates the handful of genuinely voice-worthy actions for Siri / Spotlight (§8: keep the spoken
/// set to ~4 — approve, unblock, open, "what needs me" — and expose the long tail through Shortcuts
/// only). Every phrase must name the app; `\(.applicationName)` resolves to "Nucleic".
struct NucleicShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SessionsNeedingMeIntent(),
phrases: [
"What needs me in \(.applicationName)",
"What's waiting in \(.applicationName)",
"Which agents need me in \(.applicationName)",
],
shortTitle: "What Needs Me",
systemImageName: "bell.badge")
AppShortcut(
intent: AnswerApprovalIntent(),
phrases: [
"Approve in \(.applicationName)",
"Answer an approval in \(.applicationName)",
],
shortTitle: "Answer Approval",
systemImageName: "checkmark.shield")
AppShortcut(
intent: SendFollowUpIntent(),
phrases: [
"Send a follow-up in \(.applicationName)",
"Tell an agent in \(.applicationName)",
],
shortTitle: "Send Follow-Up",
systemImageName: "arrowshape.turn.up.right")
AppShortcut(
intent: OpenSessionIntent(),
phrases: [
"Open a session in \(.applicationName)",
"Open \(.applicationName)",
],
shortTitle: "Open Session",
systemImageName: "bubble.left.and.text.bubble.right")
}
}
@@ -0,0 +1,80 @@
import AppIntents
import NucleicProtocol
/// A Nucleic agent session exposed to Siri / Shortcuts / Spotlight (docs/APP_INTENTS_OPPORTUNITIES
/// §4.6). Backed by the host's `SessionSummary` projection the phone already holds; the entity id
/// is the wire `SessionID`, so it maps straight onto the deep-link and control APIs.
///
/// A thin, `Sendable` value snapshot — never the live store row. The `EntityQuery` re-reads
/// `RemoteStore` each time so a stale Siri suggestion never acts on outdated state.
struct SessionEntity: AppEntity, Identifiable {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Session")
static let defaultQuery = SessionEntityQuery()
/// `SessionID.rawValue`.
var id: String
var title: String
var projectName: String
var statusLabel: String
/// Whether this session is waiting on the user (approval, or a non-completed input turn).
var needsAttention: Bool
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(projectName) · \(statusLabel)")
}
}
extension SessionEntity {
/// Project a wire summary into the entity (falling back to the project name for an untitled
/// session, exactly as the session list does).
init(_ s: WireSessionSummary) {
self.init(
id: s.sessionID.rawValue,
title: s.title.isEmpty ? s.projectName : s.title,
projectName: s.projectName,
statusLabel: StatusStyle.label(s.status, disposition: s.disposition),
needsAttention: s.status.needsYou(s.disposition))
}
}
/// Resolves `SessionEntity` values for Shortcuts/Siri from the phone's current (or cached) session
/// list. Discovery paths (`suggestedEntities`, string matching) bootstrap the channel first so a
/// cold intent process still has data; id resolution stays fast and offline-tolerant.
struct SessionEntityQuery: EntityQuery {
@MainActor
func entities(for identifiers: [SessionEntity.ID]) async throws -> [SessionEntity] {
let wanted = Set(identifiers)
return RemoteStore.shared.liveSessions
.filter { wanted.contains($0.sessionID.rawValue) }
.map(SessionEntity.init)
}
@MainActor
func suggestedEntities() async throws -> [SessionEntity] {
let store = RemoteStore.shared
store.bootstrapForIntent()
return store.liveSessions
.sorted(by: StatusStyle.attentionThenRecency)
.prefix(12)
.map(SessionEntity.init)
}
}
/// Lets Siri match a session by spoken name ("open payment-flow") against title or project.
extension SessionEntityQuery: EntityStringQuery {
@MainActor
func entities(matching string: String) async throws -> [SessionEntity] {
let store = RemoteStore.shared
store.bootstrapForIntent()
let needle = string.lowercased()
return store.liveSessions
.filter {
let title = ($0.title.isEmpty ? $0.projectName : $0.title).lowercased()
return title.contains(needle) || $0.projectName.lowercased().contains(needle)
}
.sorted(by: StatusStyle.attentionThenRecency)
.map(SessionEntity.init)
}
}
@@ -0,0 +1,101 @@
import AppIntents
import NucleicProtocol
// MARK: - Open a session (deep-link intent, §4.4)
/// Open a specific session in the app. Reuses the existing `nucleic://session/<id>` route via
/// `RemoteStore.route(to:)` — so a Spotlight result, a Siri phrase, or a Shortcut jumps straight
/// into the waiting session. `openAppWhenRun` foregrounds the app (this is a navigation action).
struct OpenSessionIntent: AppIntent {
static let title: LocalizedStringResource = "Open Session"
static let description = IntentDescription("Open a Nucleic agent session.")
static let openAppWhenRun = true
@Parameter(title: "Session")
var session: SessionEntity
init() {}
init(session: SessionEntity) { self.session = session }
@MainActor
func perform() async throws -> some IntentResult {
RemoteStore.shared.route(to: SessionID(rawValue: session.id))
return .result()
}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$session)")
}
}
// MARK: - Unblock with a follow-up (§4.2, approve-scope)
/// Send a follow-up prompt to a paused agent — the "type the next step to unblock it" flow that
/// stays at `approve` scope (docs/UX_IOS §2). Runs in the background over the app's live channel
/// (no foregrounding); if the Mac isn't reachable it fails clean with a spoken error (§3.1).
struct SendFollowUpIntent: AppIntent {
static let title: LocalizedStringResource = "Send Follow-Up"
static let description = IntentDescription(
"Send a follow-up prompt to a Nucleic agent to unblock or steer it.")
@Parameter(title: "Session")
var session: SessionEntity
@Parameter(title: "Message", requestValueDialog: "What should the agent do next?")
var text: String
init() {}
init(session: SessionEntity, text: String) {
self.session = session
self.text = text
}
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let store = RemoteStore.shared
guard store.isPaired else { throw IntentError.notPaired }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .result(dialog: "That message was empty.") }
guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable }
store.sendInput(trimmed, to: SessionID(rawValue: session.id))
return .result(dialog: "Sent to \(session.title).")
}
static var parameterSummary: some ParameterSummary {
Summary("Tell \(\.$session) to \(\.$text)")
}
}
// MARK: - "What needs me?" (§4.3)
/// List the agents waiting on the user — for approval, or a next prompt — using the same attention
/// rule as the badge and NEEDS YOU section (`SessionStatus.needsYou`). Answers Siri's "what needs me
/// in Nucleic?" and feeds Shortcuts automations. Runs in the background; returns the list plus a
/// spoken summary.
struct SessionsNeedingMeIntent: AppIntent {
static let title: LocalizedStringResource = "Sessions Needing Me"
static let description = IntentDescription(
"List the Nucleic agents waiting on you — for approval or your next prompt.")
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<[SessionEntity]> & ProvidesDialog {
let store = RemoteStore.shared
guard store.isPaired else { throw IntentError.notPaired }
store.bootstrapForIntent()
// Give a cold process a brief moment to bring a channel up so the answer is current; fall
// back to the persisted list if the Mac stays unreachable.
_ = await store.awaitLiveConnection(timeout: 4)
let needy = store.liveSessions
.filter { $0.status.needsYou($0.disposition) }
.sorted(by: StatusStyle.attentionThenRecency)
.map(SessionEntity.init)
let dialog: IntentDialog
switch needy.count {
case 0: dialog = "Nothing needs you right now."
case 1: dialog = "One agent needs you: \(needy[0].title)."
default: dialog = "\(needy.count) agents need you."
}
return .result(value: needy, dialog: dialog)
}
}
@@ -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)
} }
@@ -3,3 +3,8 @@
this localized alert is all the lock screen shows. The app pulls the real approval this localized alert is all the lock screen shows. The app pulls the real approval
over the encrypted channel on open. */ over the encrypted channel on open. */
"approval.pending" = "A session is waiting for your approval"; "approval.pending" = "A session is waiting for your approval";
/* The same content-free wake, but the block is an agent question (`AskUserQuestion`) rather than
a tool approval — the host tags the wake `kind:"question"` so the lock screen reads accurately.
Still content-free: the real question is pulled over the encrypted channel on open. */
"question.pending" = "A session is waiting for your answer";
@@ -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,28 +646,47 @@ 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 }
// Merge, don't replace: a fresh open merges into an empty transcript (the tail window), // Merge, don't replace: a fresh open merges into an empty transcript (the tail window),
// while a reconnect's warm-resubscribe delta appends to the history already on screen // while a reconnect's warm-resubscribe delta appends to the history already on screen
// instead of truncating it to the host's 200-event tail. // instead of truncating it to the host's 200-event tail. Drain the streaming buffer
// first so the seq-dedup merge sees the full stream (a buffered event the snapshot
// also carries would otherwise be re-appended as a duplicate after the merge).
self.drainPendingOpenEvents()
self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents) self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
self.openApprovals = snap.pendingApprovals self.openApprovals = snap.pendingApprovals
self.persistOpenTranscript() self.persistOpenTranscript()
} }
cb.openEvents = { [weak self] batch in cb.openEvents = { [weak self] batch in
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
self.openEvents.append(contentsOf: batch.events) self.enqueueOpenEvents(batch.events)
self.persistOpenTranscript()
} }
cb.openBackfill = { [weak self] batch in cb.openBackfill = { [weak self] batch in
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return } guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
// Full-history backfill precedes what's on screen — merge by seq so it slots in above the // Full-history backfill precedes what's on screen — merge by seq so it slots in above the
// tail rather than appending out of order. // tail rather than appending out of order. Drain first (same reason as openSnapshot).
self.drainPendingOpenEvents()
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
@@ -722,41 +753,57 @@ final class RemoteStore: ObservableObject {
/// or a representative one. No-op in demo, which seeds the aggregate directly. /// or a representative one. No-op in demo, which seeds the aggregate directly.
private func rebuildAggregate() { private func rebuildAggregate() {
guard !demoMode else { return } guard !demoMode else { return }
// Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire
// frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain
// `@Published` assignment fires `objectWillChange` even when the value is identical —
// re-evaluating every view observing the store for nothing. Equality checks over these
// small aggregates are far cheaper than a whole-tree SwiftUI invalidation.
let live = aggregatedSessions() let live = aggregatedSessions()
if !live.isEmpty { if !live.isEmpty {
if sessions != live {
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.
sessions = [] setIfChanged(\.sessions, [])
} else { } else {
// Offline: keep showing the saved history rather than blanking the list. // Offline: keep showing the saved history rather than blanking the list.
sessions = cachedSummaries setIfChanged(\.sessions, cachedSummaries)
} }
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard)) setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard)))
meshPeers = connections.values.flatMap(\.meshPeers) setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers))
let ctx = contextConnection let ctx = contextConnection
hostName = ctx?.hostName ?? "" setIfChanged(\.hostName, ctx?.hostName ?? "")
capabilities = ctx?.capabilities setIfChanged(\.capabilities, ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
modelCatalog = ctx?.modelCatalog ?? .empty setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
if let id = openSessionHostID, let conn = connections[id] { if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity // While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances. // gates send and its scope drives the control affordances.
connectivity = conn.connectivity setIfChanged(\.connectivity, conn.connectivity)
grantedScope = conn.grantedScope setIfChanged(\.grantedScope, conn.grantedScope)
} else { } else {
connectivity = aggregateConnectivity() setIfChanged(\.connectivity, aggregateConnectivity())
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there). // enforces scope when the intent lands there).
grantedScope = connections.values setIfChanged(\.grantedScope, connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve)
} }
} }
/// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
/// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation).
private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<RemoteStore, T>, _ value: T) {
if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value }
}
/// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
/// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on
/// screen without duplicating what's shown or dropping history outside the host's tail window. /// screen without duplicating what's shown or dropping history outside the host's tail window.
@@ -790,10 +837,130 @@ final class RemoteStore: ObservableObject {
Task { [weak self] in Task { [weak self] in
let cached = await SessionCache.loadEvents(sessionID) let cached = await SessionCache.loadEvents(sessionID)
guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return } guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
// Drain any live deltas first so the seq-dedup merge sees the complete stream.
self.drainPendingOpenEvents()
self.openEvents = Self.mergedEvents(cached, self.openEvents) self.openEvents = Self.mergedEvents(cached, self.openEvents)
} }
} }
// 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
/// while the agent talks — and every `openEvents` mutation fires `objectWillChange`, which
/// re-evaluates *every* view observing the store (the Sessions/Home tabs stay mounted behind
/// the pushed session detail, so they pay this too). Buffer incoming deltas and publish at
/// most one append per `openEventsFlushInterval`: the first delta after a quiet gap applies
/// immediately (the leading edge — first-token latency stays imperceptible), followers ride
/// the next scheduled flush. ~10 UI updates/sec still reads as live streaming; the view tree
/// stops being invalidated per wire frame. Merge/close/flush paths drain the buffer first, so
/// nothing downstream ever sees a partial stream.
private var pendingOpenEvents: [AgentEvent] = []
private var openEventsFlushTask: Task<Void, Never>?
private var lastOpenEventsFlushAt = Date.distantPast
private static let openEventsFlushInterval: TimeInterval = 0.1
private func enqueueOpenEvents(_ events: [AgentEvent]) {
pendingOpenEvents.append(contentsOf: events)
guard openEventsFlushTask == nil else { return } // a trailing flush is already scheduled
let elapsed = Date().timeIntervalSince(lastOpenEventsFlushAt)
if elapsed >= Self.openEventsFlushInterval {
drainPendingOpenEvents()
} else {
let delay = Self.openEventsFlushInterval - elapsed
openEventsFlushTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.openEventsFlushTask = nil
self.drainPendingOpenEvents()
}
}
}
/// Publish the buffered deltas (and schedule persistence). Called on the flush cadence, and
/// eagerly by anything that merges, persists, or clears `openEvents`, so those paths always
/// operate on the complete stream.
private func drainPendingOpenEvents() {
openEventsFlushTask?.cancel()
openEventsFlushTask = nil
guard !pendingOpenEvents.isEmpty else { return }
lastOpenEventsFlushAt = Date()
openEvents.append(contentsOf: pendingOpenEvents)
pendingOpenEvents.removeAll(keepingCapacity: true)
persistOpenTranscript()
}
/// Drop buffered deltas without publishing — for session switch/close/unpair, where the
/// buffer belongs to a transcript that is being cleared (a stale session's tail must never
/// leak into the next session's freshly-opened transcript).
private func discardPendingOpenEvents() {
openEventsFlushTask?.cancel()
openEventsFlushTask = nil
pendingOpenEvents.removeAll(keepingCapacity: true)
}
/// Debounced write of the open transcript, called after each batch of events lands. /// Debounced write of the open transcript, called after each batch of events lands.
private func persistOpenTranscript() { private func persistOpenTranscript() {
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return } guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
@@ -910,6 +1077,7 @@ final class RemoteStore: ObservableObject {
openSessionID = nil openSessionID = nil
compactDetailPresented = false compactDetailPresented = false
openSessionHostID = nil openSessionHostID = nil
discardPendingOpenEvents()
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
connectivity = .unpaired connectivity = .unpaired
// No Mac left whose history to hold — drop the offline cache too. // No Mac left whose history to hold — drop the offline cache too.
@@ -944,6 +1112,7 @@ final class RemoteStore: ObservableObject {
openSessionID = nil openSessionID = nil
compactDetailPresented = false compactDetailPresented = false
openSessionHostID = nil openSessionHostID = nil
discardPendingOpenEvents()
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
openDiff = nil openDiff = nil
@@ -972,6 +1141,7 @@ final class RemoteStore: ObservableObject {
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and // Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
// the host-specific projected values follow it. // the host-specific projected values follow it.
openSessionHostID = connection(owningSession: sessionID)?.hostID openSessionHostID = connection(owningSession: sessionID)?.hostID
discardPendingOpenEvents()
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
openDiff = nil openDiff = nil
@@ -1149,6 +1319,8 @@ final class RemoteStore: ObservableObject {
compactDetailPresented = false compactDetailPresented = false
openSessionHostID = nil openSessionHostID = nil
// Persist the final transcript before clearing it, so it's warm for the next open / offline. // Persist the final transcript before clearing it, so it's warm for the next open / offline.
// Buffered streaming deltas are part of that transcript — publish them first.
drainPendingOpenEvents()
flushOpenTranscript(target, openEvents) flushOpenTranscript(target, openEvents)
openEvents = [] openEvents = []
openApprovals = [] openApprovals = []
@@ -1225,6 +1397,64 @@ final class RemoteStore: ObservableObject {
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) } func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) } func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
// MARK: - App Intents support
/// Bring networking online and dial the paired host(s) for an App Intent that runs in a *cold*
/// background process (Siri / Shortcuts / a widget or Live Activity button), where the SwiftUI
/// scene never mounts and `onAppear` never fires. Mirrors the push handler's silent-launch
/// bootstrap (`startNetworkingIfNeeded` + `reconnect`). Idempotent; a no-op in demo mode.
func bootstrapForIntent() {
guard !demoMode else { return }
startNetworkingIfNeeded()
if isPaired {
// Show the persisted session list immediately so a cold intent query (Siri/Spotlight)
// has data to answer with before any host connects — same seed as `onAppear`.
if sessions.isEmpty { sessions = cachedSummaries }
reconnect()
}
}
/// Whether any paired Mac currently has a live channel.
var hasLiveConnection: Bool { connections.values.contains { $0.connectivity.isLive } }
/// Await a live connection to any paired Mac, up to `timeout` seconds — an intent must act over a
/// *live* channel or fail clean (UX_IOS §6, §3.1). Brings networking up first if it's cold, then
/// polls until a link comes up or the deadline passes. Returns whether a link is live.
func awaitLiveConnection(timeout: TimeInterval = 6) async -> Bool {
if demoMode || hasLiveConnection { return true }
bootstrapForIntent()
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try? await Task.sleep(for: .milliseconds(200))
if hasLiveConnection { return true }
}
return hasLiveConnection
}
/// Resolve an approval by id from an App Intent with a full `Decision`. Like
/// `respondFromNotification`, it prefers the owning Mac, else broadcasts to every live Mac (the
/// owner resolves; the rest see an unknown / `alreadyResolved` id and no-op), and queues briefly
/// on a dropped link so a decision made just as the socket blips still lands. `sessionID` (when
/// the surface knows it) routes directly. Returns whether it went out over a live channel now.
@discardableResult
func respondToApproval(id: ApprovalID, sessionID: SessionID?, decision: Decision) -> Bool {
if demoMode { demoHandle(.approvalRespond(id, decision)); return true }
if let sessionID, let conn = connection(owningSession: sessionID), conn.connectivity.isLive {
conn.send(.approvalRespond(id, decision))
openApprovals.removeAll { $0.id == id }
return true
}
let live = connections.values.filter { $0.connectivity.isLive }
if !live.isEmpty {
for conn in live { conn.send(.approvalRespond(id, decision)) }
openApprovals.removeAll { $0.id == id }
return true
}
pendingNotificationDecision = (id, decision, Date())
reconnect()
return false
}
// MARK: - Plumbing // MARK: - Plumbing
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
@@ -1273,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
@@ -1296,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.
@@ -1361,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
@@ -1418,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
@@ -108,21 +108,15 @@ 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 /// Recall the generic remote wake tickle ("A session is waiting for your approval") once no
/// anywhere. This is the remote wake tickle (delivered under `approvalTickleIdentifier`) that a /// approval is pending anywhere. This targets *only* the shared tickle (delivered under
/// prior push left on the lock screen — the counterpart to the per-approval `withdrawApproval`. /// `approvalTickleIdentifier`, its APNs collapse-id) — the content-free push a phone gets while
/// Arrives two ways: the relay's silent `clear` push (phone was away), or the store's own /// away. Per-approval notifications are each recalled by `withdrawApproval(_:)` as they resolve,
/// reconcile when a live socket sees the last approval resolve. Also sweeps any per-approval /// so a still-pending approval's own banner is never swept away by this.
/// locals a live socket posted but hasn't individually withdrawn, so the lock screen ends clean.
func withdrawApprovalAttention() { func withdrawApprovalAttention() {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier]) center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier])
center.removePendingNotificationRequests(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).
@@ -31,18 +31,20 @@ struct SessionDetailView: View {
} }
var body: some View { var body: some View {
// The detail's height comes from the enclosing GeometryReader — a value fixed by the parent
// (the navigation content area), never by anything inside it. The attention cards (approval /
// question) cap themselves to this so their buttons stay on-screen. Sourcing it from a
// GeometryReader — rather than measuring the transcript, whose height the cards' own bottom
// `safeAreaInset` resizes — makes the measurement categorically independent of the card, so
// the card can't feed its height back into the value it's sized from. That feedback was an
// unresolved layout cycle that pinned the main thread at 100% CPU during the push transition;
// it showed up intermittently because a feedback loop only diverges for some content/card-
// height combinations. It bit only the attention path (a card consumes this value; the plain
// composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung.
GeometryReader { proxy in
TranscriptList(events: store.openEvents, TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest, scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom) isScrolledToBottom: $isScrolledToBottom)
// Measure the transcript's full height (the floating bar overlays it via safeAreaInset,
// so this frame is the whole screen area the card has to live within) and hand it to the
// action card so it can bound itself.
.background {
GeometryReader { proxy in
Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height)
}
}
.onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 }
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting // The chat bar floats over the scrolling content on Liquid Glass instead of sitting
// in a boxed strip below it, so the transcript runs the full height of the screen. // in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea } .safeAreaInset(edge: .bottom) { actionArea }
@@ -81,6 +83,14 @@ struct SessionDetailView: View {
// disappears) unsubscribes A without tearing down B's just-opened state. // disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) } .onDisappear { store.closeOpen(sessionID) }
.background { interruptShortcut } .background { interruptShortcut }
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
// resize). Because `proxy.size.height` never depends on the card, updating this can't
// re-drive the measurement — no cycle.
.onChange(of: proxy.size.height, initial: true) { _, height in
availableHeight = height
}
}
} }
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
@@ -113,17 +123,37 @@ struct SessionDetailView: View {
set: { store.setSessionEffort(sessionID, $0) }) set: { store.setSessionEffort(sessionID, $0) })
} }
/// Memo for the context-occupancy scan below. The backward scan usually stops at the last
/// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole
/// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan
/// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as
/// in the projection cache; a class held in `@State` so updating it from `body` doesn't
/// itself invalidate the view.
private final class ContextScanCache {
var count = -1
var lastSeq: UInt64 = 0
var used: Int?
}
@State private var contextScanCache = ContextScanCache()
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read /// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
/// from the transcript exactly as the Mac header does. /// from the transcript exactly as the Mac header does. Only the token scan is memoized —
/// the window division stays live, so a model switch reflects immediately.
private var contextPercent: Int? { private var contextPercent: Int? {
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in let events = store.openEvents
let cache = contextScanCache
if cache.count != events.count || cache.lastSeq != (events.last?.seq ?? 0) {
cache.count = events.count
cache.lastSeq = events.last?.seq ?? 0
cache.used = events.reversed().lazy.compactMap { event -> Int? in
switch event.kind { switch event.kind {
case .turnCompleted(let turn): return turn.usage?.contextInputTokens case .turnCompleted(let turn): return turn.usage?.contextInputTokens
case .usage(let usage): return usage.contextInputTokens case .usage(let usage): return usage.contextInputTokens
default: return nil default: return nil
} }
}.first { $0 > 0 } }.first { $0 > 0 }
guard let used else { return nil } }
guard let used = cache.used else { return nil }
let window = store.modelCatalog.contextWindow(summary?.model) let window = store.modelCatalog.contextWindow(summary?.model)
guard window > 0 else { return nil } guard window > 0 else { return nil }
return min(100, Int((Double(used) / Double(window)) * 100)) return min(100, Int((Double(used) / Double(window)) * 100))
@@ -254,15 +284,6 @@ struct SessionDetailView: View {
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on. /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
private var actionArea: some View { private var actionArea: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
// The jump-to-bottom chevron rides here, immediately above the chat bar — not as a
// transcript overlay, which aligned to the scroll view's full-height bounds and so sat
// behind this floating bar at the screen's bottom edge. Shown only while scrolled up; a
// tap bumps the same scroll request the send button uses, so following resumes once the
// transcript reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
if !isScrolledToBottom {
JumpToBottomButton { scrollToBottomRequest += 1 }
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
// disabled composer reads as "offline / read-only history" rather than broken. // disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner } if !store.connectivity.isLive { disconnectedBanner }
@@ -270,6 +291,29 @@ struct SessionDetailView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 8) .padding(.bottom, 8)
// The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately
// not a stack member. This whole action area is the transcript's bottom `safeAreaInset`,
// so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it
// appeared — and its visibility is *decided by* that same scroll geometry
// (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every
// frame near the follow threshold, re-layouting the whole eager transcript each time and
// pinning the main thread at 100% with the UI locked. An overlay contributes nothing to
// the inset height, so showing or hiding it can't move the scroll geometry at all.
// (It's not a transcript overlay either — that aligned to the scroll view's full-height
// bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the
// same scroll request the send button uses, so following resumes once the transcript
// reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
.overlay(alignment: .top) {
if !isScrolledToBottom {
JumpToBottomButton { scrollToBottomRequest += 1 }
// Hang the button fully *above* the bar: top-aligned, then shifted up by its
// own 30pt height plus an 8pt gap. A render-time offset (not an alignment
// guide, which misplaced it half-overlapping the bar's top edge) so the
// placement is exact and — critically — contributes nothing to the inset.
.offset(y: -38)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
} }
@@ -352,6 +396,18 @@ struct SessionDetailView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.lineLimit(1...4) .lineLimit(1...4)
.padding(.vertical, 3) .padding(.vertical, 3)
// Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only
// while running and at control scope; send stays at the far right so
// its position never shifts. Mirrors `interruptShortcut`.
if running && store.canControl {
Button { store.interrupt(sessionID) } label: {
Image(systemName: "stop.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.disabled(!store.connectivity.isLive)
.accessibilityLabel("Stop")
}
Button { Button {
store.sendInput(draft, to: sessionID) store.sendInput(draft, to: sessionID)
draft = "" draft = ""
@@ -448,15 +504,6 @@ struct SessionDetailView: View {
} }
} }
/// The transcript's measured height, fed to the floating action card so it can bound itself to the
/// screen (see `availableHeight`).
private struct AvailableHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct TranscriptList: View { struct TranscriptList: View {
let events: [AgentEvent] let events: [AgentEvent]
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens /// Bumped by the parent when the user sends a message — a deliberate "show me what happens
@@ -481,8 +528,61 @@ struct TranscriptList: View {
/// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest.
@State private var transcriptContentHeight: CGFloat = 0 @State private var transcriptContentHeight: CGFloat = 0
/// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers:
/// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps
/// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against
/// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals
/// everything before the live turn once and re-folds only the tail, so a streaming delta
/// costs O(live-tail) instead of re-folding all N events (which made long sessions cost
/// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body`
/// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior).
@State private var projectionCache = IncrementalTranscriptProjection()
/// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes
/// it or the transcript goes away — so a long warm can't outlive the view or stack up behind
/// row churn.
@State private var prewarmTask: Task<Void, Never>?
/// How many projected rows the pre-warm has already covered, so each later trigger snapshots
/// only the *new* rows instead of re-walking (and re-hashing) the whole transcript on every
/// row that lands — which would quietly re-introduce an O(N) main-thread pass per row. Held
/// in a box (not `@State` value) because updating it from `body`-adjacent code must not
/// invalidate the view.
private final class PrewarmProgress { var count = 0 }
@State private var prewarmProgress = PrewarmProgress()
private var items: [TranscriptItem] { private var items: [TranscriptItem] {
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents)
}
/// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText`
/// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a
/// cost that lands on the main thread right as the session is pushed and the tab bar slides
/// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read
/// of the memoized projection), then parse them on a background task so that first layout finds
/// the caches already warm. Idempotent and self-cancelling; the parse results are the same
/// whichever thread fills the (thread-safe) caches. Incremental: only rows beyond the last
/// covered count are snapshotted (coalescing can shuffle nearby indices, but a missed body just
/// parses on first layout as before — the warm is an optimization, never a correctness gate).
private func prewarmMarkdown() {
let current = items
if current.count < prewarmProgress.count { prewarmProgress.count = 0 } // stream reset
let bodies: [String] = current[prewarmProgress.count...].compactMap {
if case .message(_, let text) = $0.kind { return text } else { return nil }
}
prewarmProgress.count = current.count
guard !bodies.isEmpty else { return }
// Chain batches instead of cancelling the in-flight one: each batch covers *new* rows
// only, so cancelling a predecessor (say, the big open batch, superseded by the first
// streamed row) would permanently drop its coverage. `onDisappear` cancels the head of
// the chain; a predecessor mid-parse just finishes its bounded batch into shared caches.
prewarmTask = Task.detached(priority: .utility) { [previous = prewarmTask] in
await previous?.value
for body in bodies {
if Task.isCancelled { return }
MarkdownText.prewarm(body)
}
}
} }
/// How much content may still sit below the viewport's bottom edge and still count as "at /// How much content may still sit below the viewport's bottom edge and still count as "at
@@ -490,6 +590,43 @@ struct TranscriptList: View {
/// the Mac's `bottomFollowThreshold`. /// the Mac's `bottomFollowThreshold`.
private let bottomFollowThreshold: CGFloat = 24 private let bottomFollowThreshold: CGFloat = 24
/// How far a *user* scroll must move away from the bottom before following disengages. Wider
/// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't
/// constant (composer lines grow, the working row appears, the keyboard dismisses
/// interactively), and every inset change perturbs the scroll geometry by tens of points. A
/// single threshold read both ways let one such perturbation flip the gate, whose reactions
/// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that
/// re-layouted the whole eager transcript every frame and pinned the main thread at 100%.
/// 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 (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 == .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 {
@@ -527,22 +664,35 @@ 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
// 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. Within the slack threshold means the // content still sits below the viewport bottom. Parked within the follow threshold
// user is parked at the end (live output keeps following); scrolling up flips this // means live output keeps following; a *user* scroll past the (wider) unfollow
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron. // threshold flips it false, which drops the anchor (above) and reveals the chevron.
.onScrollGeometryChange(for: Bool.self) { geo in // Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y // programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
<= bottomFollowThreshold // animation) may re-engage following but can never disengage it, so no layout
} action: { _, atBottom in // feedback cycle through this gate can sustain itself. Tracking the rounded distance
// While the chat is opening the layout grows over a few passes and the scroll // (not a Bool) also means every scroll emits fresh values, so the gate can't latch
// offset lags each growth by a frame — sampling that frame reads "not at bottom" // against a stale reading (the old settle-window latch bug).
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the .onScrollGeometryChange(for: CGFloat.self) { geo in
// open window accept only "at bottom" readings; honor real scroll-ups after. (geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
if transcriptSettling { } action: { _, distance in
if atBottom { isScrolledToBottom = true } if distance <= bottomFollowThreshold {
} else { isScrolledToBottom = true
isScrolledToBottom = atBottom 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
// only re-engagement (the `!transcriptSettling` above).
isScrolledToBottom = false
} }
} }
// Detect when the opening layout has come to rest: track the content height while // Detect when the opening layout has come to rest: track the content height while
@@ -556,19 +706,54 @@ struct TranscriptList: View {
guard transcriptSettling, transcriptContentHeight > 0 else { return } guard transcriptSettling, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80)) try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return } if Task.isCancelled { return }
// Pin to the true bottom before opening the gate. The settling layout lands a
// hair off the bottom, so the passive `.defaultScrollAnchor` alone leaves the
// scroll geometry reading "not at bottom" — and that stale reading latches
// `onScrollGeometryChange`'s tracked value at `false` while the gate above holds
// `isScrolledToBottom` at `true`. Because the callback only fires on a *change*,
// the user's first real scroll-up (still `false`) then never fires it: the chevron
// never appears and live output keeps yanking the view to the bottom, until a
// down-then-up round-trip finally re-emits the change. An explicit pin nudges the
// offset the last hair, forcing a fresh "at bottom" emission that re-syncs the
// tracker with the gate. Mirrors the Mac's reveal pin.
scrollToEnd(proxy, animated: false)
transcriptSettling = false transcriptSettling = false
} }
// Coalescing means item count lags event count; key the autoscroll on the raw stream // Coalescing means item count lags event count; key the autoscroll on the raw stream
// so every streamed delta keeps the view pinned to the bottom — but only while the // so every streamed delta keeps the view pinned to the bottom — but only while the
// 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
// takes them past the unfollow threshold flips the gate; until then the native
// 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) { .onChange(of: events.count) {
if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } if isScrolledToBottom, !isUserScrolling {
scrollToEnd(proxy, animated: false)
} }
// An explicit jump — the chevron or sending a message — always wins. The chevron }
// itself lives in the parent's chat bar (above the composer), not as an overlay here, // An explicit jump — the chevron or sending a message — always wins, and it *is* the
// so it sits over the composer instead of behind the floating bar; a tap bumps this // user saying "follow again": re-engage the gate directly rather than waiting for the
// same request, and following resumes once the geometry reader sees the bottom. // geometry to confirm. The animated scroll can come to rest a few points short of the
.onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) } // follow threshold (content padding, inset rounding), which sat inside the hysteresis
// band — following never re-engaged and the chevron lingered until the user manually
// scrolled the last few points. Setting the gate here hides the chevron immediately
// and hands pinning back to the bottom anchor; a later real scroll-up still
// 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 —
// `initial: true` fires it for the batch that lands on open (the expensive case), and
// each later new row tops it up. Keyed on the row *count*, so streaming deltas into an
// existing row (which don't change the count) never re-arm it. `items.count` is O(1).
.onChange(of: items.count, initial: true) { _, _ in prewarmMarkdown() }
.onDisappear { prewarmTask?.cancel() }
} }
} }
@@ -11,9 +11,15 @@ struct SessionsView: View {
private var grouped: [(title: String, rows: [WireSessionSummary])] { private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions) let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted(by: StatusStyle.attentionThenRecency) .sorted(by: StatusStyle.attentionThenRecency)
let needs = pool.filter { $0.status.needsYou($0.disposition) } // One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle } // ran O(rows²) full-summary equality scans on every body evaluation.
let done = pool.filter { !needs.contains($0) && !running.contains($0) } var needs: [WireSessionSummary] = [], running: [WireSessionSummary] = [], done: [WireSessionSummary] = []
for summary in pool {
if summary.status.needsYou(summary.disposition) { needs.append(summary) }
else if summary.status == .running || summary.status == .provisioning || summary.status == .idle {
running.append(summary)
} else { done.append(summary) }
}
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty } return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
} }
@@ -0,0 +1,419 @@
import Foundation
import NucleicProtocol
/// Stable-prefix incremental projector (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md).
///
/// `TranscriptProjection.build` folds the whole stream on every read, so a streaming turn of K
/// deltas over an N-event transcript costs O(N·K) ≈ O(N²) per session. But the stream is
/// immutable except at the tail: everything before the live turn is frozen — its `messageID`s and
/// `toolCallID`s never recur. So this projector **seals** the longest provably-stable prefix of
/// the folded item list once, and re-folds only the unstable suffix per delta: O(live-tail)
/// instead of O(N).
///
/// The seal point (an index into the raw event stream) is chosen so that
/// `fold(prefix) ++ fold(tail)` is byte-identical to `fold(whole)`:
///
/// 1. **No coalescing key crosses the seam.** Text/thinking/tool items coalesce on
/// `messageID`/`toolCallID` (and a subagent's events name their parent Task). An exact
/// interval check over the current stream forbids any seam inside an id's first→last
/// reference span, so no item can straddle it.
/// 2. **No id can recur after the seam.** Future arrivals are fenced by closure rules read off
/// the stream itself: a tool is closed once its result arrived; a message/thinking block once
/// a different message has started in its scope; everything, once its turn completed. These
/// are the premises of the design doc ("the stream is immutable except at the tail"); if one
/// is ever violated — a tail event referencing a sealed id — it is *detected* and the
/// projector resets and re-folds from scratch, so correctness never rests on them.
/// 3. **No tool run is split.** `coalesceToolRuns` merges adjacent `.tool` items, so the seam
/// only falls where the last folded item is a hard separator — a visible non-tool row that a
/// future tool call can't merge across. (Empty redacted-thinking rows are transparent to runs
/// and therefore to this rule too.)
/// 4. **Lock notes fold exactly, even across the seam.** A lock's `released` note lands when the
/// file lands in the parent — potentially many turns after the edit it brackets — so sealed
/// edit cards stay reachable through a registry (`TranscriptProjection.PriorEdit`): a tail
/// note that path-matches a sealed edit patches that card, exactly where a whole-stream fold
/// would put it. No lag heuristic, no divergence.
/// 5. **`worktreeRoot` is a carried constant.** Lock-path normalization needs the seq-0
/// `sessionStarted` cwd; nothing seals until it is known, and it never changes once found.
///
/// The equivalence test (`IncrementalProjectionEquivalenceTests`) replays streams and asserts
/// `incremental(prefix) == build(prefix)` for **every** prefix — the whole correctness argument,
/// checked mechanically.
///
/// A class held in `@State` so reads/updates from `body` don't invalidate the view; not
/// thread-safe (main-actor use only, like the `ProjectionCache` it replaces).
final class IncrementalTranscriptProjection {
// MARK: - Sealed state
/// Folded output of the stable prefix — appended to at each seal, never re-walked.
private var sealedItems: [TranscriptItem] = []
/// Watermark into the raw stream: `events[0..<sealedEventCount]` produced `sealedItems`.
private var sealedEventCount = 0
/// `events[sealedEventCount - 1].seq` at seal time — detects a rewritten/merged prefix
/// (a reconnect backfill slotting events in by seq) that shifts history under the watermark.
private var sealedLastSeq: UInt64 = 0
/// The stream's first seq — detects a session switch / stream reset.
private var streamFirstSeq: UInt64?
/// Carried constant from the first non-empty `sessionStarted.cwd`. Nothing seals while nil
/// (a root arriving later would retroactively change sealed lock folds).
private var worktreeRoot: String?
/// Every `messageID`/`toolCallID` referenced by a sealed event. A later event referencing one
/// would mutate sealed output — detected here, answered with a full reset (self-healing).
private var sealedIDs: Set<String> = []
/// Sealed edit-class calls in item order, for cross-seam lock-note folding (rule 4).
private var sealedEdits: [TranscriptProjection.PriorEdit] = []
/// Where each sealed edit's card sits in `sealedItems` (it may live inside a `.toolBlock`).
private var sealedEditIndex: [String: Int] = [:]
/// Display toggles are fold inputs; flipping either resets.
private var showRaw = false
private var showLockEvents = true
// MARK: - Read memo
/// `body` re-evaluates far more often than the stream changes (settling layout, scroll
/// geometry); this collapses those redundant reads to a cached return, as the previous
/// `ProjectionCache` did. The stream is append-only with strictly increasing seq, so
/// `(count, firstSeq, lastSeq)` pins it.
private struct MemoKey: Equatable {
var count: Int
var firstSeq: UInt64
var lastSeq: UInt64
var showRaw: Bool
var showLockEvents: Bool
}
private var memoKey: MemoKey?
private var memoValue: [TranscriptItem] = []
/// Hold-back from the stream edge: never seal into the newest few events. Decoders emit
/// tightly-coupled events in one batch (a `toolResult` and its inferred `fileChange`; a
/// whole-message's final chunks) that sync may deliver one at a time — holding the edge back
/// keeps a mid-batch read from sealing an entity whose trailing batch-mates are still in
/// flight. Cheap insurance on top of the closure rules; violations would only cost a reset.
private let edgeLag = 4
/// Test hook: how far the watermark has advanced (the equivalence suite also asserts sealing
/// actually happens, so a regression to "never seal" can't pass silently).
var sealedEventCountForTesting: Int { sealedEventCount }
// MARK: - Read
func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
let key = MemoKey(count: events.count, firstSeq: events.first?.seq ?? 0,
lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents)
if memoKey == key { return memoValue }
if needsReset(events, showRaw: showRaw, showLockEvents: showLockEvents) { reset() }
self.showRaw = showRaw
self.showLockEvents = showLockEvents
streamFirstSeq = events.first?.seq
if worktreeRoot == nil {
// Only the unsealed region needs scanning: sealing requires the root, so a sealed
// region can only exist after it was found.
worktreeRoot = TranscriptProjection.worktreeRoot(in: events[sealedEventCount...])
}
var watermark = chooseWatermark(events)
if watermark == nil {
// A tail event referenced a sealed id — a closure premise was violated (late file
// change, resumed message, post-result subagent child). Refold from scratch; with no
// sealed ids the second pass cannot be violated.
reset()
streamFirstSeq = events.first?.seq
worktreeRoot = TranscriptProjection.worktreeRoot(in: events[...])
watermark = chooseWatermark(events)
}
seal(events, upTo: watermark ?? sealedEventCount)
let result = render(events)
memoKey = key
memoValue = result
return result
}
// MARK: - Reset / identity
private func needsReset(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> Bool {
if showRaw != self.showRaw || showLockEvents != self.showLockEvents { return true }
if events.count < sealedEventCount { return true }
if sealedEventCount > 0 {
if events.first?.seq != streamFirstSeq { return true }
if events[sealedEventCount - 1].seq != sealedLastSeq { return true }
} else if streamFirstSeq != nil, events.first?.seq != streamFirstSeq {
return true // nothing sealed, but the carried worktreeRoot belongs to the old stream
}
return false
}
private func reset() {
sealedItems = []
sealedEventCount = 0
sealedLastSeq = 0
streamFirstSeq = nil
worktreeRoot = nil
sealedIDs = []
sealedEdits = []
sealedEditIndex = [:]
memoKey = nil
memoValue = []
}
// MARK: - Watermark selection
/// The coalescing ids an event mentions: its `messageID` or `toolCallID`, plus the parent
/// Task id for subagent-owned events. Two events sharing an id must land on the same side of
/// the seam; the parent link chains a subagent's whole scope (and, transitively, deeper
/// descendants) to its spawn.
private static func refs(of event: AgentEvent) -> [String] {
switch event.kind {
case .userText(let c), .assistantText(let c), .thinking(let c):
if let parent = c.parentToolCallID { return [c.messageID, parent] }
return [c.messageID]
case .toolCallStarted(let c), .toolCallCompleted(let c):
if let parent = c.parentToolCallID { return [c.toolCallID, parent] }
return [c.toolCallID]
case .toolCallInputDelta(let d): return [d.toolCallID]
case .toolResult(let r): return [r.toolCallID]
case .fileChange(let f): return f.toolCallID.map { [$0] } ?? []
default: return []
}
}
/// One id's life within the unsealed region.
private struct IDSpan {
var firstRef: Int
var lastRef: Int
/// Result arrived → the tool (or Task, with its children) is done.
var resultSeen = false
/// A later chunk with a different messageID in the same scope → this message is done
/// (its authoritative non-partial text can only arrive before the next message starts).
var closedByChunk = false
/// For message/thinking ids: the owning subagent scope ("" = top level). The owner
/// Task's result closes everything inside it.
var chunkScope: String?
}
/// What an event *creates* in the folded item list, for the run-split rule (3).
private enum Creation {
/// A visible, never-dropped, non-tool row — a safe last-item for a seam.
case separator
/// A `.tool` row a future adjacent call could merge with.
case tool
/// A thinking row: a separator iff its final text is non-empty (an empty redacted block
/// is transparent to run coalescing, so it must be transparent to the seam rule too).
case thinking(String)
/// A lock note that may fold away (dropping it can fuse the runs around it), so it
/// counts as nothing — the seam just waits for the next hard separator.
case transparent
}
/// The furthest event index the stream can be sealed to right now, or nil when a region event
/// references an already-sealed id (premise violation → caller resets).
private func chooseWatermark(_ events: [AgentEvent]) -> Int? {
let start = sealedEventCount
let n = events.count - start
// Everything below needs the worktree root (rule 5); without it, just verify no sealed-id
// violation … but nothing is sealed if no root was ever found, so there is nothing to do.
guard worktreeRoot != nil else { return start }
guard n > edgeLag else {
// Too little unsealed to advance, but tail refs must still be validated against
// sealed ids so a violation triggers the reset path.
for r in 0..<n where Self.refs(of: events[start + r]).contains(where: sealedIDs.contains) {
return nil
}
return start
}
var spans: [String: IDSpan] = [:]
var creations: [Creation?] = Array(repeating: nil, count: n)
var thinkingText: [String: String] = [:]
var seenMessageItem = Set<String>()
var seenThinkingItem = Set<String>()
var seenToolItem = Set<String>()
var lastChunkInScope: [String: String] = [:]
var lastBoundary = -1 // region index of the latest turnCompleted/runFinished
for r in 0..<n {
let event = events[start + r]
for id in Self.refs(of: event) {
if sealedIDs.contains(id) { return nil }
if var span = spans[id] {
span.lastRef = r
spans[id] = span
} else {
spans[id] = IDSpan(firstRef: r, lastRef: r)
}
}
switch event.kind {
case .userText(let c), .assistantText(let c):
trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
if seenMessageItem.insert(c.messageID).inserted { creations[r] = .separator }
case .thinking(let c):
trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
let existing = thinkingText[c.messageID] ?? ""
thinkingText[c.messageID] = c.isPartial ? existing + c.text : c.text
if seenThinkingItem.insert(c.messageID).inserted { creations[r] = .thinking(c.messageID) }
case .toolCallStarted(let c), .toolCallCompleted(let c):
if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
case .toolResult(let result):
spans[result.toolCallID]?.resultSeen = true
case .toolCallInputDelta, .fileChange, .approvalResolved:
break // refs (if any) tracked above; creates nothing
case .turnCompleted, .runFinished:
creations[r] = .separator
lastBoundary = r
case .sessionStarted, .usage, .rateLimit, .approvalRequested, .error:
creations[r] = .separator
case .note(let note):
if note.lockEvent && !showLockEvents { break }
if let lock = note.lock, !lock.paths.isEmpty { creations[r] = .transparent }
else { creations[r] = .separator }
case .raw:
if showRaw { creations[r] = .separator }
}
}
// Future-proofing (rule 2): an id wholly before the seam must be *closed* — provably done
// taking new events. An open id caps the seam at its first reference (it stays whole in
// the tail).
var cap = n - edgeLag
for (_, span) in spans where !isClosed(span, spans: spans, lastBoundary: lastBoundary) {
cap = min(cap, span.firstRef)
}
// Interval isolation (rule 1): no seam inside any id's [firstRef, lastRef] span.
// maxLastFromBefore[w] = the furthest lastRef among ids first referenced before w; a seam
// at w is isolation-safe iff that never reaches w.
var maxLastAtFirst = [Int](repeating: -1, count: n)
for span in spans.values {
maxLastAtFirst[span.firstRef] = max(maxLastAtFirst[span.firstRef], span.lastRef)
}
// Run-split rule (3): replay creations in item order; a seam is placeable after event r
// only while the last solid (visible, surviving) item is a hard separator. The replay
// resolves each thinking row against its *final* region text, which is exactly what the
// sealed fold will contain (open ids were already excluded by `cap`).
var separatorOK = [Bool](repeating: false, count: n)
var lastSolidIsSeparator = true // sealed prefix is empty or ends with a separator (invariant)
for r in 0..<n {
switch creations[r] {
case .separator: lastSolidIsSeparator = true
case .tool: lastSolidIsSeparator = false
case .thinking(let id):
let text = thinkingText[id] ?? ""
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
lastSolidIsSeparator = true
}
case .transparent, nil: break
}
separatorOK[r] = lastSolidIsSeparator
}
var runningMaxLast = -1
var best = 0
if cap >= 1 {
for w in 1...cap {
runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1])
if separatorOK[w - 1] && runningMaxLast < w { best = w }
}
}
return start + best
}
/// Track a text/thinking chunk for message-closure: a new messageID in a scope closes the
/// previous one (chunks of one message never resume after the next begins — decoder order).
private func trackChunk(
_ chunk: TextChunk, in spans: inout [String: IDSpan], lastChunkInScope: inout [String: String]
) {
let scope = chunk.parentToolCallID ?? ""
spans[chunk.messageID]?.chunkScope = scope
if let previous = lastChunkInScope[scope], previous != chunk.messageID {
spans[previous]?.closedByChunk = true
}
lastChunkInScope[scope] = chunk.messageID
}
private func isClosed(_ span: IDSpan, spans: [String: IDSpan], lastBoundary: Int) -> Bool {
if span.lastRef < lastBoundary { return true } // its turn completed; ids don't cross turns
if span.resultSeen { return true }
if span.closedByChunk { return true }
if let scope = span.chunkScope, !scope.isEmpty, spans[scope]?.resultSeen == true {
return true // the owning subagent returned; its inner stream is done
}
return false
}
// MARK: - Sealing
private func seal(_ events: [AgentEvent], upTo watermark: Int) {
guard watermark > sealedEventCount else { return }
let slice = events[sealedEventCount..<watermark]
let segment = TranscriptProjection.buildSegment(
slice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits,
showRaw: showRaw, showLockEvents: showLockEvents)
// Lock notes in this segment that folded onto edits sealed earlier: bake them in — the
// note is now sealed too, so the fold is final.
for patch in segment.priorLockPatches {
Self.applyLock(patch.lock, to: patch.toolCallID, at: sealedEditIndex, in: &sealedItems)
}
let editIDs = Set(segment.edits.map(\.toolCallID))
for item in segment.items {
let index = sealedItems.count
switch item.kind {
case .tool(let group) where editIDs.contains(group.toolCallID):
sealedEditIndex[group.toolCallID] = index
case .toolBlock(let groups):
for group in groups where editIDs.contains(group.toolCallID) {
sealedEditIndex[group.toolCallID] = index
}
default:
break
}
sealedItems.append(item)
}
sealedEdits.append(contentsOf: segment.edits)
for event in slice {
for id in Self.refs(of: event) { sealedIDs.insert(id) }
}
sealedEventCount = watermark
sealedLastSeq = events[watermark - 1].seq
}
// MARK: - Rendering
private func render(_ events: [AgentEvent]) -> [TranscriptItem] {
let tailSlice = events[sealedEventCount...]
guard !tailSlice.isEmpty else { return sealedItems }
let tail = TranscriptProjection.buildSegment(
tailSlice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits,
showRaw: showRaw, showLockEvents: showLockEvents)
if tail.priorLockPatches.isEmpty { return sealedItems + tail.items }
// A live (unsealed) lock note folded onto a sealed edit card: patch a copy per read —
// the note may still be re-evaluated until it seals, so the base stays unpatched.
var patched = sealedItems
for patch in tail.priorLockPatches {
Self.applyLock(patch.lock, to: patch.toolCallID, at: sealedEditIndex, in: &patched)
}
return patched + tail.items
}
/// Append a folded lock line to a sealed edit's card, whether it renders alone or inside a
/// coalesced `.toolBlock`.
private static func applyLock(
_ lock: NoteLock, to toolCallID: String, at index: [String: Int],
in items: inout [TranscriptItem]
) {
guard let i = index[toolCallID] else { return }
switch items[i].kind {
case .tool(var group) where group.toolCallID == toolCallID:
group.lockLines.append(lock)
items[i].kind = .tool(group)
case .toolBlock(var groups):
guard let k = groups.firstIndex(where: { $0.toolCallID == toolCallID }) else { return }
groups[k].lockLines.append(lock)
items[i].kind = .toolBlock(groups)
default:
break
}
}
}
@@ -40,14 +40,19 @@ struct MarkdownText: View {
/// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing
/// is independent of `bodySize` — so the source string is a complete key. Bounded; /// is independent of `bodySize` — so the source string is a complete key. Bounded;
/// `NSCache` also evicts under memory pressure. /// `NSCache` also evicts under memory pressure.
///
/// The whole parse pipeline (both caches and the statics below) is `nonisolated`: `prewarm`
/// runs it from a detached background task by design, and as `View` statics they'd otherwise
/// be implicitly MainActor (a Swift 6 error for that call). `(unsafe)` on the caches is
/// sound because `NSCache` is thread-safe and the boxed values are immutable.
private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } } private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } }
private static let blockCache: NSCache<NSString, ParsedBlocks> = { nonisolated(unsafe) private static let blockCache: NSCache<NSString, ParsedBlocks> = {
let cache = NSCache<NSString, ParsedBlocks>() let cache = NSCache<NSString, ParsedBlocks>()
cache.countLimit = 2048 cache.countLimit = 2048
return cache return cache
}() }()
private static func parse(_ markdown: String) -> [Block] { nonisolated private static func parse(_ markdown: String) -> [Block] {
let key = markdown as NSString let key = markdown as NSString
if let hit = blockCache.object(forKey: key) { return hit.blocks } if let hit = blockCache.object(forKey: key) { return hit.blocks }
let blocks = parseUncached(markdown) let blocks = parseUncached(markdown)
@@ -55,7 +60,7 @@ struct MarkdownText: View {
return blocks return blocks
} }
private static func parseUncached(_ markdown: String) -> [Block] { nonisolated private static func parseUncached(_ markdown: String) -> [Block] {
var blocks: [Block] = [] var blocks: [Block] = []
var textBuffer: [String] = [] var textBuffer: [String] = []
func flush() { func flush() {
@@ -100,12 +105,12 @@ struct MarkdownText: View {
/// A GitHub-style table: a `|`-bearing header line immediately followed by a /// A GitHub-style table: a `|`-bearing header line immediately followed by a
/// `|---|:--:|` separator line. /// `|---|:--:|` separator line.
private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { nonisolated private static func isTableStart(_ lines: [String], _ index: Int) -> Bool {
guard lines[index].contains("|"), index + 1 < lines.count else { return false } guard lines[index].contains("|"), index + 1 < lines.count else { return false }
return isSeparatorRow(lines[index + 1]) return isSeparatorRow(lines[index + 1])
} }
private static func isSeparatorRow(_ line: String) -> Bool { nonisolated private static func isSeparatorRow(_ line: String) -> Bool {
let cells = tableCells(line) let cells = tableCells(line)
guard !cells.isEmpty else { return false } guard !cells.isEmpty else { return false }
return cells.allSatisfy { cell in return cells.allSatisfy { cell in
@@ -115,7 +120,7 @@ struct MarkdownText: View {
/// Split a table row into trimmed cells, dropping the empties created by the /// Split a table row into trimmed cells, dropping the empties created by the
/// leading/trailing pipes. /// leading/trailing pipes.
private static func tableCells(_ line: String) -> [String] { nonisolated private static func tableCells(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespaces) var trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("|") { trimmed.removeFirst() } if trimmed.hasPrefix("|") { trimmed.removeFirst() }
if trimmed.hasSuffix("|") { trimmed.removeLast() } if trimmed.hasSuffix("|") { trimmed.removeLast() }
@@ -167,31 +172,59 @@ struct MarkdownText: View {
.lineSpacing(4) .lineSpacing(4)
} }
/// One prose line's classification: the exact string that gets inline-parsed — the cache key
/// `prewarm` must match — paired with how it's styled. Single source of truth for both the
/// live `lineView` and the off-main `prewarm`, so a warmed key can never drift from the key
/// the layout later looks up (any drift and the warm entry would silently miss).
private enum LineStyle {
case blank
case heading(content: String, scale: CGFloat, weight: Font.Weight)
case bullet(content: String)
case plain(content: String)
/// The string handed to `inline(_:)`, or nil for a line that renders without a parse.
var inlineContent: String? {
switch self {
case .blank: return nil
case .heading(let c, _, _), .bullet(let c), .plain(let c): return c
}
}
}
/// Classify one raw prose line. Headings are checked before bullets (a heading marker wins),
/// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade
/// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`.
nonisolated private static func classify(_ raw: String) -> LineStyle {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return .blank }
// Headings scale relative to the base prose size so the hierarchy holds at any base and
// never collapses to the body size.
if trimmed.hasPrefix("### ") { return .heading(content: String(trimmed.dropFirst(4)), scale: 1.13, weight: .semibold) }
if trimmed.hasPrefix("## ") { return .heading(content: String(trimmed.dropFirst(3)), scale: 1.28, weight: .bold) }
if trimmed.hasPrefix("# ") { return .heading(content: String(trimmed.dropFirst(2)), scale: 1.5, weight: .bold) }
if let bullet = bulletContent(trimmed) { return .bullet(content: bullet) }
return .plain(content: raw)
}
@ViewBuilder @ViewBuilder
private func lineView(_ raw: String) -> some View { private func lineView(_ raw: String) -> some View {
let trimmed = raw.trimmingCharacters(in: .whitespaces) switch Self.classify(raw) {
if trimmed.isEmpty { case .blank:
Color.clear.frame(height: 3) Color.clear.frame(height: 3)
} else if trimmed.hasPrefix("### ") { case .heading(let content, let scale, let weight):
// Headings scale relative to the base prose size so the hierarchy holds inline(content).font(.system(size: bodySize * scale, weight: weight))
// at any base and never collapses to the body size. case .bullet(let content):
inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold))
} else if trimmed.hasPrefix("## ") {
inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold))
} else if trimmed.hasPrefix("# ") {
inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold))
} else if let bullet = Self.bulletContent(trimmed) {
HStack(alignment: .firstTextBaseline, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•").foregroundStyle(.secondary) Text("•").foregroundStyle(.secondary)
inline(bullet) inline(content)
} }
} else { case .plain(let content):
inline(raw) inline(content)
} }
} }
/// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil. /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil.
private static func bulletContent(_ trimmed: String) -> String? { nonisolated private static func bulletContent(_ trimmed: String) -> String? {
for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) {
return String(trimmed.dropFirst(marker.count)) return String(trimmed.dropFirst(marker.count))
} }
@@ -213,13 +246,13 @@ struct MarkdownText: View {
/// recur across re-renders and reopens, so memoize the parsed result. Independent of /// recur across re-renders and reopens, so memoize the parsed result. Independent of
/// `bodySize` (callers apply the font), so the source string is a complete key. /// `bodySize` (callers apply the font), so the source string is a complete key.
private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } } private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } }
private static let inlineCache: NSCache<NSString, InlineBox> = { nonisolated(unsafe) private static let inlineCache: NSCache<NSString, InlineBox> = {
let cache = NSCache<NSString, InlineBox>() let cache = NSCache<NSString, InlineBox>()
cache.countLimit = 16384 cache.countLimit = 16384
return cache return cache
}() }()
private static func attributedInline(_ string: String) -> AttributedString { nonisolated private static func attributedInline(_ string: String) -> AttributedString {
let key = string as NSString let key = string as NSString
if let hit = inlineCache.object(forKey: key) { return hit.value } if let hit = inlineCache.object(forKey: key) { return hit.value }
let options = AttributedString.MarkdownParsingOptions( let options = AttributedString.MarkdownParsingOptions(
@@ -230,4 +263,35 @@ struct MarkdownText: View {
inlineCache.setObject(InlineBox(parsed), forKey: key) inlineCache.setObject(InlineBox(parsed), forKey: key)
return parsed return parsed
} }
// MARK: - Pre-warming
/// Populate the block and inline caches for `markdown` ahead of layout. `AttributedString(markdown:)`
/// is the dominant per-row cost when a long transcript first lays out, and the eager `VStack`
/// runs it for every prose line synchronously — right as the session is pushed and the tab bar
/// is sliding away, which is what makes the load-in jitter. Calling this off the main thread
/// (see `TranscriptList`) does that parsing in the background so the first layout hits ready
/// results instead.
///
/// Safe to call from any thread and redundantly: the caches are `NSCache` (thread-safe) and
/// keyed only by the source string (independent of `bodySize`), so a warm value equals what
/// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively
/// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled.
nonisolated static func prewarm(_ markdown: String) {
for block in parse(markdown) { // also warms the block cache
if Task.isCancelled { return }
switch block {
case .code:
break // a code block renders via `Text(code)` — no inline parse to warm
case .text(let text):
for raw in text.components(separatedBy: "\n") {
if let content = classify(raw).inlineContent { _ = attributedInline(content) }
}
case .table(let rows):
for row in rows where !Task.isCancelled {
for cell in row { _ = attributedInline(cell) }
}
}
}
}
} }
@@ -102,26 +102,71 @@ enum TranscriptProjection {
/// owns them, project the main agent's own events at the top level, and recursively project /// owns them, project the main agent's own events at the top level, and recursively project
/// each subagent's events into the `children` of its spawn — so a subagent's inner work nests /// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
/// under its card instead of leaking (and interleaving) into the main transcript. /// under its card instead of leaking (and interleaving) into the main transcript.
///
/// One-shot form: folds the whole stream in one pass. The live transcript uses
/// `IncrementalTranscriptProjection`, which folds the stream as seam-delimited segments via
/// `buildSegment` — this wrapper is the `priorEdits: []` whole-stream case of that.
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
buildSegment(events[...], worktreeRoot: worktreeRoot(in: events[...]), priorEdits: [],
showRaw: showRaw, showLockEvents: showLockEvents).items
}
/// An edit-class tool call folded in an *earlier* segment, carried forward so a later
/// segment's lock notes can still fold onto it across the seam (a lock's `released` note
/// lands when the file *lands* in the parent — potentially many turns after the edit).
struct PriorEdit: Equatable {
let toolCallID: String
/// Repo-relative, normalized paths this call writes (`editedPaths` ∘ `normalizeForLock`).
let paths: [String]
}
/// The folded output of one contiguous slice of the stream.
struct SegmentFold {
var items: [TranscriptItem]
/// Lock lines whose backward path-match crossed the seam onto a `priorEdits` entry, in
/// note order. The caller owns those already-folded items and must attach these to them —
/// that's what keeps `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)` even
/// for a lock note released turns after its edit.
var priorLockPatches: [(toolCallID: String, lock: NoteLock)]
/// This segment's own edit-class calls (in item order), for the caller's registry.
var edits: [PriorEdit]
}
/// Fold one contiguous slice of the stream with an injected worktree root (a mid-stream
/// slice lacks the seq-0 `sessionStarted` that `build` rescans for). The slice must be
/// seam-delimited — no coalescing key, subagent scope, or open tool run straddling either
/// end — which is exactly what `IncrementalTranscriptProjection` guarantees before calling.
static func buildSegment(
_ events: ArraySlice<AgentEvent>, worktreeRoot root: String?, priorEdits: [PriorEdit],
showRaw: Bool, showLockEvents: Bool
) -> SegmentFold {
let (topLevel, byParent) = partition(events) let (topLevel, byParent) = partition(events)
// Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's
// `items(_:worktreeRoot:)` does — matched against the session's working directory so an // `items(_:worktreeRoot:)` does — matched against the session's working directory so an
// edit's absolute `file_path` compares against the note's repo-relative paths. Folding // edit's absolute `file_path` compares against the note's repo-relative paths. Folding
// happens only at the top level (a subagent's inner edits are literal, unlocked); the // happens only at the top level (a subagent's inner edits are literal, unlocked); the
// subagent recursion below stays plain, matching the desktop projection. // subagent recursion below stays plain, matching the desktop projection.
let root = worktreeRoot(in: topLevel) var patches: [(toolCallID: String, lock: NoteLock)] = []
let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents), let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents),
worktreeRoot: root) worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches)
return coalesceToolRuns(flat).map { let items = coalesceToolRuns(flat).map {
attachSubagentChildren($0, byParent: byParent, depth: 0, attachSubagentChildren($0, byParent: byParent, depth: 0,
showRaw: showRaw, showLockEvents: showLockEvents) showRaw: showRaw, showLockEvents: showLockEvents)
} }
let edits = flat.compactMap { item -> PriorEdit? in
guard case .tool(let group) = item.kind else { return nil }
let paths = editedPaths(toolName: group.name, input: group.input)
.map { normalizeForLock($0, worktreeRoot: root) }
.filter { !$0.isEmpty }
return paths.isEmpty ? nil : PriorEdit(toolCallID: group.toolCallID, paths: paths)
}
return SegmentFold(items: items, priorLockPatches: patches, edits: edits)
} }
/// The session's working directory, read from its `sessionStarted` event, so an edit's /// The session's working directory, read from its `sessionStarted` event, so an edit's
/// absolute `file_path` can be made repo-relative to compare against a lock note's /// absolute `file_path` can be made repo-relative to compare against a lock note's
/// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet). /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet).
private static func worktreeRoot(in events: [AgentEvent]) -> String? { static func worktreeRoot(in events: ArraySlice<AgentEvent>) -> String? {
for event in events { for event in events {
if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty { if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty {
return started.cwd return started.cwd
@@ -138,7 +183,15 @@ enum TranscriptProjection {
/// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's /// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's
/// repo-relative paths. A note overlapping no preceding edit is left in place (renders /// repo-relative paths. A note overlapping no preceding edit is left in place (renders
/// standalone), matching the Mac's `foldLockNotes`. /// standalone), matching the Mac's `foldLockNotes`.
private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?) -> [TranscriptItem] { ///
/// The backward scan continues past the front of `flat` into `priorEdits` — the edits of
/// already-folded earlier segments, oldest first — so a note whose edit was sealed in a prior
/// segment (a lock released turns later) still folds exactly where a whole-stream fold would
/// put it. Those hits are reported via `priorLockPatches` for the caller to attach.
private static func foldLockNotes(
_ flat: [TranscriptItem], worktreeRoot: String?, priorEdits: [PriorEdit],
priorLockPatches: inout [(toolCallID: String, lock: NoteLock)]
) -> [TranscriptItem] {
// Each tool item's repo-relative edited paths (only edit-class tools have any), by index. // Each tool item's repo-relative edited paths (only edit-class tools have any), by index.
var editsByIndex: [Int: (id: String, paths: [String])] = [:] var editsByIndex: [Int: (id: String, paths: [String])] = [:]
for (i, item) in flat.enumerated() { for (i, item) in flat.enumerated() {
@@ -148,7 +201,8 @@ enum TranscriptProjection {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) } if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) }
} }
guard !editsByIndex.isEmpty else { return flat } guard !editsByIndex.isEmpty || !priorEdits.isEmpty else { return flat }
let priorIDs = Set(priorEdits.map(\.toolCallID))
var locksByCall: [String: [NoteLock]] = [:] var locksByCall: [String: [NoteLock]] = [:]
var folded = Set<Int>() var folded = Set<Int>()
@@ -167,14 +221,27 @@ enum TranscriptProjection {
guard let edit = editsByIndex[j] else { continue } guard let edit = editsByIndex[j] else { continue }
if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break } if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break }
} }
if hitID == nil {
// Nothing in this segment — keep scanning backward across the seam, newest
// prior edit first, exactly where a whole-stream scan would look next.
for prior in priorEdits.reversed()
where prior.paths.contains(where: { pathsOverlap($0, path) }) {
hitID = prior.toolCallID
break
}
}
guard let hitID else { matchedAll = false; break } guard let hitID else { matchedAll = false; break }
if let k = indexByID[hitID] { perCard[k].paths.append(path) } if let k = indexByID[hitID] { perCard[k].paths.append(path) }
else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) } else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) }
} }
guard matchedAll, !perCard.isEmpty else { continue } guard matchedAll, !perCard.isEmpty else { continue }
for card in perCard { for card in perCard {
if priorIDs.contains(card.id) {
priorLockPatches.append((card.id, NoteLock(state: lock.state, paths: card.paths)))
} else {
locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths))
} }
}
folded.insert(i) folded.insert(i)
} }
guard !folded.isEmpty else { return flat } guard !folded.isEmpty else { return flat }
@@ -235,7 +302,7 @@ enum TranscriptProjection {
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by /// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
/// the spawning `Task`'s id. /// the spawning `Task`'s id.
private static func partition(_ events: [AgentEvent]) private static func partition(_ events: ArraySlice<AgentEvent>)
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]]) -> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
{ {
let parentOf = toolParentMap(events) let parentOf = toolParentMap(events)
@@ -254,7 +321,7 @@ enum TranscriptProjection {
/// Maps each tool-call id to its parent subagent's id, built from the call start/complete /// Maps each tool-call id to its parent subagent's id, built from the call start/complete
/// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent /// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
/// scope from the call it belongs to via this map. /// scope from the call it belongs to via this map.
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] { private static func toolParentMap(_ events: ArraySlice<AgentEvent>) -> [String: String] {
var map: [String: String] = [:] var map: [String: String] = [:]
for event in events { for event in events {
switch event.kind { switch event.kind {
@@ -512,4 +579,21 @@ extension JSONValue {
default: return false default: return false
} }
} }
/// A one-line gist of a tool input/result for the collapsed row. Lives here (not with the
/// row views) because the projection folds it into `.raw` bodies, and this file also builds
/// standalone as the host-testable `NucleicRemoteProjection` SwiftPM target.
var compactSummary: String {
switch self {
case .string(let s): return s
case .object(let o):
if let cmd = o["command"]?.stringValue { return cmd }
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
return o.keys.sorted().joined(separator: ", ")
case .array(let a): return "[\(a.count) items]"
case .number(let n): return String(n)
case .bool(let b): return String(b)
case .null: return "null"
}
}
} }
@@ -250,20 +250,8 @@ extension JSONValue {
return canonical == "null" ? "" : canonical return canonical == "null" ? "" : canonical
} }
/// A one-line gist of a tool input/result for the collapsed row. // (`compactSummary` — the one-line gist — lives in TranscriptProjection.swift, which builds
var compactSummary: String { // standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs it there.)
switch self {
case .string(let s): return s
case .object(let o):
if let cmd = o["command"]?.stringValue { return cmd }
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
return o.keys.sorted().joined(separator: ", ")
case .array(let a): return "[\(a.count) items]"
case .number(let n): return String(n)
case .bool(let b): return String(b)
case .null: return "null"
}
}
/// The full, **untruncated** content of a tool input for the approval card — /// The full, **untruncated** content of a tool input for the approval card —
/// the user must see exactly what they are granting before allowing. Unlike /// the user must see exactly what they are granting before allowing. Unlike
@@ -50,10 +50,12 @@ struct SessionLiveActivity: Widget {
} }
} }
} compactLeading: { } compactLeading: {
// Calm state shows the Nucleic Control brand mark (the purple atom); when the
// user is needed it switches to the amber approval glyph.
Image(systemName: state.needsAttention Image(systemName: state.needsAttention
? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) ? ActivityPalette.glyph(.approval) : "atom")
.foregroundStyle(state.needsAttention .foregroundStyle(state.needsAttention
? ActivityPalette.attention : ActivityPalette.active) ? ActivityPalette.attention : ActivityPalette.brand)
} compactTrailing: { } compactTrailing: {
Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)") Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)")
.font(.caption2.bold()) .font(.caption2.bold())
@@ -131,15 +133,42 @@ private struct LockScreenView: View {
private struct SessionRow: View { private struct SessionRow: View {
let line: NucleicSessionAttributes.SessionLine let line: NucleicSessionAttributes.SessionLine
/// An approval this row can resolve inline: it's blocked on an approval, carries that approval's
/// id, and isn't high-risk (§3.3 — high-risk keeps the plain tap-to-open row and is answered on
/// the app's guarded card). `nil` until the producer populates `approvalID` — an older host, or
/// today's summary that doesn't yet carry it, so the row stays a deep-link exactly as before.
private var inlineApprovalID: String? {
guard line.kind == .approval, (line.approvalIsHighRisk ?? false) == false else { return nil }
return line.approvalID
}
var body: some View { var body: some View {
if let url = NucleicDeepLink.session(line.id) { if let approvalID = inlineApprovalID {
actionableRow(approvalID: approvalID)
} else if let url = NucleicDeepLink.session(line.id) {
Link(destination: url) { rowContent } Link(destination: url) { rowContent }
} else { } else {
rowContent rowContent
} }
} }
private var rowContent: some View { /// An approval row with inline Allow/Deny. The identity still deep-links to the session; the
/// buttons fire `ApproveFromActivityIntent`, which the system performs in the app's background
/// process — allow/deny without unlocking (§4.1).
private func actionableRow(approvalID: String) -> some View {
HStack(spacing: 8) {
if let url = NucleicDeepLink.session(line.id) {
Link(destination: url) { rowIdentity }
} else {
rowIdentity
}
Spacer(minLength: 6)
ApprovalButtons(sessionID: line.id, approvalID: approvalID)
}
}
/// The glyph + title + "project · Backend" — the row's identity, minus the trailing status.
private var rowIdentity: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: ActivityPalette.glyph(line.kind)) Image(systemName: ActivityPalette.glyph(line.kind))
.font(.footnote) .font(.footnote)
@@ -159,6 +188,12 @@ private struct SessionRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
} }
}
}
private var rowContent: some View {
HStack(spacing: 8) {
rowIdentity
Spacer(minLength: 6) Spacer(minLength: 6)
Text(line.detail) Text(line.detail)
.font(.caption2.weight(.medium)) .font(.caption2.weight(.medium))
@@ -169,6 +204,33 @@ private struct SessionRow: View {
} }
} }
/// The compact inline Allow/Deny pair for an actionable approval row. Each button fires
/// `ApproveFromActivityIntent` (a low/medium-risk decision — high-risk never reaches here).
private struct ApprovalButtons: View {
let sessionID: String
let approvalID: String
var body: some View {
HStack(spacing: 6) {
Button(intent: intent(allow: false)) {
Image(systemName: "xmark").font(.caption2.bold())
}
.tint(ActivityPalette.danger)
Button(intent: intent(allow: true)) {
Image(systemName: "checkmark").font(.caption2.bold())
}
.tint(ActivityPalette.success)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
private func intent(allow: Bool) -> ApproveFromActivityIntent {
ApproveFromActivityIntent(
approvalID: approvalID, sessionID: sessionID, isHighRisk: false, allow: allow)
}
}
/// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show. /// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show.
private struct CountChips: View { private struct CountChips: View {
let state: NucleicSessionAttributes.ContentState let state: NucleicSessionAttributes.ContentState
@@ -220,6 +282,7 @@ private enum ActivityPalette {
static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green
static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red
static let neutral = Color.secondary static let neutral = Color.secondary
static let brand = Color(red: 0.62, green: 0.51, blue: 0.93) // Nucleic Control purple
static let added = Color(red: 0.30, green: 0.78, blue: 0.45) static let added = Color(red: 0.30, green: 0.78, blue: 0.45)
static let removed = Color(red: 0.92, green: 0.34, blue: 0.34) static let removed = Color(red: 0.92, green: 0.34, blue: 0.34)
@@ -0,0 +1,71 @@
import AppIntents
#if NUCLEIC_APP
import NucleicProtocol
#endif
/// The interactive approve / deny an aggregate Live Activity (or widget) button fires — the flagship
/// "resolve from the lock screen without unlocking" path (docs/APP_INTENTS_OPPORTUNITIES §4.1).
///
/// It lives in the **Shared** group so both the app and the widget extension can reference it in
/// `Button(intent:)`. It carries plain `String` ids and compiles its real work only into the app,
/// gated on `#if NUCLEIC_APP` (a custom compilation condition set on the app target only). That's
/// sound because iOS runs a widget/Live-Activity button's intent in the **app's background
/// process** — where `RemoteStore` owns the live E2EE channel — never in the extension. The
/// extension-side copy exists solely to satisfy the `Button(intent:)` type reference.
///
/// NB: the guard is `#if NUCLEIC_APP`, *not* `#if canImport(NucleicProtocol)`. `canImport` tests
/// module findability, not linkage — and because the app builds `NucleicProtocol` into the shared
/// DerivedData products dir, it's findable from the widget extension too. So `canImport` is `true`
/// in the extension, which would compile this branch there and fail on the app-only `RemoteStore`
/// / `IntentError` types. `NUCLEIC_APP` tracks target membership, which is what we actually mean.
///
/// Not discoverable in Shortcuts/Spotlight: it's button-only, driven by ids embedded at render time
/// (a human uses `AnswerApprovalIntent` for the spoken/Shortcuts path).
struct ApproveFromActivityIntent: AppIntent {
static let title: LocalizedStringResource = "Approve from Live Activity"
static let isDiscoverable = false
@Parameter(title: "Approval ID")
var approvalID: String
@Parameter(title: "Session ID")
var sessionID: String
@Parameter(title: "High Risk")
var isHighRisk: Bool
/// `true` = allow, `false` = deny.
@Parameter(title: "Allow")
var allow: Bool
init() {}
init(approvalID: String, sessionID: String, isHighRisk: Bool, allow: Bool) {
self.approvalID = approvalID
self.sessionID = sessionID
self.isHighRisk = isHighRisk
self.allow = allow
}
@MainActor
func perform() async throws -> some IntentResult {
#if NUCLEIC_APP
let store = RemoteStore.shared
// §3.3 — a high-risk allow is never resolved inline; route to the app's biometric-gated card.
// (Surfaces shouldn't render an inline Allow for high-risk in the first place; this is the
// backstop so the intent can never be a softer path than the UI.)
if isHighRisk, allow {
store.route(to: SessionID(rawValue: sessionID))
throw IntentError.needsAppConfirmation
}
// Best-effort bring-up; `respondToApproval` queues briefly on a dropped link (§3.1). A lost
// first-responder race is de-duped host-side (§3.4), so we never surface an error for it.
_ = await store.awaitLiveConnection()
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
store.respondToApproval(
id: ApprovalID(rawValue: approvalID),
sessionID: SessionID(rawValue: sessionID),
decision: decision)
return .result()
#else
// Widget-extension build: never executed (the system performs this in the app process).
return .result()
#endif
}
}
@@ -48,6 +48,30 @@ struct NucleicSessionAttributes: ActivityAttributes {
/// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve", /// A compact right-aligned status: its diff ("3 files +42 −7"), "2 to approve",
/// "Waiting on you", "Working…", etc. /// "Waiting on you", "Working…", etc.
var detail: String var detail: String
/// The single most-urgent pending approval on this session, when it has one — the id the
/// inline Allow/Deny buttons resolve (`ApproveFromActivityIntent`). `nil` when the session
/// isn't blocked on an approval, or when the producer doesn't carry it (an older host, or a
/// summary that predates the field) — in which case the row falls back to a plain deep-link
/// tap, exactly as before. Optional so a host that omits it still decodes.
var approvalID: String?
/// Whether that approval is high-risk (destructive / network / host-exec). The surface hides
/// inline Allow on a high-risk request (§3.3) — only Deny / open-the-app is offered. `nil`
/// (treated as unknown → no inline Allow) when the producer doesn't carry it.
var approvalIsHighRisk: Bool?
init(
id: String, title: String, project: String, backend: Backend, kind: Kind,
detail: String, approvalID: String? = nil, approvalIsHighRisk: Bool? = nil
) {
self.id = id
self.title = title
self.project = project
self.backend = backend
self.kind = kind
self.detail = detail
self.approvalID = approvalID
self.approvalIsHighRisk = approvalIsHighRisk
}
} }
struct ContentState: Codable, Hashable { struct ContentState: Codable, Hashable {