81 lines
3.2 KiB
Swift
81 lines
3.2 KiB
Swift
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)
|
|
}
|
|
}
|