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 {
25 case trackers 25 case trackers
26 case systemStatus 26 case systemStatus
27 case lookup 27 case lookup
28 case search(query: String) 28 case search(query: String, type: LookupType?)
29 case projectDashboard(id: String, title: String?) 29 case projectDashboard(id: String, title: String?)
30 30
31 init?(url: URL) { 31 init?(url: URL) {
@@ -103,7 +103,8 @@ enum HutchRoute: Equatable, Sendable {
103 103
104 case "lookup": 104 case "lookup":
105 if let query = queryValue("q"), !query.isEmpty { 105 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)
107 } else { 108 } else {
108 self = .lookup 109 self = .lookup
109 } 110 }
@@ -148,8 +149,12 @@ enum HutchRoute: Equatable, Sendable {
148 return Self.makeURL(host: "status") 149 return Self.makeURL(host: "status")
149 case .lookup: 150 case .lookup:
150 return Self.makeURL(host: "lookup") 151 return Self.makeURL(host: "lookup")
151 case .search(let query): 152 case .search(let query, let type):
152 return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)]) 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)
153 case .projectDashboard(let id, let title): 158 case .projectDashboard(let id, let title):
154 return Self.makeURL( 159 return Self.makeURL(
155 host: "projects", 160 host: "projects",
@@ -205,8 +210,8 @@ enum DeepLink: Equatable {
205 case systemStatus 210 case systemStatus
206 /// hutch://lookup 211 /// hutch://lookup
207 case lookup 212 case lookup
208 /// hutch://lookup?q=<query> 213 /// hutch://lookup?q=<query>&type=<type>
209 case search(query: String) 214 case search(query: String, type: LookupType?)
210 /// hutch://builds?filter=failed 215 /// hutch://builds?filter=failed
211 case failedBuilds 216 case failedBuilds
212 /// hutch://projects/<rid> 217 /// hutch://projects/<rid>
@@ -253,8 +258,8 @@ enum DeepLink: Equatable {
253 self = .systemStatus 258 self = .systemStatus
254 case .lookup: 259 case .lookup:
255 self = .lookup 260 self = .lookup
256 case .search(let query): 261 case .search(let query, let type):
257 self = .search(query: query) 262 self = .search(query: query, type: type)
258 case .projectDashboard(let id, let title): 263 case .projectDashboard(let id, let title):
259 self = .projectDashboard(id: id, title: title) 264 self = .projectDashboard(id: id, title: title)
260 } 265 }
Hutch/App/HutchIntents.swift +104 −43
@@ -132,11 +132,14 @@ struct SearchHutchIntent: AppIntent {
132 @Parameter(title: "Query") 132 @Parameter(title: "Query")
133 var query: String 133 var query: String
134 134
135 @Parameter(title: "Search Type", default: .user)
136 var searchType: LookupType
137
135 var route: HutchRoute { 138 var route: HutchRoute {
136 let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) 139 let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
137 // Routes to Lookup for now; repoint at a global content search when Hutch 140 // Routes to Lookup, pre-selecting the search type, until Hutch gains a
138 // gains one — tracked in ROADMAP.md § "App Intent gaps". 141 // global content search.
139 return normalized.isEmpty ? .lookup : .search(query: normalized) 142 return normalized.isEmpty ? .lookup : .search(query: normalized, type: searchType)
140 } 143 }
141 144
142 @MainActor 145 @MainActor
@@ -147,7 +150,7 @@ struct SearchHutchIntent: AppIntent {
147} 150}
148 151
149// An OpenSavedSearchIntent belongs here once Hutch has global saved-search 152// An OpenSavedSearchIntent belongs here once Hutch has global saved-search
150// persistence — tracked in ROADMAP.md § "App Intent gaps". 153// persistence.
151 154
152// MARK: - App Entities 155// MARK: - App Entities
153 156
@@ -201,7 +204,7 @@ struct ProjectEntityQuery: EntityQuery {
201 } 204 }
202} 205}
203 206
204private enum HutchIntentEntityStore { 207enum HutchIntentEntityStore {
205 static func pinnedResources() -> [PinnedResourceEntity] { 208 static func pinnedResources() -> [PinnedResourceEntity] {
206 pins().compactMap { makePinnedResource(from: $0) } 209 pins().compactMap { makePinnedResource(from: $0) }
207 } 210 }
@@ -213,22 +216,27 @@ private enum HutchIntentEntityStore {
213 } 216 }
214 } 217 }
215 218
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? {
217 guard let userKey = ContributionWidgetContextStore.loadActor(), 221 guard let userKey = ContributionWidgetContextStore.loadActor(),
218 !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 222 !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
219 else { 223 else {
220 return [] 224 return nil
221 } 225 }
222 226 return userKey
223 return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults)
224 } 227 }
225 228
226 private static var activeAccountDefaults: UserDefaults { 229 static var accountDefaults: UserDefaults {
227 let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" 230 let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
228 guard !activeID.isEmpty else { return .standard } 231 guard !activeID.isEmpty else { return .standard }
229 return AccountDefaultsStore.userDefaults(for: activeID) 232 return AccountDefaultsStore.userDefaults(for: activeID)
230 } 233 }
231 234
235 private static func pins() -> [HomePinRecord] {
236 guard let userKey = currentUserKey() else { return [] }
237 return HomePinStore.loadPins(for: userKey, defaults: accountDefaults)
238 }
239
232 private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? { 240 private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? {
233 guard let route = route(for: pin) else { return nil } 241 guard let route = route(for: pin) else { return nil }
234 return PinnedResourceEntity( 242 return PinnedResourceEntity(
@@ -274,20 +282,22 @@ struct CheckSystemStatusIntent: AppIntent {
274 static var description = IntentDescription("Returns the current SourceHut system status.") 282 static var description = IntentDescription("Returns the current SourceHut system status.")
275 283
276 @MainActor 284 @MainActor
277 func perform() async throws -> some IntentResult & ReturnsValue<String> { 285 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
278 guard let snapshot = SystemStatusWidgetSnapshotStore.load() else { 286 let message: String
279 return .result(value: "System status is unavailable. Open Hutch to refresh.") 287 if let snapshot = SystemStatusWidgetSnapshotStore.load() {
280 } 288 if snapshot.hasDisruption {
281 289 let disrupted = snapshot.services
282 if snapshot.hasDisruption { 290 .filter { $0.requiresAttention }
283 let disrupted = snapshot.services 291 .map { "\($0.name): \($0.status)" }
284 .filter { $0.requiresAttention } 292 .joined(separator: ", ")
285 .map { "\($0.name): \($0.status)" } 293 message = "SourceHut disruption detected: \(disrupted)"
286 .joined(separator: ", ") 294 } else {
287 return .result(value: "SourceHut disruption detected: \(disrupted)") 295 message = "All SourceHut services operational."
296 }
297 } else {
298 message = "System status is unavailable. Open Hutch to refresh."
288 } 299 }
289 300 return .result(value: message, dialog: IntentDialog(stringLiteral: message))
290 return .result(value: "All SourceHut services operational.")
291 } 301 }
292} 302}
293 303
@@ -296,34 +306,85 @@ struct CheckBuildsIntent: AppIntent {
296 static var description = IntentDescription("Returns a summary of your recent build status.") 306 static var description = IntentDescription("Returns a summary of your recent build status.")
297 307
298 @MainActor 308 @MainActor
299 func perform() async throws -> some IntentResult & ReturnsValue<String> { 309 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
300 guard let snapshot = NeedsAttentionSnapshotStore.load() else { 310 let message: String
301 return .result(value: "Build status unavailable. Open Hutch to refresh.") 311 if let snapshot = NeedsAttentionSnapshotStore.load() {
302 } 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 }
303 321
304 var parts: [String] = [] 322 if let unread = snapshot.unreadInboxThreads, unread > 0 {
323 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
324 }
305 325
306 if let failed = snapshot.failedBuilds { 326 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
307 if failed > 0 { 327 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
308 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
309 } else {
310 parts.append("No failed builds")
311 } 328 }
312 }
313 329
314 if let unread = snapshot.unreadInboxThreads, unread > 0 { 330 message = parts.isEmpty ? "No recent data. Open Hutch to refresh." : parts.joined(separator: ". ") + "."
315 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") 331 } else {
332 message = "Build status unavailable. Open Hutch to refresh."
316 } 333 }
334 return .result(value: message, dialog: IntentDialog(stringLiteral: message))
335 }
336}
317 337
318 if let assigned = snapshot.assignedOpenTickets, assigned > 0 { 338// MARK: - Search Type
319 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
320 }
321 339
322 if parts.isEmpty { 340extension LookupType: @retroactive AppEnum {
323 return .result(value: "No recent data. Open Hutch to refresh.") 341 public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation {
324 } 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.")
325 373
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).")
327 } 388 }
328} 389}
329 390
Hutch/App/RootView.swift +6 −6
@@ -247,12 +247,12 @@ struct RootView: View {
247 appState.pendingBuildListFilter = .failed 247 appState.pendingBuildListFilter = .failed
248 appState.selectedTab = .builds 248 appState.selectedTab = .builds
249 249
250 case .search(let query): 250 case .search(let query, let type):
251 morePath = NavigationPath() 251 morePath = NavigationPath()
252 appState.selectedTab = .more 252 appState.selectedTab = .more
253 Task { 253 Task {
254 await settleNavigationTransition() 254 await settleNavigationTransition()
255 morePath.append(MoreRoute.lookup(query: query)) 255 morePath.append(MoreRoute.lookup(query: query, type: type))
256 } 256 }
257 257
258 case .lookup: 258 case .lookup:
@@ -260,7 +260,7 @@ struct RootView: View {
260 appState.selectedTab = .more 260 appState.selectedTab = .more
261 Task { 261 Task {
262 await settleNavigationTransition() 262 await settleNavigationTransition()
263 morePath.append(MoreRoute.lookup(query: nil)) 263 morePath.append(MoreRoute.lookup(query: nil, type: nil))
264 } 264 }
265 265
266 case .buildsTab: 266 case .buildsTab:
@@ -431,7 +431,7 @@ enum MoreDestination: Hashable {
431} 431}
432 432
433enum MoreRoute: Hashable { 433enum MoreRoute: Hashable {
434 case lookup(query: String?) 434 case lookup(query: String?, type: LookupType?)
435 case projects 435 case projects
436 case lists 436 case lists
437 case pastes 437 case pastes
@@ -454,8 +454,8 @@ private struct MoreNavigationRoot: View {
454 MoreView() 454 MoreView()
455 .navigationDestination(for: MoreRoute.self) { route in 455 .navigationDestination(for: MoreRoute.self) { route in
456 switch route { 456 switch route {
457 case .lookup(let query): 457 case .lookup(let query, let type):
458 LookupView(initialQuery: query ?? "") 458 LookupView(initialQuery: query ?? "", initialType: type)
459 case .projects: 459 case .projects:
460 ProjectsListView() 460 ProjectsListView()
461 case .lists: 461 case .lists:
Hutch/Views/Home/HomePinStore.swift +15
@@ -144,6 +144,21 @@ enum HomePinStore {
144 saveAll(pinsByUser, defaults: defaults) 144 saveAll(pinsByUser, defaults: defaults)
145 } 145 }
146 146
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
147 static func pinnedProjectIDs( 162 static func pinnedProjectIDs(
148 for userKey: String, 163 for userKey: String,
149 defaults: UserDefaults = .standard 164 defaults: UserDefaults = .standard
Hutch/Views/Lookup/LookupView.swift +12 −5
@@ -96,13 +96,17 @@ final class LookupViewModel {
96 client: SRHTClient, 96 client: SRHTClient,
97 appState: AppState, 97 appState: AppState,
98 defaults: UserDefaults = .standard, 98 defaults: UserDefaults = .standard,
99 initialQuery: String = "" 99 initialQuery: String = "",
100 initialType: LookupType? = nil
100 ) { 101 ) {
101 self.client = client 102 self.client = client
102 self.appState = appState 103 self.appState = appState
103 self.defaults = defaults 104 self.defaults = defaults
104 self.history = LookupHistoryStore.load(defaults: defaults) 105 self.history = LookupHistoryStore.load(defaults: defaults)
105 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines) 106 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
107 if let initialType {
108 self.selectedType = initialType
109 }
106 } 110 }
107 111
108 func lookup() async { 112 func lookup() async {
@@ -336,9 +340,11 @@ struct LookupView: View {
336 @Environment(AppState.self) private var appState 340 @Environment(AppState.self) private var appState
337 @State private var viewModel: LookupViewModel? 341 @State private var viewModel: LookupViewModel?
338 private let initialQuery: String 342 private let initialQuery: String
343 private let initialType: LookupType?
339 344
340 init(initialQuery: String = "") { 345 init(initialQuery: String = "", initialType: LookupType? = nil) {
341 self.initialQuery = initialQuery 346 self.initialQuery = initialQuery
347 self.initialType = initialType
342 } 348 }
343 349
344 var body: some View { 350 var body: some View {
@@ -356,7 +362,8 @@ struct LookupView: View {
356 client: appState.client, 362 client: appState.client,
357 appState: appState, 363 appState: appState,
358 defaults: appState.accountDefaults, 364 defaults: appState.accountDefaults,
359 initialQuery: initialQuery 365 initialQuery: initialQuery,
366 initialType: initialType
360 ) 367 )
361 } 368 }
362 } 369 }
@@ -443,8 +450,8 @@ struct LookupView: View {
443 } 450 }
444 .navigationDestination(for: MoreRoute.self) { route in 451 .navigationDestination(for: MoreRoute.self) { route in
445 switch route { 452 switch route {
446 case .lookup(let query): 453 case .lookup(let query, let type):
447 LookupView(initialQuery: query ?? "") 454 LookupView(initialQuery: query ?? "", initialType: type)
448 case .projects: 455 case .projects:
449 ProjectsListView() 456 ProjectsListView()
450 case .lists: 457 case .lists:
Hutch/Views/More/MoreView.swift +1 −1
@@ -13,7 +13,7 @@ struct MoreView: View {
13 var body: some View { 13 var body: some View {
14 List { 14 List {
15 Section("Search") { 15 Section("Search") {
16 NavigationLink(value: MoreRoute.lookup(query: nil)) { 16 NavigationLink(value: MoreRoute.lookup(query: nil, type: nil)) {
17 Label("Look Up", systemImage: "magnifyingglass") 17 Label("Look Up", systemImage: "magnifyingglass")
18 } 18 }
19 .themedRow() 19 .themedRow()
HutchTests/DeepLinkTests.swift +2 −1
@@ -92,7 +92,8 @@ struct DeepLinkTests {
92 func parsesRouteBackedNavigationLinks() { 92 func parsesRouteBackedNavigationLinks() {
93 #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned)) 93 #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned))
94 #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds) 94 #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))
96 #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch")) 97 #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch"))
97 } 98 }
98 99
HutchTests/HutchIntentsTests.swift +33
@@ -110,4 +110,37 @@ struct HutchIntentsTests {
110 ActiveAccountContextStore.save("account-a", defaults: defaults) 110 ActiveAccountContextStore.save("account-a", defaults: defaults)
111 #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3) 111 #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
112 } 112 }
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 }
113} 146}