diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index db9ea3e..50da6ad 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -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"; diff --git a/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift new file mode 100644 index 0000000..e1beebf --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/ApprovalIntents.swift @@ -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)") + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift new file mode 100644 index 0000000..9a73fd3 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/IntentError.swift @@ -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." + } + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift b/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift new file mode 100644 index 0000000..6e16131 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/NucleicShortcuts.swift @@ -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") + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift new file mode 100644 index 0000000..fff0b8e --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift new file mode 100644 index 0000000..8e0f0d9 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift @@ -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/` 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) + } +} diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 73dddd9..44b1de3 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -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.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) } diff --git a/NucleicRemote/NucleicRemote/Localizable.strings b/NucleicRemote/NucleicRemote/Localizable.strings index a690f5a..b9b26a0 100644 --- a/NucleicRemote/NucleicRemote/Localizable.strings +++ b/NucleicRemote/NucleicRemote/Localizable.strings @@ -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"; diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 6021e0a..ef0f7e1 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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) } } diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index f7fe423..1327198 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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(_ keyPath: ReferenceWritableKeyPath, _ 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? + 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 = [] + /// 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 = [] /// 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, diff --git a/NucleicRemote/NucleicRemote/Models/SessionCache.swift b/NucleicRemote/NucleicRemote/Models/SessionCache.swift index fa8b0f2..ed68dba 100644 --- a/NucleicRemote/NucleicRemote/Models/SessionCache.swift +++ b/NucleicRemote/NucleicRemote/Models/SessionCache.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Notifications.swift b/NucleicRemote/NucleicRemote/Notifications.swift index fb2d0dc..b2c601f 100644 --- a/NucleicRemote/NucleicRemote/Notifications.swift +++ b/NucleicRemote/NucleicRemote/Notifications.swift @@ -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). diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 8247caf..73323df 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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? + + /// 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() } } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index cd4be2a..9b6ef9d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -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 } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift new file mode 100644 index 0000000..7f76c9d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift @@ -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.. = [] + /// 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..() + var seenThinkingItem = Set() + var seenToolItem = Set() + var lastChunkInScope: [String: String] = [:] + var lastBoundary = -1 // region index of the latest turnCompleted/runFinished + + for r in 0..= 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.. [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 + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4655543..3f5fb5e 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -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 = { + nonisolated(unsafe) private static let blockCache: NSCache = { let cache = NSCache() 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 = { + nonisolated(unsafe) private static let inlineCache: NSCache = { let cache = NSCache() 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) } + } + } + } + } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 38e04fc..6f52da4 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -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, 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) -> 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() @@ -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) -> (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) -> [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" + } + } } diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index f492cec..ebe23b2 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -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 diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 5e2f336..8619626 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -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) diff --git a/NucleicRemote/Shared/ApproveFromActivityIntent.swift b/NucleicRemote/Shared/ApproveFromActivityIntent.swift new file mode 100644 index 0000000..747baad --- /dev/null +++ b/NucleicRemote/Shared/ApproveFromActivityIntent.swift @@ -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 + } +} diff --git a/NucleicRemote/Shared/SessionActivityAttributes.swift b/NucleicRemote/Shared/SessionActivityAttributes.swift index 9809710..b352ef9 100644 --- a/NucleicRemote/Shared/SessionActivityAttributes.swift +++ b/NucleicRemote/Shared/SessionActivityAttributes.swift @@ -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 {