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_MAC_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_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -400,6 +401,7 @@
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_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_VERSION = 5.0;
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
/// fresh churn still rides along on the next status-driven push.
private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
// Apply immediately when the attention signature shifts (start/finish/approval/input — the
// alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let
// that refresh on a slow cadence so it stays roughly current without spending ActivityKit's
// budget on every delta. Elapsed-time gate, not a sleeping timer — a status change is never
// held behind it.
let signatureChanged = state.attentionSignature != lastState?.attentionSignature
let churnRefreshDue = lastPushAt.map {
Date().timeIntervalSince($0) >= Self.churnRefreshInterval
} ?? true
guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
lastState = state
lastPushAt = Date()
guard let activity else {
// Recover an Activity that survived an app relaunch before starting a new one; the
// recovered one still needs the fresh state, so fall through to the update pipeline.
guard activity != nil else {
// No Activity yet — recover one that survived an app relaunch, or start a fresh one. The
// dedup gate below only guards *updates* to a live Activity; creation must never sit
// behind it. ActivityKit refuses a start unless the app has a foreground/background-
// assertion window, and committing the dedup state *before* the request meant a refused
// start left `lastState` matching the aggregate — so the next `sync` deduped the glance
// away and it never activated until the session's state changed. Record the pushed state
// only once an Activity actually exists; a refused start leaves `lastState` untouched so
// the next sync simply retries creation.
if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing
observePushToken(existing)
@@ -173,19 +166,35 @@ final class LiveActivityManager {
// expanding reveals the fresh one, and `end()` on completion would leave it lingering
// on the last "needs attention" glance. Keep exactly one.
endStrays(keeping: existing.id)
lastState = state
lastPushAt = Date()
enqueue(state)
} else {
} else if let started = try? Activity.request(
// `pushType: .token` opts the activity into APNs updates — the Macs push new
// content-state to the token so the glance stays fresh while the phone is locked.
let started = try? Activity.request(
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil),
pushType: .token)
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil),
pushType: .token) {
activity = started
if let started { observePushToken(started) }
observePushToken(started)
lastState = state
lastPushAt = Date()
}
return
}
// A live Activity exists — spend ActivityKit's update budget only when the glance actually
// moved: immediately when the attention signature shifts (start/finish/approval/input — the
// alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking
// on every transcript delta). Elapsed-time gate, not a sleeping timer — a status change is
// never held behind it.
let signatureChanged = state.attentionSignature != lastState?.attentionSignature
let churnRefreshDue = lastPushAt.map {
Date().timeIntervalSince($0) >= Self.churnRefreshInterval
} ?? true
guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
lastState = state
lastPushAt = Date()
enqueue(state)
}
@@ -3,3 +3,8 @@
this localized alert is all the lock screen shows. The app pulls the real approval
over the encrypted channel on open. */
"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 didStartFullFetch = false
/// The in-flight *background* transcript prefetch (one at a time): its request id, target
/// session, and accumulated chunks. Independent of the open-session fetch above — replies
/// are matched on this id, so the two flows never mix, and prefetching keeps working while a
/// different session is open on screen. Driven by RemoteStore, which warms the offline cache
/// with the result so a never-before-opened session still opens at its end instantly.
private var prefetchRequestID: String?
private var prefetchSessionID: SessionID?
private var prefetchEvents: [AgentEvent] = []
// MARK: Callbacks up to RemoteStore (aggregate concerns)
/// How a `HostConnection` talks back to `RemoteStore`. All fire on the main actor.
@@ -74,6 +83,10 @@ final class HostConnection {
/// Backfilled history for the open session (the full-transcript fetch). Merged into the
/// transcript by seq, not appended — these events precede the tail already on screen.
var openBackfill: (EventBatch) -> Void = { _ in }
/// A background transcript prefetch finished — everything it fetched (empty when the
/// host had nothing / the fetch died with the connection). RemoteStore merges it into
/// the offline cache and starts the next queued prefetch either way.
var transcriptPrefetched: (SessionID, [AgentEvent]) -> Void = { _, _ in }
/// The open session's full diff arrived.
var openDiff: (WireSessionDiff) -> Void = { _ in }
/// An approval was requested (with the resolved session title) — post the notification and,
@@ -571,6 +584,12 @@ final class HostConnection {
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break
case .transcriptChunk(let chunk):
// A slice of a *background prefetch*: accumulate it whole — it never touches the
// open transcript's cursor/dedup state, which belongs to the on-screen session.
if chunk.requestID == prefetchRequestID {
if chunk.sessionID == prefetchSessionID { prefetchEvents.append(contentsOf: chunk.events) }
break
}
// One ordered slice of the full-history fetch we started on open. Match on request id so
// a stale batch from a previous open (session switched underneath us) is ignored.
guard chunk.requestID == transcriptFetchRequestID, chunk.sessionID == openSessionID else { break }
@@ -582,10 +601,12 @@ final class HostConnection {
if let maxSeq = chunk.events.map(\.seq).max() { openMaxSeq = max(openMaxSeq ?? 0, maxSeq) }
if !fresh.isEmpty { callbacks.openBackfill(EventBatch(sessionID: chunk.sessionID, events: fresh)) }
case .transcriptFetchComplete(let done):
if done.requestID == prefetchRequestID { finishPrefetch(delivering: true); break }
// Terminal success — every batch shipped. Clear the in-flight id; the cursor/dedup state
// is already advanced by the chunks above.
if done.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil }
case .transcriptUnavailable(let un):
if un.requestID == prefetchRequestID { finishPrefetch(delivering: false); break }
// The host holds no transcript for this session (or can't serve the format). The cold tail
// still shows; there's just no deeper history to add. Clear the in-flight id.
if un.requestID == transcriptFetchRequestID { transcriptFetchRequestID = nil }
@@ -671,6 +692,39 @@ final class HostConnection {
send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: 0)))
}
/// Whether a background transcript prefetch can start now: live, the host can serve
/// transcript fetches, and no prefetch is already in flight (they run one at a time).
var canPrefetchTranscript: Bool {
connectivity.isLive && capabilities.canSyncTranscripts && client != nil && prefetchRequestID == nil
}
/// Fetch a session's transcript in the background — no subscribe, no open — so the offline
/// cache is warm before the user ever opens the session. `afterSeq` bounds the pull to what
/// the cache is missing. The result (all chunks, merged) arrives via
/// `callbacks.transcriptPrefetched`; returns false when it can't start (caller retries on a
/// later session-list update).
@discardableResult
func prefetchTranscript(_ sessionID: SessionID, afterSeq: UInt64) -> Bool {
guard canPrefetchTranscript else { return false }
let requestID = UUID().uuidString
prefetchRequestID = requestID
prefetchSessionID = sessionID
prefetchEvents = []
send(.fetchTranscript(TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: afterSeq)))
return true
}
/// Settle the in-flight prefetch: hand what arrived to RemoteStore (nothing on
/// unavailable/disconnect) and clear the slot so the next queued prefetch can start.
private func finishPrefetch(delivering: Bool) {
guard let sessionID = prefetchSessionID else { return }
let events = delivering ? prefetchEvents : []
prefetchRequestID = nil
prefetchSessionID = nil
prefetchEvents = []
callbacks.transcriptPrefetched(sessionID, events)
}
/// The device's network path changed (Wi-Fi ⇄ cellular, joined/left a network). React now rather
/// than waiting for a zombie LAN socket to time out or the reconnect backoff to elapse — this is
/// what makes the transport switch feel immediate. Only reconnect-managed hosts (a pinned host)
@@ -818,6 +872,9 @@ final class HostConnection {
eventTask?.cancel(); eventTask = nil
if let client { Task { await client.disconnect() } }
client = nil
// A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty
// so RemoteStore's queue isn't left waiting on a completion that will never arrive.
finishPrefetch(delivering: false)
}
}
@@ -359,6 +359,10 @@ final class RemoteStore: ObservableObject {
func onForeground() {
endBackgroundHold()
guard !demoMode, isPaired else { return }
// Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop
// push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
// it via `didUpdate` once live.
setForegroundState(true)
pathMonitor.refresh()
// Ensure a connection exists for every paired host (and drop unpaired); this re-dials the
// ones already known to be offline.
@@ -379,6 +383,10 @@ final class RemoteStore: ObservableObject {
/// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast.
func onBackground() {
guard !demoMode else { return }
// Heading to the background: the app can't reliably start a Live Activity anymore, so tell the
// hosts to push-to-start the glance themselves when work begins (UX_IOS §5.3). Sent now while
// the socket-hold below still keeps the connection live for a beat.
setForegroundState(false)
#if canImport(UIKit)
endBackgroundHold()
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") {
@@ -406,6 +414,10 @@ final class RemoteStore: ObservableObject {
/// state until the app next runs.
func handleLiveActivityAdoptWake(completion: @escaping () -> Void) {
guard !demoMode, isPaired else { completion(); return }
// A silent adopt wake means we're background (possibly cold-launched with no scene, where
// `isForeground`'s default would otherwise read stale-true) — record that so the reconnect
// below reports background and the host keeps owning the glance via push-to-start (§5.3).
setForegroundState(false)
setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already
startNetworkingIfNeeded()
#if canImport(UIKit)
@@ -634,28 +646,47 @@ final class RemoteStore: ObservableObject {
// the glance cold when work starts before the app is opened.
self.syncLiveActivityRegistration()
self.syncPushToStartRegistration()
// …and the current foreground state, so it push-to-starts the glance itself when the app
// is backgrounded rather than waiting on a self-create that can't happen (UX_IOS §5.3).
self.syncForegroundState()
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
// 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
// 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.openApprovals = snap.pendingApprovals
self.persistOpenTranscript()
}
cb.openEvents = { [weak self] batch in
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
self.openEvents.append(contentsOf: batch.events)
self.persistOpenTranscript()
self.enqueueOpenEvents(batch.events)
}
cb.openBackfill = { [weak self] batch in
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
// 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.persistOpenTranscript()
}
cb.transcriptPrefetched = { [weak self] sessionID, events in
guard let self else { return }
self.prefetchInFlight = nil
if !events.isEmpty {
// Merge into whatever the cache already holds (the fetch pulled only the gap);
// `saveEvents` re-caps to the tail window on write.
Task {
let cached = await SessionCache.loadEvents(sessionID)
await SessionCache.saveEvents(Self.mergedEvents(cached, events), for: sessionID)
}
}
self.pumpTranscriptPrefetch()
}
cb.openDiff = { [weak self] diff in
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
self.openDiff = diff
@@ -722,41 +753,57 @@ final class RemoteStore: ObservableObject {
/// or a representative one. No-op in demo, which seeds the aggregate directly.
private func rebuildAggregate() {
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()
if !live.isEmpty {
sessions = live
cachedSummaries = live
persistSummaries(live)
if sessions != live {
sessions = live
cachedSummaries = live
persistSummaries(live)
// The list moved — new or advanced sessions may need their transcript cache
// warmed so a first open starts at the end like a revisit does.
scheduleTranscriptPrefetch()
}
} else if connections.values.contains(where: { $0.connectivity.isLive }) {
// Connected, but the host genuinely has no sessions — reflect that honestly.
sessions = []
setIfChanged(\.sessions, [])
} else {
// Offline: keep showing the saved history rather than blanking the list.
sessions = cachedSummaries
setIfChanged(\.sessions, cachedSummaries)
}
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
meshPeers = connections.values.flatMap(\.meshPeers)
setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard)))
setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers))
let ctx = contextConnection
hostName = ctx?.hostName ?? ""
capabilities = ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
modelCatalog = ctx?.modelCatalog ?? .empty
setIfChanged(\.hostName, ctx?.hostName ?? "")
setIfChanged(\.capabilities, ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances.
connectivity = conn.connectivity
grantedScope = conn.grantedScope
setIfChanged(\.connectivity, conn.connectivity)
setIfChanged(\.grantedScope, conn.grantedScope)
} else {
connectivity = aggregateConnectivity()
setIfChanged(\.connectivity, aggregateConnectivity())
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there).
grantedScope = connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
setIfChanged(\.grantedScope, connections.values
.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
/// 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.
@@ -790,10 +837,130 @@ final class RemoteStore: ObservableObject {
Task { [weak self] in
let cached = await SessionCache.loadEvents(sessionID)
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)
}
}
// 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.
private func persistOpenTranscript() {
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
@@ -910,6 +1077,7 @@ final class RemoteStore: ObservableObject {
openSessionID = nil
compactDetailPresented = false
openSessionHostID = nil
discardPendingOpenEvents()
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
connectivity = .unpaired
// No Mac left whose history to hold — drop the offline cache too.
@@ -944,6 +1112,7 @@ final class RemoteStore: ObservableObject {
openSessionID = nil
compactDetailPresented = false
openSessionHostID = nil
discardPendingOpenEvents()
openEvents = []
openApprovals = []
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
// the host-specific projected values follow it.
openSessionHostID = connection(owningSession: sessionID)?.hostID
discardPendingOpenEvents()
openEvents = []
openApprovals = []
openDiff = nil
@@ -1149,6 +1319,8 @@ final class RemoteStore: ObservableObject {
compactDetailPresented = false
openSessionHostID = nil
// 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)
openEvents = []
openApprovals = []
@@ -1225,6 +1397,64 @@ final class RemoteStore: ObservableObject {
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
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
/// 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()`, not through this owner-routing switch.
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode, .respondMacPair:
break
@@ -1296,6 +1526,14 @@ final class RemoteStore: ObservableObject {
private var pushToStartToken: String?
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
private var pushToStartSentTo: Set<String> = []
/// Whether the app is currently foreground. Reported to capable Macs (`ClientMsg.setForeground`)
/// so they know whether to defer to the phone's own Live Activity creation (foreground) or
/// push-to-start the glance themselves (backgrounded — the phone can't reliably start one). Starts
/// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3).
private var isForeground = true
/// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the
/// next sync re-notifies every host (mirrors `pushToStartSentTo`).
private var foregroundSentTo: Set<String> = []
/// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt
/// wake, and installing the callbacks / observers once is enough.
@@ -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)
//
// 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,
// Live Activity push registration is a real-connection concern (there's no host to
// push in demo), so it's inert here.
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
@@ -22,8 +22,9 @@ enum SessionCache {
/// files) are pruned on every save so the cache stays bounded.
private static let sessionLimit = 50
/// Keep the newest N events per session — the tail is what a returning reader wants, and it
/// bounds a long-running session's file.
private static let eventLimit = 1500
/// bounds a long-running session's file. Internal (not private) so the background transcript
/// prefetch can bound a cold session's pull to the same window this cache would keep anyway.
static let eventLimit = 1500
// MARK: - Paths
@@ -108,21 +108,15 @@ final class NotificationRouter: NSObject {
center.removePendingNotificationRequests(withIdentifiers: [identifier])
}
/// Recall the shared "waiting for your approval" attention notification once nothing is pending
/// anywhere. This is the remote wake tickle (delivered under `approvalTickleIdentifier`) that a
/// prior push left on the lock screen — the counterpart to the per-approval `withdrawApproval`.
/// Arrives two ways: the relay's silent `clear` push (phone was away), or the store's own
/// reconcile when a live socket sees the last approval resolve. Also sweeps any per-approval
/// locals a live socket posted but hasn't individually withdrawn, so the lock screen ends clean.
/// Recall the generic remote wake tickle ("A session is waiting for your approval") once no
/// approval is pending anywhere. This targets *only* the shared tickle (delivered under
/// `approvalTickleIdentifier`, its APNs collapse-id) — the content-free push a phone gets while
/// away. Per-approval notifications are each recalled by `withdrawApproval(_:)` as they resolve,
/// so a still-pending approval's own banner is never swept away by this.
func withdrawApprovalAttention() {
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier])
center.removePendingNotificationRequests(withIdentifiers: [Self.approvalTickleIdentifier])
center.getDeliveredNotifications { delivered in
let stale = delivered.map(\.request.identifier).filter { $0.hasPrefix("approval-") }
guard !stale.isEmpty else { return }
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: stale)
}
}
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
@@ -31,56 +31,66 @@ struct SessionDetailView: View {
}
var body: some View {
TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest,
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)
// 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,
scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom)
// 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.
.safeAreaInset(edge: .bottom) { actionArea }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
// from any entry point, not just the Sessions list), not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.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
}
}
.onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 }
// 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.
.safeAreaInset(edge: .bottom) { actionArea }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
// from any entry point, not just the Sessions list), not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.background { interruptShortcut }
}
/// ⌘. 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) })
}
/// 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
/// 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? {
let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in
switch event.kind {
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
case .usage(let usage): return usage.contextInputTokens
default: return nil
}
}.first { $0 > 0 }
guard let used else { return nil }
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 {
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
case .usage(let usage): return usage.contextInputTokens
default: return nil
}
}.first { $0 > 0 }
}
guard let used = cache.used else { return nil }
let window = store.modelCatalog.contextWindow(summary?.model)
guard window > 0 else { return nil }
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.
private var actionArea: some View {
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
// disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner }
@@ -270,6 +291,29 @@ struct SessionDetailView: View {
}
.padding(.horizontal, 12)
.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)
}
@@ -352,6 +396,18 @@ struct SessionDetailView: View {
.textFieldStyle(.plain)
.lineLimit(1...4)
.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 {
store.sendInput(draft, to: sessionID)
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 {
let events: [AgentEvent]
/// 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.
@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] {
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
@@ -490,6 +590,43 @@ struct TranscriptList: View {
/// the Mac's `bottomFollowThreshold`.
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 {
ScrollViewReader { proxy in
ScrollView {
@@ -527,22 +664,35 @@ struct TranscriptList: View {
// they scroll away, which lets new content land off-screen below instead of dragging
// the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.onScrollPhaseChange { _, newPhase in
scrollPhase = newPhase
// A real grab (drag displacement, not a mere touch-down) takes over from an
// in-flight programmatic jump: the user may scroll away again immediately.
if newPhase == .interacting { jumpingToBottom = false }
}
// Track the live scroll position straight from the scroll view's geometry: how much
// content still sits below the viewport bottom. Within the slack threshold means the
// user is parked at the end (live output keeps following); scrolling up flips this
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron.
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
<= bottomFollowThreshold
} action: { _, atBottom in
// While the chat is opening the layout grows over a few passes and the scroll
// offset lags each growth by a frame — sampling that frame reads "not at bottom"
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the
// open window accept only "at bottom" readings; honor real scroll-ups after.
if transcriptSettling {
if atBottom { isScrolledToBottom = true }
} else {
isScrolledToBottom = atBottom
// content still sits below the viewport bottom. Parked within the follow threshold
// means live output keeps following; a *user* scroll past the (wider) unfollow
// threshold flips it false, which drops the anchor (above) and reveals the chevron.
// Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
// programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
// animation) may re-engage following but can never disengage it, so no layout
// feedback cycle through this gate can sustain itself. Tracking the rounded distance
// (not a Bool) also means every scroll emits fresh values, so the gate can't latch
// against a stale reading (the old settle-window latch bug).
.onScrollGeometryChange(for: CGFloat.self) { geo in
(geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
} action: { _, distance in
if distance <= bottomFollowThreshold {
isScrolledToBottom = true
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
@@ -556,19 +706,54 @@ struct TranscriptList: View {
guard transcriptSettling, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80))
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
}
// 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
// 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) {
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,
// so it sits over the composer instead of behind the floating bar; a tap bumps this
// same request, and following resumes once the geometry reader sees the bottom.
.onChange(of: scrollToBottomRequest) { scrollToEnd(proxy) }
// An explicit jump — the chevron or sending a message — always wins, and it *is* the
// user saying "follow again": re-engage the gate directly rather than waiting for the
// geometry to confirm. The animated scroll can come to rest a few points short of the
// 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])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted(by: StatusStyle.attentionThenRecency)
let needs = pool.filter { $0.status.needsYou($0.disposition) }
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
// ran O(rows²) full-summary equality scans on every body evaluation.
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 }
}
@@ -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
/// is independent of `bodySize` — so the source string is a complete key. Bounded;
/// `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 static let blockCache: NSCache<NSString, ParsedBlocks> = {
nonisolated(unsafe) private static let blockCache: NSCache<NSString, ParsedBlocks> = {
let cache = NSCache<NSString, ParsedBlocks>()
cache.countLimit = 2048
return cache
}()
private static func parse(_ markdown: String) -> [Block] {
nonisolated private static func parse(_ markdown: String) -> [Block] {
let key = markdown as NSString
if let hit = blockCache.object(forKey: key) { return hit.blocks }
let blocks = parseUncached(markdown)
@@ -55,7 +60,7 @@ struct MarkdownText: View {
return blocks
}
private static func parseUncached(_ markdown: String) -> [Block] {
nonisolated private static func parseUncached(_ markdown: String) -> [Block] {
var blocks: [Block] = []
var textBuffer: [String] = []
func flush() {
@@ -100,12 +105,12 @@ struct MarkdownText: View {
/// A GitHub-style table: a `|`-bearing header line immediately followed by a
/// `|---|:--:|` 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 }
return isSeparatorRow(lines[index + 1])
}
private static func isSeparatorRow(_ line: String) -> Bool {
nonisolated private static func isSeparatorRow(_ line: String) -> Bool {
let cells = tableCells(line)
guard !cells.isEmpty else { return false }
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
/// leading/trailing pipes.
private static func tableCells(_ line: String) -> [String] {
nonisolated private static func tableCells(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("|") { trimmed.removeFirst() }
if trimmed.hasSuffix("|") { trimmed.removeLast() }
@@ -167,31 +172,59 @@ struct MarkdownText: View {
.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
private func lineView(_ raw: String) -> some View {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
switch Self.classify(raw) {
case .blank:
Color.clear.frame(height: 3)
} else if trimmed.hasPrefix("### ") {
// Headings scale relative to the base prose size so the hierarchy holds
// at any base and never collapses to the body size.
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) {
case .heading(let content, let scale, let weight):
inline(content).font(.system(size: bodySize * scale, weight: weight))
case .bullet(let content):
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•").foregroundStyle(.secondary)
inline(bullet)
inline(content)
}
} else {
inline(raw)
case .plain(let content):
inline(content)
}
}
/// 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) {
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
/// `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 static let inlineCache: NSCache<NSString, InlineBox> = {
nonisolated(unsafe) private static let inlineCache: NSCache<NSString, InlineBox> = {
let cache = NSCache<NSString, InlineBox>()
cache.countLimit = 16384
return cache
}()
private static func attributedInline(_ string: String) -> AttributedString {
nonisolated private static func attributedInline(_ string: String) -> AttributedString {
let key = string as NSString
if let hit = inlineCache.object(forKey: key) { return hit.value }
let options = AttributedString.MarkdownParsingOptions(
@@ -230,4 +263,35 @@ struct MarkdownText: View {
inlineCache.setObject(InlineBox(parsed), forKey: key)
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
/// 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.
///
/// 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] {
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)
// 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
// 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
// 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),
worktreeRoot: root)
return coalesceToolRuns(flat).map {
worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches)
let items = coalesceToolRuns(flat).map {
attachSubagentChildren($0, byParent: byParent, depth: 0,
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
/// 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).
private static func worktreeRoot(in events: [AgentEvent]) -> String? {
static func worktreeRoot(in events: ArraySlice<AgentEvent>) -> String? {
for event in events {
if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty {
return started.cwd
@@ -138,7 +183,15 @@ enum TranscriptProjection {
/// `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
/// 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.
var editsByIndex: [Int: (id: String, paths: [String])] = [:]
for (i, item) in flat.enumerated() {
@@ -148,7 +201,8 @@ enum TranscriptProjection {
.filter { !$0.isEmpty }
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 folded = Set<Int>()
@@ -167,13 +221,26 @@ enum TranscriptProjection {
guard let edit = editsByIndex[j] else { continue }
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 }
if let k = indexByID[hitID] { perCard[k].paths.append(path) }
else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) }
}
guard matchedAll, !perCard.isEmpty else { continue }
for card in perCard {
locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths))
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))
}
}
folded.insert(i)
}
@@ -235,7 +302,7 @@ enum TranscriptProjection {
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
/// the spawning `Task`'s id.
private static func partition(_ events: [AgentEvent])
private static func partition(_ events: ArraySlice<AgentEvent>)
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
{
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
/// 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.
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] {
private static func toolParentMap(_ events: ArraySlice<AgentEvent>) -> [String: String] {
var map: [String: String] = [:]
for event in events {
switch event.kind {
@@ -512,4 +579,21 @@ extension JSONValue {
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
}
/// A one-line gist of a tool input/result for the collapsed row.
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"
}
}
// (`compactSummary` — the one-line gist — lives in TranscriptProjection.swift, which builds
// standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs it there.)
/// The full, **untruncated** content of a tool input for the approval card —
/// the user must see exactly what they are granting before allowing. Unlike
@@ -50,10 +50,12 @@ struct SessionLiveActivity: Widget {
}
}
} 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
? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running))
? ActivityPalette.glyph(.approval) : "atom")
.foregroundStyle(state.needsAttention
? ActivityPalette.attention : ActivityPalette.active)
? ActivityPalette.attention : ActivityPalette.brand)
} compactTrailing: {
Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)")
.font(.caption2.bold())
@@ -131,15 +133,42 @@ private struct LockScreenView: View {
private struct SessionRow: View {
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 {
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 }
} else {
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) {
Image(systemName: ActivityPalette.glyph(line.kind))
.font(.footnote)
@@ -159,6 +188,12 @@ private struct SessionRow: View {
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
private var rowContent: some View {
HStack(spacing: 8) {
rowIdentity
Spacer(minLength: 6)
Text(line.detail)
.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.
private struct CountChips: View {
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 danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red
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 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",
/// "Waiting on you", "Working…", etc.
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 {