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
}