Commit 7e9802570d

7e9802570d73f41a8d000d3b2c1aa560fc421e05

parent: b35798691d

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 08:06 UTC

Expand App Intents: dialogs, search type, mutating intents (#17)

Addresses the three open items on the App Intents ticket:

- Check Status / Check Builds now return a spoken/visible dialog alongside
  their value, so they surface a result in Siri instead of appearing to do
  nothing. Logic consolidated to always produce a message.
- Search Hutch gains a Search Type parameter (user, git/hg repo, mailing
  list, tracker, build job) threaded through the .search route into Lookup,
  which pre-selects the type. LookupType now conforms to AppEnum.
- Adds mutating intents (not just navigation): Clear Recent Activity and
  Unpin Resource, backed by RecentActivityStore.clear and a new
  HomePinStore.removePin. Both act on the active account's storage.

Tests cover search-type routing, the blank-query fallback, and pin removal.

Layout: unified · split

Hutch/App/DeepLink.swift +13 −8
@@ -25,7 +25,7 @@ enum HutchRoute: Equatable, Sendable {
2525 case trackers
2626 case systemStatus
2727 case lookup
28 case search(query: String)
28 case search(query: String, type: LookupType?)
2929 case projectDashboard(id: String, title: String?)
3030
3131 init?(url: URL) {
@@ -103,7 +103,8 @@ enum HutchRoute: Equatable, Sendable {
103103
104104 case "lookup":
105105 if let query = queryValue("q"), !query.isEmpty {
106 self = .search(query: query)
106 let type = queryValue("type").flatMap(LookupType.init(rawValue:))
107 self = .search(query: query, type: type)
107108 } else {
108109 self = .lookup
109110 }
@@ -148,8 +149,12 @@ enum HutchRoute: Equatable, Sendable {
148149 return Self.makeURL(host: "status")
149150 case .lookup:
150151 return Self.makeURL(host: "lookup")
151 case .search(let query):
152 return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)])
152 case .search(let query, let type):
153 var items = [URLQueryItem(name: "q", value: query)]
154 if let type {
155 items.append(URLQueryItem(name: "type", value: type.rawValue))
156 }
157 return Self.makeURL(host: "lookup", queryItems: items)
153158 case .projectDashboard(let id, let title):
154159 return Self.makeURL(
155160 host: "projects",
@@ -205,8 +210,8 @@ enum DeepLink: Equatable {
205210 case systemStatus
206211 /// hutch://lookup
207212 case lookup
208 /// hutch://lookup?q=<query>
209 case search(query: String)
213 /// hutch://lookup?q=<query>&type=<type>
214 case search(query: String, type: LookupType?)
210215 /// hutch://builds?filter=failed
211216 case failedBuilds
212217 /// hutch://projects/<rid>
@@ -253,8 +258,8 @@ enum DeepLink: Equatable {
253258 self = .systemStatus
254259 case .lookup:
255260 self = .lookup
256 case .search(let query):
257 self = .search(query: query)
261 case .search(let query, let type):
262 self = .search(query: query, type: type)
258263 case .projectDashboard(let id, let title):
259264 self = .projectDashboard(id: id, title: title)
260265 }
Hutch/App/HutchIntents.swift +104 −43
@@ -132,11 +132,14 @@ struct SearchHutchIntent: AppIntent {
132132 @Parameter(title: "Query")
133133 var query: String
134134
135 @Parameter(title: "Search Type", default: .user)
136 var searchType: LookupType
137
135138 var route: HutchRoute {
136139 let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
137 // Routes to Lookup for now; repoint at a global content search when Hutch
138 // gains one — tracked in ROADMAP.md § "App Intent gaps".
139 return normalized.isEmpty ? .lookup : .search(query: normalized)
140 // Routes to Lookup, pre-selecting the search type, until Hutch gains a
141 // global content search.
142 return normalized.isEmpty ? .lookup : .search(query: normalized, type: searchType)
140143 }
141144
142145 @MainActor
@@ -147,7 +150,7 @@ struct SearchHutchIntent: AppIntent {
147150}
148151
149152// An OpenSavedSearchIntent belongs here once Hutch has global saved-search
150// persistence — tracked in ROADMAP.md § "App Intent gaps".
153// persistence.
151154
152155// MARK: - App Entities
153156
@@ -201,7 +204,7 @@ struct ProjectEntityQuery: EntityQuery {
201204 }
202205}
203206
204private enum HutchIntentEntityStore {
207enum HutchIntentEntityStore {
205208 static func pinnedResources() -> [PinnedResourceEntity] {
206209 pins().compactMap { makePinnedResource(from: $0) }
207210 }
@@ -213,22 +216,27 @@ private enum HutchIntentEntityStore {
213216 }
214217 }
215218
216 private static func pins() -> [HomePinRecord] {
219 /// The active account's key, or `nil` when no account is signed in.
220 static func currentUserKey() -> String? {
217221 guard let userKey = ContributionWidgetContextStore.loadActor(),
218222 !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
219223 else {
220 return []
224 return nil
221225 }
222
223 return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults)
226 return userKey
224227 }
225228
226 private static var activeAccountDefaults: UserDefaults {
229 static var accountDefaults: UserDefaults {
227230 let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
228231 guard !activeID.isEmpty else { return .standard }
229232 return AccountDefaultsStore.userDefaults(for: activeID)
230233 }
231234
235 private static func pins() -> [HomePinRecord] {
236 guard let userKey = currentUserKey() else { return [] }
237 return HomePinStore.loadPins(for: userKey, defaults: accountDefaults)
238 }
239
232240 private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? {
233241 guard let route = route(for: pin) else { return nil }
234242 return PinnedResourceEntity(
@@ -274,20 +282,22 @@ struct CheckSystemStatusIntent: AppIntent {
274282 static var description = IntentDescription("Returns the current SourceHut system status.")
275283
276284 @MainActor
277 func perform() async throws -> some IntentResult & ReturnsValue<String> {
278 guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
279 return .result(value: "System status is unavailable. Open Hutch to refresh.")
280 }
281
282 if snapshot.hasDisruption {
283 let disrupted = snapshot.services
284 .filter { $0.requiresAttention }
285 .map { "\($0.name): \($0.status)" }
286 .joined(separator: ", ")
287 return .result(value: "SourceHut disruption detected: \(disrupted)")
285 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
286 let message: String
287 if let snapshot = SystemStatusWidgetSnapshotStore.load() {
288 if snapshot.hasDisruption {
289 let disrupted = snapshot.services
290 .filter { $0.requiresAttention }
291 .map { "\($0.name): \($0.status)" }
292 .joined(separator: ", ")
293 message = "SourceHut disruption detected: \(disrupted)"
294 } else {
295 message = "All SourceHut services operational."
296 }
297 } else {
298 message = "System status is unavailable. Open Hutch to refresh."
288299 }
289
290 return .result(value: "All SourceHut services operational.")
300 return .result(value: message, dialog: IntentDialog(stringLiteral: message))
291301 }
292302}
293303
@@ -296,34 +306,85 @@ struct CheckBuildsIntent: AppIntent {
296306 static var description = IntentDescription("Returns a summary of your recent build status.")
297307
298308 @MainActor
299 func perform() async throws -> some IntentResult & ReturnsValue<String> {
300 guard let snapshot = NeedsAttentionSnapshotStore.load() else {
301 return .result(value: "Build status unavailable. Open Hutch to refresh.")
302 }
309 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
310 let message: String
311 if let snapshot = NeedsAttentionSnapshotStore.load() {
312 var parts: [String] = []
313
314 if let failed = snapshot.failedBuilds {
315 if failed > 0 {
316 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
317 } else {
318 parts.append("No failed builds")
319 }
320 }
303321
304 var parts: [String] = []
322 if let unread = snapshot.unreadInboxThreads, unread > 0 {
323 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
324 }
305325
306 if let failed = snapshot.failedBuilds {
307 if failed > 0 {
308 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
309 } else {
310 parts.append("No failed builds")
326 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
327 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
311328 }
312 }
313329
314 if let unread = snapshot.unreadInboxThreads, unread > 0 {
315 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
330 message = parts.isEmpty ? "No recent data. Open Hutch to refresh." : parts.joined(separator: ". ") + "."
331 } else {
332 message = "Build status unavailable. Open Hutch to refresh."
316333 }
334 return .result(value: message, dialog: IntentDialog(stringLiteral: message))
335 }
336}
317337
318 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
319 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
320 }
338// MARK: - Search Type
321339
322 if parts.isEmpty {
323 return .result(value: "No recent data. Open Hutch to refresh.")
324 }
340extension LookupType: @retroactive AppEnum {
341 public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation {
342 TypeDisplayRepresentation(name: "Search Type")
343 }
344
345 public nonisolated static var caseDisplayRepresentations: [LookupType: DisplayRepresentation] {
346 [
347 .user: "User",
348 .gitRepo: "Git Repository",
349 .hgRepo: "Mercurial Repository",
350 .mailingList: "Mailing List",
351 .tracker: "Tracker",
352 .buildJob: "Build Job"
353 ]
354 }
355}
356
357// MARK: - Mutating Intents
358
359struct ClearRecentActivityIntent: AppIntent {
360 static var title: LocalizedStringResource = "Clear Recent Activity"
361 static var description = IntentDescription("Clears the Recent list on the Hutch Home tab.")
362
363 @MainActor
364 func perform() async throws -> some IntentResult & ProvidesDialog {
365 RecentActivityStore.clear(defaults: HutchIntentEntityStore.accountDefaults)
366 return .result(dialog: "Cleared recent activity.")
367 }
368}
369
370struct UnpinResourceIntent: AppIntent {
371 static var title: LocalizedStringResource = "Unpin Resource"
372 static var description = IntentDescription("Removes a pinned resource from the Hutch Home tab.")
325373
326 return .result(value: parts.joined(separator: ". ") + ".")
374 @Parameter(title: "Pinned Resource")
375 var pinnedResource: PinnedResourceEntity
376
377 @MainActor
378 func perform() async throws -> some IntentResult & ProvidesDialog {
379 guard let userKey = HutchIntentEntityStore.currentUserKey() else {
380 return .result(dialog: "No active Hutch account.")
381 }
382 HomePinStore.removePin(
383 id: pinnedResource.id,
384 for: userKey,
385 defaults: HutchIntentEntityStore.accountDefaults
386 )
387 return .result(dialog: "Unpinned \(pinnedResource.name).")
327388 }
328389}
329390
Hutch/App/RootView.swift +6 −6
@@ -247,12 +247,12 @@ struct RootView: View {
247247 appState.pendingBuildListFilter = .failed
248248 appState.selectedTab = .builds
249249
250 case .search(let query):
250 case .search(let query, let type):
251251 morePath = NavigationPath()
252252 appState.selectedTab = .more
253253 Task {
254254 await settleNavigationTransition()
255 morePath.append(MoreRoute.lookup(query: query))
255 morePath.append(MoreRoute.lookup(query: query, type: type))
256256 }
257257
258258 case .lookup:
@@ -260,7 +260,7 @@ struct RootView: View {
260260 appState.selectedTab = .more
261261 Task {
262262 await settleNavigationTransition()
263 morePath.append(MoreRoute.lookup(query: nil))
263 morePath.append(MoreRoute.lookup(query: nil, type: nil))
264264 }
265265
266266 case .buildsTab:
@@ -431,7 +431,7 @@ enum MoreDestination: Hashable {
431431}
432432
433433enum MoreRoute: Hashable {
434 case lookup(query: String?)
434 case lookup(query: String?, type: LookupType?)
435435 case projects
436436 case lists
437437 case pastes
@@ -454,8 +454,8 @@ private struct MoreNavigationRoot: View {
454454 MoreView()
455455 .navigationDestination(for: MoreRoute.self) { route in
456456 switch route {
457 case .lookup(let query):
458 LookupView(initialQuery: query ?? "")
457 case .lookup(let query, let type):
458 LookupView(initialQuery: query ?? "", initialType: type)
459459 case .projects:
460460 ProjectsListView()
461461 case .lists:
Hutch/Views/Home/HomePinStore.swift +15
@@ -144,6 +144,21 @@ enum HomePinStore {
144144 saveAll(pinsByUser, defaults: defaults)
145145 }
146146
147 static func removePin(
148 id: String,
149 for userKey: String,
150 defaults: UserDefaults = .standard
151 ) {
152 let normalizedUserKey = normalizedUserKey(userKey)
153 guard !normalizedUserKey.isEmpty else { return }
154
155 var pinsByUser = loadAll(defaults: defaults)
156 var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
157 pins.removeAll { $0.id == id }
158 pinsByUser[normalizedUserKey] = pins
159 saveAll(pinsByUser, defaults: defaults)
160 }
161
147162 static func pinnedProjectIDs(
148163 for userKey: String,
149164 defaults: UserDefaults = .standard
Hutch/Views/Lookup/LookupView.swift +12 −5
@@ -96,13 +96,17 @@ final class LookupViewModel {
9696 client: SRHTClient,
9797 appState: AppState,
9898 defaults: UserDefaults = .standard,
99 initialQuery: String = ""
99 initialQuery: String = "",
100 initialType: LookupType? = nil
100101 ) {
101102 self.client = client
102103 self.appState = appState
103104 self.defaults = defaults
104105 self.history = LookupHistoryStore.load(defaults: defaults)
105106 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
107 if let initialType {
108 self.selectedType = initialType
109 }
106110 }
107111
108112 func lookup() async {
@@ -336,9 +340,11 @@ struct LookupView: View {
336340 @Environment(AppState.self) private var appState
337341 @State private var viewModel: LookupViewModel?
338342 private let initialQuery: String
343 private let initialType: LookupType?
339344
340 init(initialQuery: String = "") {
345 init(initialQuery: String = "", initialType: LookupType? = nil) {
341346 self.initialQuery = initialQuery
347 self.initialType = initialType
342348 }
343349
344350 var body: some View {
@@ -356,7 +362,8 @@ struct LookupView: View {
356362 client: appState.client,
357363 appState: appState,
358364 defaults: appState.accountDefaults,
359 initialQuery: initialQuery
365 initialQuery: initialQuery,
366 initialType: initialType
360367 )
361368 }
362369 }
@@ -443,8 +450,8 @@ struct LookupView: View {
443450 }
444451 .navigationDestination(for: MoreRoute.self) { route in
445452 switch route {
446 case .lookup(let query):
447 LookupView(initialQuery: query ?? "")
453 case .lookup(let query, let type):
454 LookupView(initialQuery: query ?? "", initialType: type)
448455 case .projects:
449456 ProjectsListView()
450457 case .lists:
Hutch/Views/More/MoreView.swift +1 −1
@@ -13,7 +13,7 @@ struct MoreView: View {
1313 var body: some View {
1414 List {
1515 Section("Search") {
16 NavigationLink(value: MoreRoute.lookup(query: nil)) {
16 NavigationLink(value: MoreRoute.lookup(query: nil, type: nil)) {
1717 Label("Look Up", systemImage: "magnifyingglass")
1818 }
1919 .themedRow()
HutchTests/DeepLinkTests.swift +2 −1
@@ -92,7 +92,8 @@ struct DeepLinkTests {
9292 func parsesRouteBackedNavigationLinks() {
9393 #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned))
9494 #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds)
95 #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue"))
95 #expect(DeepLink(url: HutchRoute.search(query: "patch queue", type: nil).url) == .search(query: "patch queue", type: nil))
96 #expect(DeepLink(url: HutchRoute.search(query: "~alice", type: .user).url) == .search(query: "~alice", type: .user))
9697 #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch"))
9798 }
9899
HutchTests/HutchIntentsTests.swift +33
@@ -110,4 +110,37 @@ struct HutchIntentsTests {
110110 ActiveAccountContextStore.save("account-a", defaults: defaults)
111111 #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
112112 }
113
114 @Test
115 func searchIntentCarriesQueryAndType() {
116 let intent = SearchHutchIntent()
117 intent.query = "~alice/hutch"
118 intent.searchType = .tracker
119 #expect(intent.route == .search(query: "~alice/hutch", type: .tracker))
120 }
121
122 @Test
123 func searchIntentWithBlankQueryFallsBackToLookup() {
124 let intent = SearchHutchIntent()
125 intent.query = " "
126 intent.searchType = .user
127 #expect(intent.route == .lookup)
128 }
129
130 @Test
131 func removePinDropsMatchingResource() {
132 let defaultsName = "HutchIntentsTests-unpin-\(UUID().uuidString)"
133 let defaults = UserDefaults(suiteName: defaultsName)!
134 defer { defaults.removePersistentDomain(forName: defaultsName) }
135
136 let alice = HomePinRecord(kind: .user, value: "~alice", title: "~alice", subtitle: "User", ownerUsername: "~alice", service: nil)
137 let bob = HomePinRecord(kind: .user, value: "~bob", title: "~bob", subtitle: "User", ownerUsername: "~bob", service: nil)
138 HomePinStore.togglePin(alice, for: "~me", defaults: defaults)
139 HomePinStore.togglePin(bob, for: "~me", defaults: defaults)
140
141 HomePinStore.removePin(id: alice.id, for: "~me", defaults: defaults)
142
143 let remaining = HomePinStore.loadPins(for: "~me", defaults: defaults)
144 #expect(remaining.map(\.id) == [bob.id])
145 }
113146}