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:
1 parent
f8241e42bb
commit
bfcbb69550
29 files changed
+1100
-137
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user