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
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 98 KiB |