Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications

This commit is contained in:
gib committed 2026-11-02 13:56:10 -05:00
1 parent f8241e42bb
commit bfcbb69550
29 files changed
+1100 -137

No files matched your search

+6
View File
@@ -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)
}
+113 -8
View File
@@ -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>) -> 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
+9 -1
View File
@@ -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/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
/// `wristcode://projects/<hostId>` or `wristcode://project/<hostId>/<projectId>`.
/// `wristcode://projects/<hostId>`, `wristcode://project/<hostId>/<projectId>` or `wristcode://search?q=<query>`.
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
}
+50 -4
View File
@@ -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<String>
@@ -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<n>`, 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 {
@@ -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.<n>` 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<InputNotificationView> {
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)
}
}
+8 -1
View File
@@ -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) } }
+165 -6
View File
@@ -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<String> {
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<Trailing: View>: View {
@Environment(AppModel.self) private var model
@@ -135,11 +277,13 @@ struct ThreadLine<Trailing: View>: View {
}
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
private struct BrowseList<Rows: View>: View {
/// `header` shows above the rows even while they load.
private struct BrowseList<Header: View, Rows: View>: 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<Rows: View>: View {
}
@ViewBuilder private var content: some View {
header
if loaded {
rows
} else if error == nil {
@@ -174,6 +319,12 @@ private struct BrowseList<Rows: View>: 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")
+12 -23
View File
@@ -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") {
+35 -1
View File
@@ -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 {
+42 -6
View File
@@ -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
+5 -1
View File
@@ -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)
}
}
}
+164 -1
View File
@@ -4,6 +4,7 @@
import Foundation
import Security
import Testing
import UserNotifications
@testable import WristCode
private func decode<T: Decodable>(_ 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 })
+109 -9
View File
@@ -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)
}
}
+37
View File
@@ -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()
}
}
+217 -47
View File
@@ -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 {
+19 -18
View File
@@ -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<WaitingEntry>) -> 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))
}
+2 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB