diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index a868338..f368c37 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -62,6 +62,8 @@ protocol GatewayAPI: Sendable { func projects() async throws -> ProjectsResponse /// A project's newest 50 threads, settled ones included. Subagents are left out. func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse + /// Thread titles and messages on every online host matching `query`, which needs two characters. + func search(_ query: String) async throws -> SearchResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func registerPush(_ registration: PushRegistration) async throws func status() async throws -> StatusResponse @@ -137,6 +139,10 @@ struct GatewayClient: GatewayAPI { try await call("GET", ["v1", "projects", hostId, projectId, "threads"]) } + func search(_ query: String) async throws -> SearchResponse { + try await call("GET", ["v1", "search"], query: [URLQueryItem(name: "q", value: query)]) + } + func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { try await call("POST", ["v1", "threads"], body: request) } diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index bd582de..8e2763e 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -157,6 +157,8 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable { /// What a working thread is doing right now. Nil when nothing is in progress, and from older /// gateways. var activity: Activity? = nil + /// What the latest run changed, in total. Nil when it changed no files, and from older gateways. + var changes: ChangeTotals? = nil var id: String { "\(hostId)/\(threadId)" } var route: Route { .thread(hostId: hostId, threadId: threadId) } @@ -189,6 +191,62 @@ struct Activity: Codable, Hashable, Sendable { var startedAt: Date? } +/// What a thread's latest run changed, in total, for the line on a Recent card. +struct ChangeTotals: Codable, Hashable, Sendable { + var files: Int + var additions: Int + var deletions: Int + + /// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files". + var parts: (additions: String, deletions: String, files: String) { + ("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files") + } +} + +/// A file the latest run changed. `name` is the last part of `path`. +struct ChangedFile: Codable, Hashable, Sendable, Identifiable { + var path: String + var name: String + var additions: Int + var deletions: Int + + var id: String { path } +} + +/// A thread search hit: the thread, and the text around the match when it was inside a message. +struct SearchHit: Codable, Hashable, Sendable, Identifiable { + var thread: ThreadRow + var projectTitle: String + var hostLabel: String + /// Nil when only the title matched. + var snippet: String? + + var id: String { thread.id } +} + +/// `GET /v1/search?q=`: hits from every online host, newest match first. +struct SearchResponse: Codable, Hashable, Sendable { + var hits: [SearchHit] + /// Labels of hosts that were offline or failed, so their threads were not searched. + var unsearched: [String] + + /// The line under the results naming the hosts left out, such as "Laptop is offline, so its + /// threads were not searched." `offline` holds the labels of hosts the watch knows are offline; + /// any other host failed or was too slow, which the gateway does not tell apart. + static func unsearchedNote(_ labels: [String], offline: Set) -> String? { + guard !labels.isEmpty else { return nil } + let names: String = switch labels.count { + case 1: labels[0] + case 2: "\(labels[0]) and \(labels[1])" + default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1] + } + guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." } + return labels.count == 1 + ? "\(names) is offline, so its threads were not searched." + : "\(names) are offline, so their threads were not searched." + } +} + /// A thread in a list: a project's threads, or a thread's subagents. struct ThreadRow: Codable, Hashable, Sendable, Identifiable { var hostId: String @@ -277,13 +335,26 @@ struct SettleRecentResponse: Codable, Hashable, Sendable { var failed: Int } -/// `GET /v1/summary`: the counts behind the complication. +/// `GET /v1/summary`: what the complication and the Smart Stack widget show. struct Summary: Codable, Hashable, Sendable { struct Next: Codable, Hashable, Sendable { var hostId: String var threadId: String var title: String var hostLabel: String + /// The command it wants to run or its first question, trimmed. Nil until the gateway has read + /// it, and from older gateways. + var request: String? = nil + + var route: Route { .thread(hostId: hostId, threadId: threadId) } + } + + /// The working thread updated most recently, with what it is doing. + struct Current: Codable, Hashable, Sendable { + var hostId: String + var threadId: String + var title: String + var activity: Activity? var route: Route { .thread(hostId: hostId, threadId: threadId) } } @@ -292,18 +363,24 @@ struct Summary: Codable, Hashable, Sendable { var working: Int /// The thread that has waited longest for you. var next: Next? + /// Nil when nothing is working, and from older gateways. + var current: Current? = nil var hostsOffline: Int } extension Summary { - /// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its - /// first item is the one that has waited longest. + /// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its + /// first item is the one that has waited longest, and working newest first. init(_ feed: Feed) { let next = feed.needsYou.first + let current = feed.working.first self.init( needsYou: feed.needsYou.count, working: feed.working.count, - next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) }, + next: next.map { + Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel, request: $0.request?.summary) + }, + current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) }, hostsOffline: feed.hosts.filter { $0.status != .online }.count) } } @@ -336,14 +413,16 @@ struct ThreadDetail: Codable, Hashable, Sendable { var earlierCursor: String? = nil /// Subagents and delegated threads this thread started, newest first. var subagents: [ThreadRow] = [] + /// The files the latest run changed, most lines changed first. + var changedFiles: [ChangedFile] = [] } extension ThreadDetail { private enum CodingKeys: String, CodingKey { - case item, messages, canStop, askFirst, earlierCursor, subagents + case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles } - /// `subagents` is newer than the rest, so a gateway that leaves it out lists none. + /// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( @@ -352,7 +431,8 @@ extension ThreadDetail { canStop: try container.decode(Bool.self, forKey: .canStop), askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst), earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor), - subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? []) + subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [], + changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? []) } } @@ -605,7 +685,7 @@ struct SendMessageRequest: Codable, Sendable { var text: String } -enum RespondRequest: Encodable, Sendable { +enum RespondRequest: Encodable, Equatable, Sendable { /// One question's answer: an option value or typed text, or option values for a multi-select question. enum Answer: Encodable, Hashable, Sendable { case text(String) @@ -657,6 +737,31 @@ struct ErrorResponse: Codable, Sendable { var error: String } +/// `question` on an `input` push that asks one single-choice question: its options, so the +/// notification can offer each as a button. Answer with `[id: value]` on the respond route. +struct PushQuestion: Codable, Hashable, Sendable { + var id: String + /// At most 4. + var options: [PendingRequest.Question.Choice] + /// Adds a spoken or typed answer. + var allowCustomAnswer: Bool + + /// The question in a push's `userInfo`, or nil when the push has none or it does not decode. + init?(userInfo: [AnyHashable: Any]) { + guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object), + let question = try? JSON.decoder().decode(Self.self, from: data) + else { return nil } + self = question + } + + init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) { + self.id = id + self.options = options + self.allowCustomAnswer = allowCustomAnswer + } +} + /// Notification categories the watch registers actions for. enum PushCategory: String, CaseIterable, Sendable { case approval, finished, failed, input, host, test diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift index afb5c50..33fce0b 100644 --- a/watch/WristCode/API/Route.swift +++ b/watch/WristCode/API/Route.swift @@ -15,12 +15,14 @@ enum Route: Hashable, Sendable { case allThreads case hostProjects(hostId: String) case projectThreads(hostId: String, projectId: String) + /// Threads on every host matching a search from All threads. + case search(query: String) static let scheme = "wristcode" /// `wristcode://thread//`, `wristcode://new`, `wristcode://recent`, `wristcode://status`, /// `wristcode://host/`, `wristcode://notifications`, `wristcode://threads`, - /// `wristcode://projects/` or `wristcode://project//`. + /// `wristcode://projects/`, `wristcode://project//` or `wristcode://search?q=`. var url: URL { var components = URLComponents() components.scheme = Self.scheme @@ -50,6 +52,9 @@ enum Route: Hashable, Sendable { case let .projectThreads(hostId, projectId): components.host = "project" components.percentEncodedPath = path(hostId, projectId) + case let .search(query): + components.host = "search" + components.queryItems = [URLQueryItem(name: "q", value: query)] } return components.url! } @@ -79,6 +84,9 @@ enum Route: Hashable, Sendable { self = .hostProjects(hostId: parts[0]) case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }: self = .projectThreads(hostId: parts[0], projectId: parts[1]) + case "search": + guard let query = components.queryItems?.first(where: { $0.name == "q" })?.value, !query.isEmpty else { return nil } + self = .search(query: query) default: return nil } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 757440f..0769607 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -2,7 +2,8 @@ // SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes: // answering an approval moves the thread to working, a reply restarts a finished thread, settling // hides a thread from the feed, Ask before acting and the notification switches stick, and so on. -// Subagent threads open like any other but stay out of the feed, as the gateway's do. +// Subagent threads open like any other but stay out of the feed, as the gateway's do. Search matches +// titles and messages on the online hosts. import Foundation @@ -13,6 +14,8 @@ actor DemoGateway: GatewayAPI { private var messages: [String: [Message]] /// Pages before each thread's messages, newest page first. private let earlierPages: [String: [[Message]]] + /// Files each thread's latest run changed, by thread id. + private let changedFiles: [String: [ChangedFile]] /// Subagent ids by parent id. Subagents are in `items` but not the feed. private let children: [String: [String]] private let subagentIds: Set @@ -30,6 +33,7 @@ actor DemoGateway: GatewayAPI { items = fixtures.items messages = fixtures.messages earlierPages = fixtures.history + changedFiles = fixtures.changedFiles children = fixtures.children subagentIds = Set(fixtures.children.values.joined()) settled = fixtures.settled @@ -52,7 +56,8 @@ actor DemoGateway: GatewayAPI { item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking, askFirst: askingFirst.contains(item.id), earlierCursor: earlierPages[item.id] == nil ? nil : "p0", - subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) }) + subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) }, + changedFiles: changedFiles[item.id] ?? []) } /// Cursors are `p`, the n-th page back from the thread's messages. @@ -149,6 +154,31 @@ actor DemoGateway: GatewayAPI { return ThreadListResponse(items: rows) } + /// Titles first, then messages, on online hosts only, newest first. A message match carries the + /// words around it. + func search(_ query: String) async throws -> SearchResponse { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.count >= 2 else { throw GatewayError.http(status: 400, message: "Search for at least two letters.") } + let online = Set(hosts.filter { $0.status == .online }.map(\.id)) + let hits: [(SearchHit, Date)] = items.compactMap { item in + guard online.contains(item.hostId) else { return nil } + let hit = { (snippet: String?) in + SearchHit(thread: self.row(item), projectTitle: item.projectTitle, hostLabel: item.hostLabel, snippet: snippet) + } + if item.title.localizedCaseInsensitiveContains(query) { return (hit(nil), item.updatedAt) } + guard let message = messages[item.id]?.last(where: { $0.text.localizedCaseInsensitiveContains(query) }), + let range = message.text.range(of: query, options: .caseInsensitive) + else { return nil } + let start = message.text.index(range.lowerBound, offsetBy: -30, limitedBy: message.text.startIndex) ?? message.text.startIndex + let end = message.text.index(range.upperBound, offsetBy: 50, limitedBy: message.text.endIndex) ?? message.text.endIndex + let snippet = (start > message.text.startIndex ? "…" : "") + message.text[start ..< end] + (end < message.text.endIndex ? "…" : "") + return (hit(snippet), message.createdAt) + } + return SearchResponse( + hits: hits.sorted { $0.1 > $1.1 }.map(\.0), + unsearched: hosts.filter { !online.contains($0.id) }.map(\.label)) + } + func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { guard let host = projectHosts.first(where: { $0.id == request.hostId }), let project = host.projects.first(where: { $0.id == request.projectId }) @@ -229,6 +259,8 @@ struct DemoFixtures { let items: [FeedItem] let messages: [String: [Message]] let history: [String: [[Message]]] + /// Files each thread's latest run changed, by thread id. Each thread's `changes` totals them. + let changedFiles: [String: [ChangedFile]] /// Subagent ids by parent id. let children: [String: [String]] /// Threads settled from the start, which only a project's thread list shows. @@ -388,8 +420,22 @@ struct DemoFixtures { } settled = Set(settledThreads.map(\.id)) - items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] - + olderRecent + settledThreads + [path, image, healthcheck] + func file(_ path: String, _ additions: Int, _ deletions: Int) -> ChangedFile { + ChangedFile(path: path, name: String(path.split(separator: "/").last ?? ""), additions: additions, deletions: deletions) + } + let changed = [ + nextjs.id: [file("package.json", 31, 0), file("next.config.ts", 9, 6), file("app/layout.tsx", 2, 2)], + cdn.id: [file("src/headers.rs", 14, 6)], + ] + changedFiles = changed + items = ([approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] + + olderRecent + settledThreads + [path, image, healthcheck]).map { item in + guard let files = changed[item.id] else { return item } + var item = item + item.changes = ChangeTotals( + files: files.count, additions: files.map(\.additions).reduce(0, +), deletions: files.map(\.deletions).reduce(0, +)) + return item + } children = [homelab.id: [path.id], containerfile.id: [image.id, healthcheck.id]] func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message { diff --git a/watch/WristCode/Notifications/NotificationHandler.swift b/watch/WristCode/Notifications/NotificationHandler.swift index dfdc5cf..dbcf25c 100644 --- a/watch/WristCode/Notifications/NotificationHandler.swift +++ b/watch/WristCode/Notifications/NotificationHandler.swift @@ -1,12 +1,22 @@ -// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny -// and Reply run in the background against the gateway; tapping a notification opens its thread. +// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny, +// Reply and a question's answers run in the background against the gateway; tapping a notification +// opens its thread. +// +// An `input` push that carries `question` gets one button per option, plus "Say an answer" when the +// question takes its own answer. watchOS only lets a notification's actions change per push from +// its hosting controller, so `InputNotificationController` sets them when the push arrives. Any other +// `input` push keeps the category's Answer and Open, which open the thread's question screen. import Foundation +import SwiftUI import UserNotifications +import WatchKit /// What a notification response asks for, parsed out of the payload. enum NotificationCommand: Equatable, Sendable { case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision) + /// One answer to a push's single question: an option's value, or what was said or typed. + case answer(hostId: String, threadId: String, requestId: String, questionId: String, value: String) case reply(hostId: String, threadId: String, text: String) case open(Route?) case none @@ -16,9 +26,16 @@ enum NotificationCommand: Equatable, Sendable { static let deny = "deny" static let reply = "reply" static let open = "open" + /// Opens the thread, on a question the notification cannot answer itself. + static let answer = "answer" + /// A spoken or typed answer to a push's question. + static let say = "say" + /// `option.` picks the push question's n-th option. + static let optionPrefix = "option." } - /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`. + /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals and questions, + /// `requestId`, and for a lone single-choice question, `question`. init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) { // The gateway sends `threadId: ""` on host notifications, so empty means absent. func value(_ key: String) -> String? { @@ -29,6 +46,14 @@ enum NotificationCommand: Equatable, Sendable { let requestId = value("requestId") let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil } + /// The question's answer, or the thread when the answer or the keys it needs are missing. + func answer(_ value: String?) -> Self { + let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let hostId, let threadId, let requestId, let question = PushQuestion(userInfo: userInfo), !value.isEmpty + else { return .open(thread) } + return .answer(hostId: hostId, threadId: threadId, requestId: requestId, questionId: question.id, value: value) + } + switch actionIdentifier { case Action.allow, Action.deny: guard let hostId, let threadId, let requestId else { self = .open(thread); return } @@ -39,13 +64,31 @@ enum NotificationCommand: Equatable, Sendable { let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return } self = .reply(hostId: hostId, threadId: threadId, text: text) + case Action.say: + self = answer(text) + case let id where id.hasPrefix(Action.optionPrefix): + let index = Int(id.dropFirst(Action.optionPrefix.count)) + let options = PushQuestion(userInfo: userInfo)?.options ?? [] + self = answer(index.flatMap { options.indices.contains($0) ? options[$0].value : nil }) case UNNotificationDismissActionIdentifier: self = .none default: - // The Open action, or a tap on the notification itself. + // Open, Answer, or a tap on the notification itself. self = .open(thread) } } + + /// The respond route's thread and body, for Allow, Deny and a question's answer. + var response: (hostId: String, threadId: String, request: RespondRequest)? { + switch self { + case let .respond(hostId, threadId, requestId, decision): + (hostId, threadId, .decision(requestId: requestId, decision: decision)) + case let .answer(hostId, threadId, requestId, questionId, value): + (hostId, threadId, .answers(requestId: requestId, answers: [questionId: .text(value)])) + default: + nil + } + } } @MainActor @@ -66,13 +109,15 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { let reply = UNTextInputNotificationAction( identifier: Action.reply, title: "Reply", options: [], textInputButtonTitle: "Send", textInputPlaceholder: "Reply") + let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]) func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory { UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: []) } return [ category(.approval, [allow, deny]), - category(.input, [open]), + // A question push replaces these with its options; see `inputActions`. + category(.input, [answer, open]), category(.finished, [reply, open]), category(.failed, [open]), category(.host, []), @@ -80,6 +125,24 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { ] } + /// An `input` push's actions: its question's options, "Say an answer" when the question takes + /// one, and Open. Without a question, Answer and Open, as the category registers them. + static func inputActions(_ question: PushQuestion?) -> [UNNotificationAction] { + typealias Action = NotificationCommand.Action + let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground]) + guard let question else { + return [UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]), open] + } + let options = question.options.enumerated().map { index, option in + UNNotificationAction(identifier: Action.optionPrefix + String(index), title: option.label, options: []) + } + let say = UNTextInputNotificationAction( + identifier: Action.say, title: "Say an answer", options: [], + icon: UNNotificationActionIcon(systemImageName: "mic.fill"), + textInputButtonTitle: "Send", textInputPlaceholder: "Answer") + return options + (question.allowCustomAnswer ? [say] : []) + [open] + } + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { @@ -107,12 +170,10 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { model.reloadComplication() await model.refreshFeed() return - case let .respond(hostId, threadId, requestId, decision): - guard model.mode != .unpaired else { return } + case .respond, .answer: + guard model.mode != .unpaired, let response = command.response else { return } await attempt("Couldn't answer") { - try await model.api.respond( - hostId: hostId, threadId: threadId, - request: .decision(requestId: requestId, decision: decision)) + try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request) } case let .reply(hostId, threadId, text): guard model.mode != .unpaired else { return } @@ -141,3 +202,39 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { } } } + +/// The long look for `input` pushes. It shows the push's title and question, and sets the push's +/// own actions, which is the one place watchOS takes per-push action titles. +final class InputNotificationController: WKUserNotificationHostingController { + private var threadTitle = "" + private var questionText = "" + + override var body: InputNotificationView { + InputNotificationView(title: threadTitle, question: questionText) + } + + override func didReceive(_ notification: UNNotification) { + let content = notification.request.content + threadTitle = content.title + questionText = content.body + notificationActions = NotificationHandler.inputActions(PushQuestion(userInfo: content.userInfo)) + } +} + +/// The thread's title and its question, above the actions the system draws. +struct InputNotificationView: View { + var title: String + var question: String + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + Text(question) + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .fontDesign(.rounded) + } +} diff --git a/watch/WristCode/Shared/SharedState.swift b/watch/WristCode/Shared/SharedState.swift index 8cd8c82..c4a89a8 100644 --- a/watch/WristCode/Shared/SharedState.swift +++ b/watch/WristCode/Shared/SharedState.swift @@ -1,6 +1,7 @@ // What the app tells the complication besides the credentials: whether it is in demo mode, and the // last summary it saw, which the complication shows when it cannot reach the gateway. Both live in -// the shared Keychain group next to the device token, the one store the two processes share. +// the shared Keychain group next to the device token, the one store the two processes share. `api` +// is the gateway the complication and its Allow and Deny buttons call. import Foundation @@ -14,6 +15,12 @@ enum SharedState { set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) } } + /// The demo gateway in demo mode, the paired gateway otherwise, and nil when not paired. + static var api: (any GatewayAPI)? { + if demo { return DemoGateway.shared } + return DeviceStore.load().map { GatewayClient(baseURL: $0.gatewayURL, token: $0.token) } + } + /// The last summary the app or the complication saw, or nil after signing out. static var lastSummary: Summary? { get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } } diff --git a/watch/WristCode/Views/BrowseView.swift b/watch/WristCode/Views/BrowseView.swift index 011aedf..584fbf1 100644 --- a/watch/WristCode/Views/BrowseView.swift +++ b/watch/WristCode/Views/BrowseView.swift @@ -1,11 +1,12 @@ -// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title, -// then a project's threads with settled ones included. Hosts and projects come from -// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads -// when it appears; nothing polls. A thread opens the usual thread screen. +// All threads, opened from the last row of the feed: a search field over the hosts, then a host's +// projects sorted by title, then a project's threads with settled ones included. Hosts and projects +// come from `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`, and +// search results from `GET /v1/search`. Each screen loads when it appears; nothing polls. A thread or +// a search hit opens the usual thread screen. import SwiftUI -/// Every host, offline ones dimmed. Each opens its projects. +/// A search field, then every host, offline ones dimmed. Each host opens its projects. struct HostsView: View { @Environment(AppModel.self) private var model /// Snapshots render the rows without their List, which ImageRenderer leaves blank. @@ -13,6 +14,9 @@ struct HostsView: View { var body: some View { BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) { + SearchField() + .feedRow() + } rows: { if let hosts = model.projects?.hosts { ForEach(hosts) { host in BrowseRow( @@ -105,6 +109,144 @@ struct ProjectThreadsView: View { } } +/// Threads on every host matching `query`: each hit's host and project, its title, and the words +/// around the match when it was inside a message. Says which hosts were left out. +struct SearchView: View { + @Environment(AppModel.self) private var model + let query: String + @State private var response: SearchResponse? + @State private var error: String? + private let scrolls: Bool + + /// `response` starts the screen loaded, for snapshots. + init(query: String, response: SearchResponse? = nil, scrolls: Bool = true) { + self.query = query + self.scrolls = scrolls + _response = State(initialValue: response) + } + + var body: some View { + BrowseList(scrolls: scrolls, error: error, loaded: response != nil, reload: load) { + if let response { + ForEach(response.hits) { hit in + SearchHitCard(hit: hit, query: query) + .feedRow() + } + if response.hits.isEmpty { + Text("No threads match.") + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.top, 16) + .feedRow() + } + if let note = SearchResponse.unsearchedNote(response.unsearched, offline: offlineHosts) { + Text(note) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .feedRow() + } + } + } + .navigationTitle("\u{201C}\(query)\u{201D}") + } + + /// Labels of hosts the feed or All threads last saw offline. + private var offlineHosts: Set { + let feed = model.feed?.hosts.filter { $0.status != .online }.map(\.label) ?? [] + let browsed = model.projects?.hosts.filter { $0.status != .online }.map(\.label) ?? [] + return Set(feed + browsed) + } + + private func load() async { + do { + response = try await model.api.search(query) + error = nil + } catch { + self.error = model.message(for: error) + } + } +} + +/// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS +/// text field does, and submitting opens the results. +private struct SearchField: View { + @Environment(AppModel.self) private var model + + var body: some View { + TextFieldLink(prompt: Text("Search threads")) { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.secondaryText) + Text("Search") + .foregroundStyle(Theme.secondaryText) + Spacer(minLength: 4) + Image(systemName: "mic.fill") + .foregroundStyle(Theme.work) + } + .font(.system(size: 14, weight: .semibold)) + .padding(.vertical, 9) + .padding(.horizontal, 12) + .background(Theme.control.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .contentShape(Rectangle()) + } onSubmit: { text in + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + model.path.append(.search(query: query)) + } + .buttonStyle(.plain) + .accessibilityLabel("Search threads") + } +} + +/// A search hit: "host · project", the title, dimmed once finished or settled, and the snippet with +/// the matched words picked out. Opens the thread. +private struct SearchHitCard: View { + @Environment(AppModel.self) private var model + var hit: SearchHit + var query: String + + var body: some View { + Button { model.path.append(hit.thread.route) } label: { + Card(edge: Theme.card) { + Text("\(hit.hostLabel) · \(hit.projectTitle)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(1) + Text(hit.thread.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(hit.thread.isActive ? Color.white : Theme.secondaryText) + .lineLimit(2) + .padding(.top, 1) + if let snippet = hit.snippet { + Text(highlighted(snippet)) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(3) + .padding(.top, 3) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + /// The snippet with each match of the query bold and white. + private func highlighted(_ snippet: String) -> AttributedString { + var text = AttributedString(snippet) + var searchFrom = text.startIndex + while let range = text[searchFrom...].range(of: query, options: .caseInsensitive) { + text[range].inlinePresentationIntent = .stronglyEmphasized + text[range].foregroundColor = .white + searchFrom = range.upperBound + } + return text + } +} + /// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread. struct ThreadLine: View { @Environment(AppModel.self) private var model @@ -135,11 +277,13 @@ struct ThreadLine: View { } /// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear. -private struct BrowseList: View { +/// `header` shows above the rows even while they load. +private struct BrowseList: View { var scrolls: Bool var error: String? var loaded: Bool var reload: () async -> Void + @ViewBuilder var header: Header @ViewBuilder var rows: Rows var body: some View { @@ -155,6 +299,7 @@ private struct BrowseList: View { } @ViewBuilder private var content: some View { + header if loaded { rows } else if error == nil { @@ -174,6 +319,12 @@ private struct BrowseList: View { } } +extension BrowseList where Header == EmptyView { + init(scrolls: Bool, error: String?, loaded: Bool, reload: @escaping () async -> Void, @ViewBuilder rows: () -> Rows) { + self.init(scrolls: scrolls, error: error, loaded: loaded, reload: reload, header: { EmptyView() }, rows: rows) + } +} + /// A plain card with a title, an optional line under it, and a chevron. private struct BrowseRow: View { @Environment(AppModel.self) private var model @@ -228,6 +379,14 @@ private struct BrowseNote: View { .fontDesign(.rounded) } +#Preview("Search") { + NavigationStack { + SearchView(query: "containerfile") + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} + #Preview("Threads") { NavigationStack { ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac") diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index 17dc078..dba9fc3 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -317,8 +317,8 @@ struct NeedsYouCard: View { } /// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the -/// step it is on, or its latest message when the gateway names no step. A recent thread that failed -/// to settle shows why. +/// step it is on, or its latest message when the gateway names no step. A recent thread shows what +/// its latest run changed, such as "+42 −8 · 3 files", and why it failed to settle. struct StatusCard: View { @Environment(AppModel.self) private var model var item: FeedItem @@ -344,6 +344,12 @@ struct StatusCard: View { .foregroundStyle(Theme.secondaryText) .lineLimit(2) .padding(.top, 3) + } else if !item.phase.isWorking, let changes = item.changes { + Text(changes: changes) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(1) + .padding(.top, 3) } if let error = model.settleErrors[item.id] { Text(error) @@ -374,7 +380,7 @@ struct ActivityLine: View { var body: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { - Image(systemName: symbol) + Image(systemName: activity.kind.symbol) .font(.system(size: 10, weight: .bold)) .foregroundStyle(Theme.work) .frame(width: 14) @@ -393,30 +399,13 @@ struct ActivityLine: View { } private var label: Text { - // Marked as code, which Text sets in monospace. A monospaced Font loses to the app's rounded design. - let mono = { (text: String) -> Text in - var code = AttributedString(text) - code.inlinePresentationIntent = .code - return Text(code).font(.system(size: 11)) - } + let mono = Text(code: activity.text).font(.system(size: 11)) return switch activity.kind { - case .command: mono(activity.text) - case .edit: Text("Editing \(mono(activity.text))") + case .command: mono + case .edit: Text("Editing \(mono)") default: Text(activity.text) } } - - private var symbol: String { - switch activity.kind { - case .command: "terminal" - case .edit: "pencil" - case .search: "magnifyingglass" - case .web: "globe" - case .subagent: "arrow.triangle.branch" - case .thinking: "ellipsis" - default: "wrench.adjustable" - } - } } #Preview("Feed") { diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index 2e1c957..6006db3 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -1,5 +1,5 @@ // Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done, -// and red means broken. +// and red means broken. Green and red also count lines added and removed. import SwiftUI @@ -12,6 +12,40 @@ enum Theme { static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2 static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e + static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a +} + +extension Text { + /// Text marked as inline code, which Text sets in monospace. A monospaced Font loses to the app's + /// rounded design. + init(code: String) { + var attributed = AttributedString(code) + attributed.inlinePresentationIntent = .code + self.init(attributed) + } + + /// "+42 −8 · 3 files" in monospace, the additions green, the deletions red and the rest in the + /// current style. Without `files`, just "+42 −8", as on a changed file's row. + init(changes: ChangeTotals, files: Bool = true) { + let parts = changes.parts + let counts = Text("\(Text(code: parts.additions).foregroundStyle(Theme.add))\(Text(code: " "))\(Text(code: parts.deletions).foregroundStyle(Theme.bad))") + self = files ? Text("\(counts)\(Text(code: " · \(parts.files)"))") : counts + } +} + +extension Activity.Kind { + /// The symbol in front of an activity line. + var symbol: String { + switch self { + case .command: "terminal" + case .edit: "pencil" + case .search: "magnifyingglass" + case .web: "globe" + case .subagent: "arrow.triangle.branch" + case .thinking: "ellipsis" + default: "wrench.adjustable" + } + } } extension Phase { diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 5eba2bb..c3b7d07 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -1,5 +1,6 @@ // One thread: the agent's latest message large, older messages one scroll up with Load earlier above -// them, the pending approval or question with its options, the thread's subagents, Reply by +// them, the pending approval or question with its options, the thread's subagents, the files its +// latest run changed with their line counts, Reply by // dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop // while a run is active. A subagent's request shows first, labeled with the subagent. Message text // renders as markdown. @@ -143,11 +144,7 @@ struct ThreadView: View { } if !detail.subagents.isEmpty { - Text("Subagents") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Theme.secondaryText) - .padding(.horizontal, 6) - .padding(.top, 4) + sectionLabel("Subagents") ForEach(detail.subagents) { row in ThreadLine(row: row) { Text(row.statusWord) @@ -156,6 +153,13 @@ struct ThreadView: View { } } + if !detail.changedFiles.isEmpty { + sectionLabel("Changed") + ForEach(detail.changedFiles) { file in + ChangedFileRow(file: file) + } + } + if item.phase == .completed && item.request == nil { QuickReplies { send($0) } .disabled(sending) @@ -196,6 +200,15 @@ struct ThreadView: View { } } + /// A small grey title over a list, such as Subagents or Changed. + private func sectionLabel(_ title: String) -> some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + .padding(.horizontal, 6) + .padding(.top, 4) + } + @ViewBuilder private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View { if request.isQuestion { @@ -517,6 +530,29 @@ private struct MessageRow: View { } } +/// A file the latest run changed: its name in monospace, and its line counts on the right. Read only. +private struct ChangedFileRow: View { + var file: ChangedFile + + var body: some View { + Card(edge: Theme.card) { + HStack(spacing: 6) { + Text(code: file.name) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text(changes: ChangeTotals(files: 1, additions: file.additions, deletions: file.deletions), files: false) + .lineLimit(1) + .layoutPriority(1) + } + .font(.system(size: 12)) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(file.name), \(file.additions) added, \(file.deletions) removed") + } +} + /// One-tap replies for a finished turn, like mock B's chips. private struct QuickReplies: View { var send: (String) -> Void diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index 0c988fc..9c3f6fe 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -1,4 +1,5 @@ -// App entry. Notification setup and APNs registration go through the WatchKit app delegate. +// App entry. Notification setup and APNs registration go through the WatchKit app delegate, and +// `input` pushes get their own long look, which sets a question's options as its actions. import SwiftUI import WatchKit @@ -13,6 +14,7 @@ struct WristCodeApp: App { RootView() .environment(model) } + WKNotificationScene(controller: InputNotificationController.self, category: PushCategory.input.rawValue) } } @@ -47,6 +49,8 @@ struct RootView: View { ProjectsView(hostId: hostId) case let .projectThreads(hostId, projectId): ProjectThreadsView(hostId: hostId, projectId: projectId) + case let .search(query): + SearchView(query: query) } } } diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 7c13162..fabe230 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -4,6 +4,7 @@ import Foundation import Security import Testing +import UserNotifications @testable import WristCode private func decode(_ type: T.Type, _ json: String) throws -> T { @@ -243,6 +244,65 @@ private let feedItemJSON = """ #expect(list.items.first?.route == .thread(hostId: "h", threadId: "t")) } + @Test func phase4FromDocs() throws { + let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences( + of: #""request": {"#, with: #""changes": { "files": 3, "additions": 42, "deletions": 8 }, "request": {"#)) + #expect(item.changes == ChangeTotals(files: 3, additions: 42, deletions: 8)) + #expect(try decode(FeedItem.self, feedItemJSON).changes == nil) + + let detail = try decode(ThreadDetail.self, """ + { "item": \(feedItemJSON), "messages": [], "canStop": false, "subagents": [], + "changedFiles": [{ "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }] } + """) + #expect(detail.changedFiles == [ChangedFile(path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0)]) + #expect(try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true }"#).changedFiles.isEmpty) + + let summary = try decode(Summary.self, """ + { "needsYou": 1, "working": 3, "hostsOffline": 0, + "next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": "bun test --filter ocr" }, + "current": { "hostId": "h", "threadId": "w", "title": "Phase 3 screens", + "activity": { "kind": "command", "text": "bun test", "startedAt": null } } } + """) + #expect(summary.next?.request == "bun test --filter ocr") + #expect(summary.current?.title == "Phase 3 screens") + #expect(summary.current?.activity?.text == "bun test") + #expect(summary.current?.route == .thread(hostId: "h", threadId: "w")) + let unread = try decode(Summary.self, """ + { "needsYou": 1, "working": 0, "hostsOffline": 0, "current": null, + "next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": null } } + """) + #expect(unread.next?.request == nil) + #expect(unread.current == nil) + + let search = try decode(SearchResponse.self, """ + { "hits": [{ "thread": { "hostId": "h", "threadId": "t", "title": "Jellyfin image", "phase": null, "settled": true, + "updatedAt": "2026-11-01T09:41:00.000Z" }, + "projectTitle": "homelab", "hostLabel": "Mac mini", "snippet": "…rebuild from the Containerfile and…" }, + { "thread": { "hostId": "h", "threadId": "u", "title": "Gateway Containerfile", "phase": "running", "settled": false, + "updatedAt": "2026-11-01T09:42:00.000Z" }, + "projectTitle": "wristcode", "hostLabel": "Mac mini", "snippet": null }], + "unsearched": ["Laptop"] } + """) + #expect(search.hits.map(\.id) == ["h/t", "h/u"]) + #expect(search.hits.first?.snippet == "…rebuild from the Containerfile and…") + #expect(search.hits.last?.snippet == nil) + #expect(search.unsearched == ["Laptop"]) + } + + @Test func pushQuestion() throws { + let info: [AnyHashable: Any] = [ + "hostId": "h", "threadId": "t", "requestId": "r", + "question": ["id": "q1", "options": [["label": "bun:1-alpine", "value": "alpine"]], "allowCustomAnswer": true], + ] + let question = try #require(PushQuestion(userInfo: info)) + #expect(question.id == "q1") + #expect(question.options.map(\.value) == ["alpine"]) + #expect(question.allowCustomAnswer) + // Pushes without one, as from an older gateway, and malformed ones have none. + #expect(PushQuestion(userInfo: ["hostId": "h"]) == nil) + #expect(PushQuestion(userInfo: ["question": ["id": "q1"]]) == nil) + } + @Test func smallResponses() throws { #expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x") #expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t") @@ -444,6 +504,45 @@ private let feedItemJSON = """ #expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil) } + @Test func summaryFromFeedNamesRequestAndCurrent() throws { + let feed = DemoFixtures.previewFeed() + let summary = Summary(feed) + #expect(summary.next?.request == feed.needsYou.first?.request?.summary) + #expect(summary.current?.threadId == feed.working.first?.threadId) + #expect(summary.current?.activity == feed.working.first?.activity) + } + + @Test func changesLine() { + let parts = ChangeTotals(files: 3, additions: 42, deletions: 8).parts + #expect(parts.additions == "+42") + #expect(parts.deletions == "\u{2212}8") + #expect(parts.files == "3 files") + #expect(ChangeTotals(files: 1, additions: 0, deletions: 2).parts == ("+0", "\u{2212}2", "1 file")) + } + + @Test func unsearchedNote() { + #expect(SearchResponse.unsearchedNote([], offline: []) == nil) + #expect(SearchResponse.unsearchedNote(["Laptop"], offline: ["Laptop"]) + == "Laptop is offline, so its threads were not searched.") + #expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop", "LittleMan"]) + == "Laptop and LittleMan are offline, so their threads were not searched.") + // A host the watch saw online failed or was slow, which the gateway does not tell apart. + #expect(SearchResponse.unsearchedNote(["Laptop"], offline: []) == "Laptop could not be searched.") + #expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop"]) + == "Laptop and LittleMan could not be searched.") + #expect(SearchResponse.unsearchedNote(["A", "B", "C"], offline: []) == "A, B and C could not be searched.") + } + + @Test func complicationMode() { + let next = Summary.Next(hostId: "h", threadId: "t", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test") + let current = Summary.Current(hostId: "h", threadId: "w", title: "Phase 3", activity: nil) + #expect(ComplicationMode(Summary(needsYou: 2, working: 1, next: next, current: current, hostsOffline: 0)) == .waiting(next)) + #expect(ComplicationMode(Summary(needsYou: 0, working: 1, next: nil, current: current, hostsOffline: 0)) == .working(current)) + #expect(ComplicationMode(Summary(needsYou: 0, working: 0, next: nil, hostsOffline: 0)) == .idle) + // An older gateway names no working thread. + #expect(ComplicationMode(Summary(needsYou: 0, working: 3, next: nil, hostsOffline: 0)) == .idle) + } + @Test func routeURLs() { let thread = Route.thread(hostId: "mac mini", threadId: "t/1") #expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1") @@ -451,7 +550,7 @@ private let feedItemJSON = """ #expect(Route(url: Route.newThread.url) == .newThread) for route in [ Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"), - .projectThreads(hostId: "mac mini", projectId: "p/1"), + .projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"), ] { #expect(Route(url: route.url) == route) } @@ -477,6 +576,47 @@ private let feedItemJSON = """ // Allow without a requestId cannot answer, so it opens the thread instead. #expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil) == .open(.thread(hostId: "h", threadId: "t"))) + // Answer opens the thread's question screen. + #expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t"))) + } + + @Test func questionActionsBuildAnswers() throws { + let info: [AnyHashable: Any] = [ + "hostId": "h", "threadId": "t", "requestId": "r", + "question": [ + "id": "base", "allowCustomAnswer": true, + "options": [["label": "bun:1-alpine", "value": "alpine"], ["label": "bun:1-debian", "value": "debian"]], + ], + ] + func body(_ action: String, _ text: String? = nil) throws -> [String: Any] { + let response = try #require(NotificationCommand(actionIdentifier: action, userInfo: info, text: text).response) + #expect(response.hostId == "h" && response.threadId == "t") + return try encodeObject(response.request) + } + let picked = try body("option.1") + #expect(picked["requestId"] as? String == "r") + #expect(picked["answers"] as? [String: String] == ["base": "debian"]) + #expect(try body("say", " the slim one ")["answers"] as? [String: String] == ["base": "the slim one"]) + + // An option the push does not have, an empty answer, or no question opens the thread instead. + let thread = NotificationCommand.open(.thread(hostId: "h", threadId: "t")) + #expect(NotificationCommand(actionIdentifier: "option.4", userInfo: info, text: nil) == thread) + #expect(NotificationCommand(actionIdentifier: "say", userInfo: info, text: " ") == thread) + #expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread) + } + + @MainActor @Test func questionNotificationActions() throws { + let question = PushQuestion( + id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")], + allowCustomAnswer: true) + let actions = NotificationHandler.inputActions(question) + #expect(actions.map(\.title) == ["bun:1-alpine", "bun:1-debian", "Say an answer", "Open"]) + #expect(actions.map(\.identifier) == ["option.0", "option.1", "say", "open"]) + #expect(actions[2] is UNTextInputNotificationAction) + var closed = question + closed.allowCustomAnswer = false + #expect(NotificationHandler.inputActions(closed).map(\.title) == ["bun:1-alpine", "bun:1-debian", "Open"]) + #expect(NotificationHandler.inputActions(nil).map(\.title) == ["Answer", "Open"]) } } @@ -652,6 +792,29 @@ private let feedItemJSON = """ } } + @Test func searchSkipsOfflineHosts() async throws { + let gateway = DemoGateway() + let response = try await gateway.search("healthcheck") + #expect(response.hits.map(\.thread.threadId).sorted() == ["t-container", "t-health"]) + #expect(response.hits.first { $0.thread.threadId == "t-container" }?.snippet?.contains("healthcheck") == true) + #expect(response.hits.first { $0.thread.threadId == "t-health" }?.snippet == nil) + #expect(response.unsearched == ["LittleMan"]) + // Bump Next.js is on LittleMan, which is offline. + #expect(try await gateway.search("Next.js").hits.isEmpty) + } + + @Test func approvalForTheSmartStack() async throws { + let gateway = DemoGateway() + // The demo's longest wait is a question, which gets no buttons. + #expect(await WaitingEntry.Approval.load(for: try await gateway.summary(), api: gateway) == nil) + let summary = Summary( + needsYou: 1, working: 0, next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"), + hostsOffline: 0) + let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway)) + #expect(approval.requestId == "r-ocr") + #expect(approval.allow.decision == .accept && approval.deny.decision == .decline) + } + @Test func multiSelectAnswerMovesThreadToWorking() async throws { let gateway = DemoGateway() let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index 0038f06..f438680 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -27,9 +27,62 @@ struct SnapshotTests { hostId: target.hostId, threadId: target.threadId, request: .decision(requestId: item.request!.id, decision: .decline)) } - let idle = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) + let working = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false)) - try write(ComplicationGallery(waiting: waiting, waitingCount: feed.needsYou.count, idle: idle), "complication.png") + try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting), ("Nothing waiting", working)]), "complication.png") + try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting)]), "complication-waiting.png") + try write(ComplicationGallery(sections: [("Nothing waiting", working)]), "complication-working.png") + } + + @Test func smartStack() async throws { + // Answering the release question leaves the homelab card's subagent approval waiting longest. + let gateway = DemoGateway() + let release = try await gateway.thread(hostId: "mac-mini", threadId: "t-release") + try await gateway.respond( + hostId: "mac-mini", threadId: "t-release", + request: .answers(requestId: release.item.request!.id, answers: ["checks": .values(["unit"])])) + let summary = try await gateway.summary() + let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway)) + let waiting = WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval) + + // Denying everything on a fresh gateway leaves the scaffold thread working most recently. + let idle = DemoGateway() + for item in try await idle.feed().needsYou { + let target = item.requestThread + try await idle.respond( + hostId: target.hostId, threadId: target.threadId, + request: .decision(requestId: item.request!.id, decision: .decline)) + } + let working = WaitingEntry(date: .now, content: .counts(try await idle.summary(), stale: false)) + try write(SmartStackGallery(entries: [waiting, working]), "smart-stack.png") + } + + @Test func changes() async throws { + let gateway = DemoGateway() + let feed = try await gateway.feed() + try write(feedColumn { + RecentHeader(recent: feed.recent, more: feed.moreRecent) + ForEach(feed.recent.prefix(4)) { StatusCard(item: $0) } + }, "feed-changes.png") + let detail = try await gateway.thread(hostId: "littleman", threadId: "t-next") + try write(titled("Thread", ThreadView(hostId: "littleman", threadId: "t-next", detail: detail, scrolls: false)), "thread-changes.png") + } + + @Test func search() async throws { + let gateway = DemoGateway() + let hits = try await gateway.search("healthcheck") + try write(titled("\u{201C}healthcheck\u{201D}", SearchView(query: "healthcheck", response: hits, scrolls: false)), "search.png") + let none = try await gateway.search("terraform") + try write(titled("\u{201C}terraform\u{201D}", SearchView(query: "terraform", response: none, scrolls: false)), "search-empty.png") + } + + @Test func questionNotification() throws { + let question = PushQuestion( + id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")], + allowCustomAnswer: true) + try write(NotificationPreview( + view: InputNotificationView(title: "Gateway Containerfile", question: "Which base image?"), + actions: NotificationHandler.inputActions(question).map(\.title)), "notification-question.png") } @Test func multiSelectQuestion() async throws { @@ -169,16 +222,15 @@ struct SnapshotTests { } } -/// Every family at its 46mm size, once with threads waiting and once with nothing waiting. +/// Every family at its 46mm size, once per titled entry. private struct ComplicationGallery: View { - var waiting: WaitingEntry - var waitingCount: Int - var idle: WaitingEntry + var sections: [(title: String, entry: WaitingEntry)] var body: some View { VStack(alignment: .leading, spacing: 10) { - section("\(waitingCount) waiting", waiting) - section("Nothing waiting", idle) + ForEach(sections.indices, id: \.self) { index in + section(sections[index].title, sections[index].entry) + } } .padding(.horizontal, 10) .padding(.top, 8) @@ -193,7 +245,7 @@ private struct ComplicationGallery: View { WaitingView(entry: entry, familyOverride: .accessoryInline) .font(.system(size: 13, weight: .semibold, design: .rounded)) .frame(height: 18) - WaitingView(entry: entry, familyOverride: .accessoryRectangular) + WaitingView(entry: entry, familyOverride: .accessoryRectangular, inStackOverride: false) .frame(width: 184, height: 72) HStack(spacing: 16) { // Outside a face AccessoryWidgetBackground fills a square; a face clips it round. @@ -206,3 +258,51 @@ private struct ComplicationGallery: View { } } } + +/// The rectangular widget as the Smart Stack shows it, on its card and with Allow and Deny when the +/// waiting thread wants an approval. +private struct SmartStackGallery: View { + var entries: [WaitingEntry] + + var body: some View { + VStack(spacing: 10) { + ForEach(entries.indices, id: \.self) { index in + WaitingView(entry: entries[index], familyOverride: .accessoryRectangular, inStackOverride: true) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(width: 196, height: 96, alignment: .topLeading) + .background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + .padding(.top, 8) + .foregroundStyle(.white) + .fontDesign(.rounded) + } +} + +/// The question notification's long look over its actions. The system draws the actions; these +/// stand in for them with the same titles. +private struct NotificationPreview: View { + var view: InputNotificationView + var actions: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("WRISTCODE") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(Theme.secondaryText) + view + .padding(.bottom, 4) + ForEach(actions, id: \.self) { title in + Text(title) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background(Theme.control, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + .padding(.horizontal, 8) + .padding(.top, 10) + .foregroundStyle(.white) + } +} diff --git a/watch/WristCodeWidget/RespondIntent.swift b/watch/WristCodeWidget/RespondIntent.swift new file mode 100644 index 0000000..02e5159 --- /dev/null +++ b/watch/WristCodeWidget/RespondIntent.swift @@ -0,0 +1,37 @@ +// Allow and Deny on the Smart Stack widget. WidgetKit runs the intent without opening the app, then +// reloads the widget's timeline. A request someone already answered (409) counts as done. +// +// The app target compiles this file too, because WaitingView builds the buttons. + +import AppIntents + +struct RespondIntent: AppIntent { + static let title: LocalizedStringResource = "Answer an approval" + static let isDiscoverable = false + + @Parameter(title: "Host") var hostId: String + @Parameter(title: "Thread") var threadId: String + @Parameter(title: "Request") var requestId: String + @Parameter(title: "Decision") var decision: String + + init() {} + + init(_ approval: WaitingEntry.Approval, decision: ApprovalDecision) { + hostId = approval.hostId + threadId = approval.threadId + requestId = approval.requestId + self.decision = decision.rawValue + } + + func perform() async throws -> some IntentResult { + guard let api = SharedState.api else { return .result() } + do { + try await api.respond( + hostId: hostId, threadId: threadId, + request: .decision(requestId: requestId, decision: ApprovalDecision(rawValue: decision))) + } catch let error as GatewayError where error.isStale { + // Answered already, on the watch or elsewhere. The reload shows what is next. + } + return .result() + } +} diff --git a/watch/WristCodeWidget/WaitingView.swift b/watch/WristCodeWidget/WaitingView.swift index 20e6a65..609de4c 100644 --- a/watch/WristCodeWidget/WaitingView.swift +++ b/watch/WristCodeWidget/WaitingView.swift @@ -1,6 +1,12 @@ // One view per complication family, in mock B's colors: amber when something needs you, teal for // work in progress, and nothing that animates. Tinted faces use the accented parts only. // +// The rectangular slot switches, as mock C does: while anything waits on you it shows the counts, +// the thread that has waited longest and what it wants; otherwise the working thread changed most +// recently, how long its step has run and what it is doing. In the Smart Stack, where the widget +// has its container background, an approval also gets Allow and Deny, which answer through +// `RespondIntent` without opening the app. +// // The app target compiles this file too, so the snapshot tests can render the complication. import SwiftUI @@ -16,8 +22,20 @@ struct WaitingEntry: TimelineEntry { case unavailable } + /// The Allow and Deny pair for the summary's `next` thread, when it waits on an approval that + /// offers both. The thread named is the one that answers it, a subagent when one asked. + struct Approval: Equatable, Sendable { + var hostId: String + var threadId: String + var requestId: String + var allow: PendingRequest.Option + var deny: PendingRequest.Option + } + var date: Date var content: Content + /// Only looked up for the rectangular family, which the Smart Stack uses. + var approval: Approval? = nil /// Ranks the complication higher in the Smart Stack while something waits. var relevance: TimelineEntryRelevance? { @@ -27,21 +45,59 @@ struct WaitingEntry: TimelineEntry { static let sample = WaitingEntry(date: .now, content: .counts(Summary( needsYou: 2, working: 3, - next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"), + next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr"), hostsOffline: 0), stale: false)) } +extension WaitingEntry.Approval { + /// Reads `next`'s pending request. Nil unless it is an approval with Allow and Deny, or when the + /// thread cannot be read, in which case the widget opens the thread instead. + static func load(for summary: Summary, api: any GatewayAPI) async -> Self? { + guard summary.needsYou > 0, let next = summary.next, + let detail = try? await api.thread(hostId: next.hostId, threadId: next.threadId), + let request = detail.item.request, !request.isQuestion, let quick = request.quickOptions + else { return nil } + let target = detail.item.requestThread + return Self(hostId: target.hostId, threadId: target.threadId, requestId: request.id, allow: quick.allow, deny: quick.deny) + } +} + +/// What the rectangular slot shows. +enum ComplicationMode: Equatable { + /// Something waits on you: the counts, then the thread that has waited longest and what it wants. + case waiting(Summary.Next) + /// Nothing waits: the working thread changed most recently and what it is doing. + case working(Summary.Current) + /// Nothing waits on you and nothing works, or an older gateway sent no thread to name. + case idle + + init(_ summary: Summary) { + if summary.needsYou > 0, let next = summary.next { + self = .waiting(next) + } else if let current = summary.current { + self = .working(current) + } else { + self = .idle + } + } +} + struct WaitingView: View { @Environment(\.widgetFamily) private var widgetFamily + @Environment(\.showsWidgetContainerBackground) private var showsBackground var entry: WaitingEntry /// Overrides the environment's family, for rendering outside a widget. var familyOverride: WidgetFamily? + /// Overrides whether this is the Smart Stack, for rendering outside a widget. + var inStackOverride: Bool? private var family: WidgetFamily { familyOverride ?? widgetFamily } + /// Watch faces drop the container background, and the Smart Stack keeps it. + private var inStack: Bool { inStackOverride ?? showsBackground } var body: some View { content - .widgetURL(next?.route.url) + .widgetURL(route?.url) .containerBackground(for: .widget) { if waiting > 0 { LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom) @@ -67,14 +123,15 @@ struct WaitingView: View { private var circular: some View { ZStack { AccessoryWidgetBackground() - if let summary, summary.needsYou > 0 { + if let summary, summary.needsYou > 0 || summary.working > 0 { + let waits = summary.needsYou > 0 VStack(spacing: -3) { - Text("\(summary.needsYou)") + Text("\(waits ? summary.needsYou : summary.working)") .font(.system(size: 26, weight: .bold, design: .rounded)) - .foregroundStyle(Theme.you) + .foregroundStyle(waits ? Theme.you : Theme.work) .minimumScaleFactor(0.6) .widgetAccentable() - Text("waiting") + Text(waits ? "waiting" : "working") .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) } @@ -95,42 +152,10 @@ struct WaitingView: View { private var rectangular: some View { VStack(alignment: .leading, spacing: 0) { if let summary { - if let next = summary.next, summary.needsYou > 0 { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(summary.needsYou)") - .font(.system(size: 22, weight: .bold, design: .rounded)) - Text("waiting") - .font(.system(size: 14, weight: .semibold, design: .rounded)) - Spacer(minLength: 0) - staleMark - } - .foregroundStyle(Theme.you) - .widgetAccentable() - Text(next.title) - .font(.system(size: 15, weight: .semibold, design: .rounded)) - .lineLimit(1) - Text(next.hostLabel) - .font(.system(size: 13, design: .rounded)) - .foregroundStyle(.secondary) - .lineLimit(1) - } else { - HStack(spacing: 4) { - Image(systemName: "checkmark") - Text("Nothing waiting") - Spacer(minLength: 0) - staleMark - } - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .foregroundStyle(.secondary) - Text("\(summary.working) working") - .font(.system(size: 17, weight: .bold, design: .rounded)) - .foregroundStyle(summary.working > 0 ? Theme.work : .secondary) - .widgetAccentable() - if summary.hostsOffline > 0 { - Text(summary.hostsOffline == 1 ? "1 host offline" : "\(summary.hostsOffline) hosts offline") - .font(.system(size: 13, design: .rounded)) - .foregroundStyle(.secondary) - } + switch ComplicationMode(summary) { + case let .waiting(next): waitingRows(summary, next) + case let .working(current): workingRows(summary, current) + case .idle: idleRows(summary) } } else { Text("WristCode") @@ -143,22 +168,148 @@ struct WaitingView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) } + /// The counts, the thread that has waited longest, and the command it wants to run or its + /// question. In the Smart Stack an approval adds Allow and Deny. + @ViewBuilder + private func waitingRows(_ summary: Summary, _ next: Summary.Next) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("● \(summary.needsYou) waiting") + .foregroundStyle(Theme.you) + .widgetAccentable() + Spacer(minLength: 0) + if summary.working > 0 { + Text("\(summary.working) working") + .foregroundStyle(Theme.work) + } + staleMark + } + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .lineLimit(1) + Text(next.title) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .lineLimit(1) + .padding(.top, 1) + if let request = next.request { + Text(code: request) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(inStack && entry.approval == nil ? 2 : 1) + } else { + Text(next.hostLabel) + .font(.system(size: 13, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if inStack, let approval = entry.approval { + HStack(spacing: 5) { + Button(intent: RespondIntent(approval, decision: approval.allow.decision)) { + stackButton(approval.allow.label, fill: Theme.you, foreground: .black) + } + Button(intent: RespondIntent(approval, decision: approval.deny.decision)) { + stackButton(approval.deny.label, fill: Theme.control, foreground: .white) + } + } + .buttonStyle(.plain) + .padding(.top, 4) + } + } + + /// The working thread changed most recently with how long its step has run, the step itself, and + /// the waiting count under it. The elapsed time is as of the entry, since nothing here ticks. + @ViewBuilder + private func workingRows(_ summary: Summary, _ current: Summary.Current) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(current.title) + .lineLimit(1) + if let startedAt = current.activity?.startedAt { + Text("· \(Format.elapsed(since: startedAt, now: entry.date))") + .monospacedDigit() + .fixedSize() + } + Spacer(minLength: 0) + staleMark + } + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(Theme.work) + .widgetAccentable() + if let activity = current.activity { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: activity.kind.symbol) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.work) + Text(code: activity.text) + .font(.system(size: 12)) + .lineLimit(inStack ? 2 : 1) + } + .padding(.top, 2) + } else { + Text(summary.working == 1 ? "1 working" : "\(summary.working) working") + .font(.system(size: 13, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + Text(summary.needsYou > 0 ? "● \(summary.needsYou) waiting" : "Nothing waiting") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(summary.needsYou > 0 ? Theme.you : .secondary) + .padding(.top, 3) + } + + private func idleRows(_ summary: Summary) -> some View { + Group { + HStack(spacing: 4) { + if summary.needsYou > 0 { + Text("● \(summary.needsYou) waiting") + .foregroundStyle(Theme.you) + } else { + Image(systemName: "checkmark") + Text("Nothing waiting") + } + Spacer(minLength: 0) + staleMark + } + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + Text("\(summary.working) working") + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(summary.working > 0 ? Theme.work : .secondary) + .widgetAccentable() + if summary.hostsOffline > 0 { + Text(summary.hostsOffline == 1 ? "1 host offline" : "\(summary.hostsOffline) hosts offline") + .font(.system(size: 13, design: .rounded)) + .foregroundStyle(.secondary) + } + } + } + + /// The count with the thread's title, then the counts alone, then the waiting count. private var inline: some View { ViewThatFits { + Text(titled ?? spoken) Text(spoken) if let summary { Text("\(summary.needsYou) waiting") } } } + /// "2 waiting · Receipt OCR" or "3 working · Phase 3 screens", for the inline slot. + private var titled: String? { + guard let summary else { return nil } + return switch ComplicationMode(summary) { + case let .waiting(next): "\(summary.needsYou) waiting · \(next.title)" + case let .working(current): "\(summary.working) working · \(current.title)" + case .idle: nil + } + } + private var corner: some View { Group { if let summary { - Text("\(summary.needsYou)") + let waits = summary.needsYou > 0 + Text("\(waits ? summary.needsYou : summary.working)") .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundStyle(summary.needsYou > 0 ? Theme.you : Color.secondary) + .foregroundStyle(waits ? Theme.you : summary.working > 0 ? Theme.work : Color.secondary) .widgetAccentable() } else { Image(systemName: unpairedSymbol) @@ -168,7 +319,7 @@ struct WaitingView: View { } .widgetLabel { if let summary { - Text(summary.needsYou > 0 ? "waiting" : "\(summary.working) working") + Text(summary.needsYou > 0 ? "waiting" : "working") } else { Text("WristCode") } @@ -185,12 +336,31 @@ struct WaitingView: View { private var waiting: Int { summary?.needsYou ?? 0 } - private var next: Summary.Next? { waiting > 0 ? summary?.next : nil } + /// Where a tap goes: the thread waiting longest, else the working thread, else the feed. + private var route: Route? { + guard let summary else { return nil } + return switch ComplicationMode(summary) { + case let .waiting(next): next.route + case let .working(current): current.route + case .idle: nil + } + } private var unpairedSymbol: String { entry.content == .notPaired ? "applewatch.slash" : "wifi.slash" } + /// A Smart Stack button's label, drawn here because widgets show a button's label as is. + private func stackButton(_ title: String, fill: Color, foreground: Color) -> some View { + Text(title) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(foreground) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background(fill, in: Capsule()) + } + /// Shown beside the count when it is the last one seen, not a fresh one. @ViewBuilder private var staleMark: some View { diff --git a/watch/WristCodeWidget/WaitingWidget.swift b/watch/WristCodeWidget/WaitingWidget.swift index 331a02c..68c9422 100644 --- a/watch/WristCodeWidget/WaitingWidget.swift +++ b/watch/WristCodeWidget/WaitingWidget.swift @@ -1,6 +1,7 @@ -// The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It -// reads the device token and the demo flag from the shared Keychain group, and the app reloads it -// whenever its own feed or a notification changes the counts. +// The watch face complication and Smart Stack widget: what waits on you or what is working, from +// `GET /v1/summary`. For the rectangular family it also reads the waiting thread, so the Smart Stack +// can offer Allow and Deny. It reads the device token and the demo flag from the shared Keychain +// group, and the app reloads it whenever its own feed or a notification changes the counts. import SwiftUI import WidgetKit @@ -18,7 +19,7 @@ struct WaitingWidget: Widget { WaitingView(entry: entry) } .configurationDisplayName("Waiting on you") - .description("Threads waiting for your approval or answer.") + .description("Threads waiting for your approval or answer, or what is working now.") .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) } } @@ -36,29 +37,26 @@ struct WaitingProvider: TimelineProvider { completion(.sample) return } - Task { completion(await Self.load()) } + let family = context.family + Task { completion(await Self.load(family)) } } func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { + let family = context.family Task { - let entry = await Self.load() + let entry = await Self.load(family) completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(Self.refreshInterval)))) } } - static func load() async -> WaitingEntry { - let api: any GatewayAPI - if SharedState.demo { - api = DemoGateway.shared - } else if let credentials = DeviceStore.load() { - api = GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token) - } else { - return WaitingEntry(date: .now, content: .notPaired) - } + /// The summary, and for the rectangular family the waiting approval's Allow and Deny. + static func load(_ family: WidgetFamily) async -> WaitingEntry { + guard let api = SharedState.api else { return WaitingEntry(date: .now, content: .notPaired) } do { let summary = try await api.summary() SharedState.lastSummary = summary - return WaitingEntry(date: .now, content: .counts(summary, stale: false)) + let approval = family == .accessoryRectangular ? await WaitingEntry.Approval.load(for: summary, api: api) : nil + return WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval) } catch GatewayError.unauthorized { return WaitingEntry(date: .now, content: .notPaired) } catch { @@ -69,9 +67,12 @@ struct WaitingProvider: TimelineProvider { } private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry { - let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini") + let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr") + let current = Summary.Current( + hostId: "mac-mini", threadId: "t-scaffold", title: "Watch app scaffold", + activity: Activity(kind: .command, text: "bun test", startedAt: .now.addingTimeInterval(-42))) return WaitingEntry(date: .now, content: .counts( - Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1), + Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, current: working > 0 ? current : nil, hostsOffline: 1), stale: false)) } diff --git a/watch/project.yml b/watch/project.yml index 5d5d004..f502197 100644 --- a/watch/project.yml +++ b/watch/project.yml @@ -19,8 +19,9 @@ targets: platform: watchOS sources: - WristCode - # The complication's views, so the snapshot tests can render them. + # The complication's views and the intent its buttons run, so the snapshot tests can render them. - WristCodeWidget/WaitingView.swift + - WristCodeWidget/RespondIntent.swift dependencies: # Embedded in the watch app's PlugIns folder. - target: WristCodeWidget diff --git a/watch/screenshots/browse-hosts.png b/watch/screenshots/browse-hosts.png index 6d7ba6e..9318c19 100644 Binary files a/watch/screenshots/browse-hosts.png and b/watch/screenshots/browse-hosts.png differ diff --git a/watch/screenshots/complication-waiting.png b/watch/screenshots/complication-waiting.png new file mode 100644 index 0000000..69879ef Binary files /dev/null and b/watch/screenshots/complication-waiting.png differ diff --git a/watch/screenshots/complication-working.png b/watch/screenshots/complication-working.png new file mode 100644 index 0000000..88f6c2f Binary files /dev/null and b/watch/screenshots/complication-working.png differ diff --git a/watch/screenshots/complication.png b/watch/screenshots/complication.png index a232f35..b31c294 100644 Binary files a/watch/screenshots/complication.png and b/watch/screenshots/complication.png differ diff --git a/watch/screenshots/feed-changes.png b/watch/screenshots/feed-changes.png new file mode 100644 index 0000000..c342f05 Binary files /dev/null and b/watch/screenshots/feed-changes.png differ diff --git a/watch/screenshots/feed-recent.png b/watch/screenshots/feed-recent.png index d759bd8..27f3005 100644 Binary files a/watch/screenshots/feed-recent.png and b/watch/screenshots/feed-recent.png differ diff --git a/watch/screenshots/notification-question.png b/watch/screenshots/notification-question.png new file mode 100644 index 0000000..c1bd473 Binary files /dev/null and b/watch/screenshots/notification-question.png differ diff --git a/watch/screenshots/recent.png b/watch/screenshots/recent.png index 5e46dc4..20287cb 100644 Binary files a/watch/screenshots/recent.png and b/watch/screenshots/recent.png differ diff --git a/watch/screenshots/search-empty.png b/watch/screenshots/search-empty.png new file mode 100644 index 0000000..d23f487 Binary files /dev/null and b/watch/screenshots/search-empty.png differ diff --git a/watch/screenshots/search.png b/watch/screenshots/search.png new file mode 100644 index 0000000..173454d Binary files /dev/null and b/watch/screenshots/search.png differ diff --git a/watch/screenshots/smart-stack.png b/watch/screenshots/smart-stack.png new file mode 100644 index 0000000..d189506 Binary files /dev/null and b/watch/screenshots/smart-stack.png differ diff --git a/watch/screenshots/thread-changes.png b/watch/screenshots/thread-changes.png new file mode 100644 index 0000000..9ffd7a4 Binary files /dev/null and b/watch/screenshots/thread-changes.png differ