From 4764f483ffaa96d390dd6b3830f0d02fd5a6cb53 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 14:44:03 -0700 Subject: [PATCH] Read App Intents Documentation Nucleic-Session: 56C5F781-4444-466B-91CE-A7734A8E117F Co-authored-by: Nucleic --- .../AppIntents/SessionEntity.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift new file mode 100644 index 0000000..fff0b8e --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionEntity.swift @@ -0,0 +1,80 @@ +import AppIntents +import NucleicProtocol + +/// A Nucleic agent session exposed to Siri / Shortcuts / Spotlight (docs/APP_INTENTS_OPPORTUNITIES +/// §4.6). Backed by the host's `SessionSummary` projection the phone already holds; the entity id +/// is the wire `SessionID`, so it maps straight onto the deep-link and control APIs. +/// +/// A thin, `Sendable` value snapshot — never the live store row. The `EntityQuery` re-reads +/// `RemoteStore` each time so a stale Siri suggestion never acts on outdated state. +struct SessionEntity: AppEntity, Identifiable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Session") + static let defaultQuery = SessionEntityQuery() + + /// `SessionID.rawValue`. + var id: String + var title: String + var projectName: String + var statusLabel: String + /// Whether this session is waiting on the user (approval, or a non-completed input turn). + var needsAttention: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: "\(projectName) · \(statusLabel)") + } +} + +extension SessionEntity { + /// Project a wire summary into the entity (falling back to the project name for an untitled + /// session, exactly as the session list does). + init(_ s: WireSessionSummary) { + self.init( + id: s.sessionID.rawValue, + title: s.title.isEmpty ? s.projectName : s.title, + projectName: s.projectName, + statusLabel: StatusStyle.label(s.status, disposition: s.disposition), + needsAttention: s.status.needsYou(s.disposition)) + } +} + +/// Resolves `SessionEntity` values for Shortcuts/Siri from the phone's current (or cached) session +/// list. Discovery paths (`suggestedEntities`, string matching) bootstrap the channel first so a +/// cold intent process still has data; id resolution stays fast and offline-tolerant. +struct SessionEntityQuery: EntityQuery { + @MainActor + func entities(for identifiers: [SessionEntity.ID]) async throws -> [SessionEntity] { + let wanted = Set(identifiers) + return RemoteStore.shared.liveSessions + .filter { wanted.contains($0.sessionID.rawValue) } + .map(SessionEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [SessionEntity] { + let store = RemoteStore.shared + store.bootstrapForIntent() + return store.liveSessions + .sorted(by: StatusStyle.attentionThenRecency) + .prefix(12) + .map(SessionEntity.init) + } +} + +/// Lets Siri match a session by spoken name ("open payment-flow") against title or project. +extension SessionEntityQuery: EntityStringQuery { + @MainActor + func entities(matching string: String) async throws -> [SessionEntity] { + let store = RemoteStore.shared + store.bootstrapForIntent() + let needle = string.lowercased() + return store.liveSessions + .filter { + let title = ($0.title.isEmpty ? $0.projectName : $0.title).lowercased() + return title.contains(needle) || $0.projectName.lowercased().contains(needle) + } + .sorted(by: StatusStyle.attentionThenRecency) + .map(SessionEntity.init) + } +}