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