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

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

No files matched your search

+6
View File
@@ -62,6 +62,8 @@ protocol GatewayAPI: Sendable {
func projects() async throws -> ProjectsResponse
/// A project's newest 50 threads, settled ones included. Subagents are left out.
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse
/// Thread titles and messages on every online host matching `query`, which needs two characters.
func search(_ query: String) async throws -> SearchResponse
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
func registerPush(_ registration: PushRegistration) async throws
func status() async throws -> StatusResponse
@@ -137,6 +139,10 @@ struct GatewayClient: GatewayAPI {
try await call("GET", ["v1", "projects", hostId, projectId, "threads"])
}
func search(_ query: String) async throws -> SearchResponse {
try await call("GET", ["v1", "search"], query: [URLQueryItem(name: "q", value: query)])
}
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
try await call("POST", ["v1", "threads"], body: request)
}
+113 -8
View File
@@ -157,6 +157,8 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
/// gateways.
var activity: Activity? = nil
/// What the latest run changed, in total. Nil when it changed no files, and from older gateways.
var changes: ChangeTotals? = nil
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
@@ -189,6 +191,62 @@ struct Activity: Codable, Hashable, Sendable {
var startedAt: Date?
}
/// What a thread's latest run changed, in total, for the line on a Recent card.
struct ChangeTotals: Codable, Hashable, Sendable {
var files: Int
var additions: Int
var deletions: Int
/// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files".
var parts: (additions: String, deletions: String, files: String) {
("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files")
}
}
/// A file the latest run changed. `name` is the last part of `path`.
struct ChangedFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int
var deletions: Int
var id: String { path }
}
/// A thread search hit: the thread, and the text around the match when it was inside a message.
struct SearchHit: Codable, Hashable, Sendable, Identifiable {
var thread: ThreadRow
var projectTitle: String
var hostLabel: String
/// Nil when only the title matched.
var snippet: String?
var id: String { thread.id }
}
/// `GET /v1/search?q=`: hits from every online host, newest match first.
struct SearchResponse: Codable, Hashable, Sendable {
var hits: [SearchHit]
/// Labels of hosts that were offline or failed, so their threads were not searched.
var unsearched: [String]
/// The line under the results naming the hosts left out, such as "Laptop is offline, so its
/// threads were not searched." `offline` holds the labels of hosts the watch knows are offline;
/// any other host failed or was too slow, which the gateway does not tell apart.
static func unsearchedNote(_ labels: [String], offline: Set<String>) -> String? {
guard !labels.isEmpty else { return nil }
let names: String = switch labels.count {
case 1: labels[0]
case 2: "\(labels[0]) and \(labels[1])"
default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1]
}
guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." }
return labels.count == 1
? "\(names) is offline, so its threads were not searched."
: "\(names) are offline, so their threads were not searched."
}
}
/// A thread in a list: a project's threads, or a thread's subagents.
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
var hostId: String
@@ -277,13 +335,26 @@ struct SettleRecentResponse: Codable, Hashable, Sendable {
var failed: Int
}
/// `GET /v1/summary`: the counts behind the complication.
/// `GET /v1/summary`: what the complication and the Smart Stack widget show.
struct Summary: Codable, Hashable, Sendable {
struct Next: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var hostLabel: String
/// The command it wants to run or its first question, trimmed. Nil until the gateway has read
/// it, and from older gateways.
var request: String? = nil
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
/// The working thread updated most recently, with what it is doing.
struct Current: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var activity: Activity?
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
@@ -292,18 +363,24 @@ struct Summary: Codable, Hashable, Sendable {
var working: Int
/// The thread that has waited longest for you.
var next: Next?
/// Nil when nothing is working, and from older gateways.
var current: Current? = nil
var hostsOffline: Int
}
extension Summary {
/// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its
/// first item is the one that has waited longest.
/// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its
/// first item is the one that has waited longest, and working newest first.
init(_ feed: Feed) {
let next = feed.needsYou.first
let current = feed.working.first
self.init(
needsYou: feed.needsYou.count,
working: feed.working.count,
next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) },
next: next.map {
Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel, request: $0.request?.summary)
},
current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) },
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
}
}
@@ -336,14 +413,16 @@ struct ThreadDetail: Codable, Hashable, Sendable {
var earlierCursor: String? = nil
/// Subagents and delegated threads this thread started, newest first.
var subagents: [ThreadRow] = []
/// The files the latest run changed, most lines changed first.
var changedFiles: [ChangedFile] = []
}
extension ThreadDetail {
private enum CodingKeys: String, CodingKey {
case item, messages, canStop, askFirst, earlierCursor, subagents
case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles
}
/// `subagents` is newer than the rest, so a gateway that leaves it out lists none.
/// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
@@ -352,7 +431,8 @@ extension ThreadDetail {
canStop: try container.decode(Bool.self, forKey: .canStop),
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [])
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [],
changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? [])
}
}
@@ -605,7 +685,7 @@ struct SendMessageRequest: Codable, Sendable {
var text: String
}
enum RespondRequest: Encodable, Sendable {
enum RespondRequest: Encodable, Equatable, Sendable {
/// One question's answer: an option value or typed text, or option values for a multi-select question.
enum Answer: Encodable, Hashable, Sendable {
case text(String)
@@ -657,6 +737,31 @@ struct ErrorResponse: Codable, Sendable {
var error: String
}
/// `question` on an `input` push that asks one single-choice question: its options, so the
/// notification can offer each as a button. Answer with `[id: value]` on the respond route.
struct PushQuestion: Codable, Hashable, Sendable {
var id: String
/// At most 4.
var options: [PendingRequest.Question.Choice]
/// Adds a spoken or typed answer.
var allowCustomAnswer: Bool
/// The question in a push's `userInfo`, or nil when the push has none or it does not decode.
init?(userInfo: [AnyHashable: Any]) {
guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let question = try? JSON.decoder().decode(Self.self, from: data)
else { return nil }
self = question
}
init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) {
self.id = id
self.options = options
self.allowCustomAnswer = allowCustomAnswer
}
}
/// Notification categories the watch registers actions for.
enum PushCategory: String, CaseIterable, Sendable {
case approval, finished, failed, input, host, test
+9 -1
View File
@@ -15,12 +15,14 @@ enum Route: Hashable, Sendable {
case allThreads
case hostProjects(hostId: String)
case projectThreads(hostId: String, projectId: String)
/// Threads on every host matching a search from All threads.
case search(query: String)
static let scheme = "wristcode"
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
/// `wristcode://projects/<hostId>` or `wristcode://project/<hostId>/<projectId>`.
/// `wristcode://projects/<hostId>`, `wristcode://project/<hostId>/<projectId>` or `wristcode://search?q=<query>`.
var url: URL {
var components = URLComponents()
components.scheme = Self.scheme
@@ -50,6 +52,9 @@ enum Route: Hashable, Sendable {
case let .projectThreads(hostId, projectId):
components.host = "project"
components.percentEncodedPath = path(hostId, projectId)
case let .search(query):
components.host = "search"
components.queryItems = [URLQueryItem(name: "q", value: query)]
}
return components.url!
}
@@ -79,6 +84,9 @@ enum Route: Hashable, Sendable {
self = .hostProjects(hostId: parts[0])
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
self = .projectThreads(hostId: parts[0], projectId: parts[1])
case "search":
guard let query = components.queryItems?.first(where: { $0.name == "q" })?.value, !query.isEmpty else { return nil }
self = .search(query: query)
default:
return nil
}
+50 -4
View File
@@ -2,7 +2,8 @@
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
// answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, Ask before acting and the notification switches stick, and so on.
// Subagent threads open like any other but stay out of the feed, as the gateway's do.
// Subagent threads open like any other but stay out of the feed, as the gateway's do. Search matches
// titles and messages on the online hosts.
import Foundation
@@ -13,6 +14,8 @@ actor DemoGateway: GatewayAPI {
private var messages: [String: [Message]]
/// Pages before each thread's messages, newest page first.
private let earlierPages: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id.
private let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id. Subagents are in `items` but not the feed.
private let children: [String: [String]]
private let subagentIds: Set<String>
@@ -30,6 +33,7 @@ actor DemoGateway: GatewayAPI {
items = fixtures.items
messages = fixtures.messages
earlierPages = fixtures.history
changedFiles = fixtures.changedFiles
children = fixtures.children
subagentIds = Set(fixtures.children.values.joined())
settled = fixtures.settled
@@ -52,7 +56,8 @@ actor DemoGateway: GatewayAPI {
item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking,
askFirst: askingFirst.contains(item.id),
earlierCursor: earlierPages[item.id] == nil ? nil : "p0",
subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) })
subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) },
changedFiles: changedFiles[item.id] ?? [])
}
/// Cursors are `p<n>`, the n-th page back from the thread's messages.
@@ -149,6 +154,31 @@ actor DemoGateway: GatewayAPI {
return ThreadListResponse(items: rows)
}
/// Titles first, then messages, on online hosts only, newest first. A message match carries the
/// words around it.
func search(_ query: String) async throws -> SearchResponse {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else { throw GatewayError.http(status: 400, message: "Search for at least two letters.") }
let online = Set(hosts.filter { $0.status == .online }.map(\.id))
let hits: [(SearchHit, Date)] = items.compactMap { item in
guard online.contains(item.hostId) else { return nil }
let hit = { (snippet: String?) in
SearchHit(thread: self.row(item), projectTitle: item.projectTitle, hostLabel: item.hostLabel, snippet: snippet)
}
if item.title.localizedCaseInsensitiveContains(query) { return (hit(nil), item.updatedAt) }
guard let message = messages[item.id]?.last(where: { $0.text.localizedCaseInsensitiveContains(query) }),
let range = message.text.range(of: query, options: .caseInsensitive)
else { return nil }
let start = message.text.index(range.lowerBound, offsetBy: -30, limitedBy: message.text.startIndex) ?? message.text.startIndex
let end = message.text.index(range.upperBound, offsetBy: 50, limitedBy: message.text.endIndex) ?? message.text.endIndex
let snippet = (start > message.text.startIndex ? "…" : "") + message.text[start ..< end] + (end < message.text.endIndex ? "…" : "")
return (hit(snippet), message.createdAt)
}
return SearchResponse(
hits: hits.sorted { $0.1 > $1.1 }.map(\.0),
unsearched: hosts.filter { !online.contains($0.id) }.map(\.label))
}
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
let project = host.projects.first(where: { $0.id == request.projectId })
@@ -229,6 +259,8 @@ struct DemoFixtures {
let items: [FeedItem]
let messages: [String: [Message]]
let history: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id. Each thread's `changes` totals them.
let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id.
let children: [String: [String]]
/// Threads settled from the start, which only a project's thread list shows.
@@ -388,8 +420,22 @@ struct DemoFixtures {
}
settled = Set(settledThreads.map(\.id))
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + settledThreads + [path, image, healthcheck]
func file(_ path: String, _ additions: Int, _ deletions: Int) -> ChangedFile {
ChangedFile(path: path, name: String(path.split(separator: "/").last ?? ""), additions: additions, deletions: deletions)
}
let changed = [
nextjs.id: [file("package.json", 31, 0), file("next.config.ts", 9, 6), file("app/layout.tsx", 2, 2)],
cdn.id: [file("src/headers.rs", 14, 6)],
]
changedFiles = changed
items = ([approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + settledThreads + [path, image, healthcheck]).map { item in
guard let files = changed[item.id] else { return item }
var item = item
item.changes = ChangeTotals(
files: files.count, additions: files.map(\.additions).reduce(0, +), deletions: files.map(\.deletions).reduce(0, +))
return item
}
children = [homelab.id: [path.id], containerfile.id: [image.id, healthcheck.id]]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
@@ -1,12 +1,22 @@
// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny
// and Reply run in the background against the gateway; tapping a notification opens its thread.
// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny,
// Reply and a question's answers run in the background against the gateway; tapping a notification
// opens its thread.
//
// An `input` push that carries `question` gets one button per option, plus "Say an answer" when the
// question takes its own answer. watchOS only lets a notification's actions change per push from
// its hosting controller, so `InputNotificationController` sets them when the push arrives. Any other
// `input` push keeps the category's Answer and Open, which open the thread's question screen.
import Foundation
import SwiftUI
import UserNotifications
import WatchKit
/// What a notification response asks for, parsed out of the payload.
enum NotificationCommand: Equatable, Sendable {
case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision)
/// One answer to a push's single question: an option's value, or what was said or typed.
case answer(hostId: String, threadId: String, requestId: String, questionId: String, value: String)
case reply(hostId: String, threadId: String, text: String)
case open(Route?)
case none
@@ -16,9 +26,16 @@ enum NotificationCommand: Equatable, Sendable {
static let deny = "deny"
static let reply = "reply"
static let open = "open"
/// Opens the thread, on a question the notification cannot answer itself.
static let answer = "answer"
/// A spoken or typed answer to a push's question.
static let say = "say"
/// `option.<n>` picks the push question's n-th option.
static let optionPrefix = "option."
}
/// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`.
/// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals and questions,
/// `requestId`, and for a lone single-choice question, `question`.
init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) {
// The gateway sends `threadId: ""` on host notifications, so empty means absent.
func value(_ key: String) -> String? {
@@ -29,6 +46,14 @@ enum NotificationCommand: Equatable, Sendable {
let requestId = value("requestId")
let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil }
/// The question's answer, or the thread when the answer or the keys it needs are missing.
func answer(_ value: String?) -> Self {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let hostId, let threadId, let requestId, let question = PushQuestion(userInfo: userInfo), !value.isEmpty
else { return .open(thread) }
return .answer(hostId: hostId, threadId: threadId, requestId: requestId, questionId: question.id, value: value)
}
switch actionIdentifier {
case Action.allow, Action.deny:
guard let hostId, let threadId, let requestId else { self = .open(thread); return }
@@ -39,13 +64,31 @@ enum NotificationCommand: Equatable, Sendable {
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return }
self = .reply(hostId: hostId, threadId: threadId, text: text)
case Action.say:
self = answer(text)
case let id where id.hasPrefix(Action.optionPrefix):
let index = Int(id.dropFirst(Action.optionPrefix.count))
let options = PushQuestion(userInfo: userInfo)?.options ?? []
self = answer(index.flatMap { options.indices.contains($0) ? options[$0].value : nil })
case UNNotificationDismissActionIdentifier:
self = .none
default:
// The Open action, or a tap on the notification itself.
// Open, Answer, or a tap on the notification itself.
self = .open(thread)
}
}
/// The respond route's thread and body, for Allow, Deny and a question's answer.
var response: (hostId: String, threadId: String, request: RespondRequest)? {
switch self {
case let .respond(hostId, threadId, requestId, decision):
(hostId, threadId, .decision(requestId: requestId, decision: decision))
case let .answer(hostId, threadId, requestId, questionId, value):
(hostId, threadId, .answers(requestId: requestId, answers: [questionId: .text(value)]))
default:
nil
}
}
}
@MainActor
@@ -66,13 +109,15 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
let reply = UNTextInputNotificationAction(
identifier: Action.reply, title: "Reply", options: [],
textInputButtonTitle: "Send", textInputPlaceholder: "Reply")
let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground])
func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory {
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
}
return [
category(.approval, [allow, deny]),
category(.input, [open]),
// A question push replaces these with its options; see `inputActions`.
category(.input, [answer, open]),
category(.finished, [reply, open]),
category(.failed, [open]),
category(.host, []),
@@ -80,6 +125,24 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
]
}
/// An `input` push's actions: its question's options, "Say an answer" when the question takes
/// one, and Open. Without a question, Answer and Open, as the category registers them.
static func inputActions(_ question: PushQuestion?) -> [UNNotificationAction] {
typealias Action = NotificationCommand.Action
let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground])
guard let question else {
return [UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]), open]
}
let options = question.options.enumerated().map { index, option in
UNNotificationAction(identifier: Action.optionPrefix + String(index), title: option.label, options: [])
}
let say = UNTextInputNotificationAction(
identifier: Action.say, title: "Say an answer", options: [],
icon: UNNotificationActionIcon(systemImageName: "mic.fill"),
textInputButtonTitle: "Send", textInputPlaceholder: "Answer")
return options + (question.allowCustomAnswer ? [say] : []) + [open]
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
@@ -107,12 +170,10 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
model.reloadComplication()
await model.refreshFeed()
return
case let .respond(hostId, threadId, requestId, decision):
guard model.mode != .unpaired else { return }
case .respond, .answer:
guard model.mode != .unpaired, let response = command.response else { return }
await attempt("Couldn't answer") {
try await model.api.respond(
hostId: hostId, threadId: threadId,
request: .decision(requestId: requestId, decision: decision))
try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request)
}
case let .reply(hostId, threadId, text):
guard model.mode != .unpaired else { return }
@@ -141,3 +202,39 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
}
}
}
/// The long look for `input` pushes. It shows the push's title and question, and sets the push's
/// own actions, which is the one place watchOS takes per-push action titles.
final class InputNotificationController: WKUserNotificationHostingController<InputNotificationView> {
private var threadTitle = ""
private var questionText = ""
override var body: InputNotificationView {
InputNotificationView(title: threadTitle, question: questionText)
}
override func didReceive(_ notification: UNNotification) {
let content = notification.request.content
threadTitle = content.title
questionText = content.body
notificationActions = NotificationHandler.inputActions(PushQuestion(userInfo: content.userInfo))
}
}
/// The thread's title and its question, above the actions the system draws.
struct InputNotificationView: View {
var title: String
var question: String
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 15, weight: .semibold))
Text(question)
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
}
.frame(maxWidth: .infinity, alignment: .leading)
.fontDesign(.rounded)
}
}
+8 -1
View File
@@ -1,6 +1,7 @@
// What the app tells the complication besides the credentials: whether it is in demo mode, and the
// last summary it saw, which the complication shows when it cannot reach the gateway. Both live in
// the shared Keychain group next to the device token, the one store the two processes share.
// the shared Keychain group next to the device token, the one store the two processes share. `api`
// is the gateway the complication and its Allow and Deny buttons call.
import Foundation
@@ -14,6 +15,12 @@ enum SharedState {
set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) }
}
/// The demo gateway in demo mode, the paired gateway otherwise, and nil when not paired.
static var api: (any GatewayAPI)? {
if demo { return DemoGateway.shared }
return DeviceStore.load().map { GatewayClient(baseURL: $0.gatewayURL, token: $0.token) }
}
/// The last summary the app or the complication saw, or nil after signing out.
static var lastSummary: Summary? {
get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
+165 -6
View File
@@ -1,11 +1,12 @@
// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title,
// then a project's threads with settled ones included. Hosts and projects come from
// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads
// when it appears; nothing polls. A thread opens the usual thread screen.
// All threads, opened from the last row of the feed: a search field over the hosts, then a host's
// projects sorted by title, then a project's threads with settled ones included. Hosts and projects
// come from `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`, and
// search results from `GET /v1/search`. Each screen loads when it appears; nothing polls. A thread or
// a search hit opens the usual thread screen.
import SwiftUI
/// Every host, offline ones dimmed. Each opens its projects.
/// A search field, then every host, offline ones dimmed. Each host opens its projects.
struct HostsView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
@@ -13,6 +14,9 @@ struct HostsView: View {
var body: some View {
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
SearchField()
.feedRow()
} rows: {
if let hosts = model.projects?.hosts {
ForEach(hosts) { host in
BrowseRow(
@@ -105,6 +109,144 @@ struct ProjectThreadsView: View {
}
}
/// Threads on every host matching `query`: each hit's host and project, its title, and the words
/// around the match when it was inside a message. Says which hosts were left out.
struct SearchView: View {
@Environment(AppModel.self) private var model
let query: String
@State private var response: SearchResponse?
@State private var error: String?
private let scrolls: Bool
/// `response` starts the screen loaded, for snapshots.
init(query: String, response: SearchResponse? = nil, scrolls: Bool = true) {
self.query = query
self.scrolls = scrolls
_response = State(initialValue: response)
}
var body: some View {
BrowseList(scrolls: scrolls, error: error, loaded: response != nil, reload: load) {
if let response {
ForEach(response.hits) { hit in
SearchHitCard(hit: hit, query: query)
.feedRow()
}
if response.hits.isEmpty {
Text("No threads match.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity)
.padding(.top, 16)
.feedRow()
}
if let note = SearchResponse.unsearchedNote(response.unsearched, offline: offlineHosts) {
Text(note)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.feedRow()
}
}
}
.navigationTitle("\u{201C}\(query)\u{201D}")
}
/// Labels of hosts the feed or All threads last saw offline.
private var offlineHosts: Set<String> {
let feed = model.feed?.hosts.filter { $0.status != .online }.map(\.label) ?? []
let browsed = model.projects?.hosts.filter { $0.status != .online }.map(\.label) ?? []
return Set(feed + browsed)
}
private func load() async {
do {
response = try await model.api.search(query)
error = nil
} catch {
self.error = model.message(for: error)
}
}
}
/// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS
/// text field does, and submitting opens the results.
private struct SearchField: View {
@Environment(AppModel.self) private var model
var body: some View {
TextFieldLink(prompt: Text("Search threads")) {
HStack(spacing: 7) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.secondaryText)
Text("Search")
.foregroundStyle(Theme.secondaryText)
Spacer(minLength: 4)
Image(systemName: "mic.fill")
.foregroundStyle(Theme.work)
}
.font(.system(size: 14, weight: .semibold))
.padding(.vertical, 9)
.padding(.horizontal, 12)
.background(Theme.control.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(Rectangle())
} onSubmit: { text in
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return }
model.path.append(.search(query: query))
}
.buttonStyle(.plain)
.accessibilityLabel("Search threads")
}
}
/// A search hit: "host · project", the title, dimmed once finished or settled, and the snippet with
/// the matched words picked out. Opens the thread.
private struct SearchHitCard: View {
@Environment(AppModel.self) private var model
var hit: SearchHit
var query: String
var body: some View {
Button { model.path.append(hit.thread.route) } label: {
Card(edge: Theme.card) {
Text("\(hit.hostLabel) · \(hit.projectTitle)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
Text(hit.thread.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(hit.thread.isActive ? Color.white : Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
if let snippet = hit.snippet {
Text(highlighted(snippet))
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(3)
.padding(.top, 3)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// The snippet with each match of the query bold and white.
private func highlighted(_ snippet: String) -> AttributedString {
var text = AttributedString(snippet)
var searchFrom = text.startIndex
while let range = text[searchFrom...].range(of: query, options: .caseInsensitive) {
text[range].inlinePresentationIntent = .stronglyEmphasized
text[range].foregroundColor = .white
searchFrom = range.upperBound
}
return text
}
}
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
struct ThreadLine<Trailing: View>: View {
@Environment(AppModel.self) private var model
@@ -135,11 +277,13 @@ struct ThreadLine<Trailing: View>: View {
}
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
private struct BrowseList<Rows: View>: View {
/// `header` shows above the rows even while they load.
private struct BrowseList<Header: View, Rows: View>: View {
var scrolls: Bool
var error: String?
var loaded: Bool
var reload: () async -> Void
@ViewBuilder var header: Header
@ViewBuilder var rows: Rows
var body: some View {
@@ -155,6 +299,7 @@ private struct BrowseList<Rows: View>: View {
}
@ViewBuilder private var content: some View {
header
if loaded {
rows
} else if error == nil {
@@ -174,6 +319,12 @@ private struct BrowseList<Rows: View>: View {
}
}
extension BrowseList where Header == EmptyView {
init(scrolls: Bool, error: String?, loaded: Bool, reload: @escaping () async -> Void, @ViewBuilder rows: () -> Rows) {
self.init(scrolls: scrolls, error: error, loaded: loaded, reload: reload, header: { EmptyView() }, rows: rows)
}
}
/// A plain card with a title, an optional line under it, and a chevron.
private struct BrowseRow: View {
@Environment(AppModel.self) private var model
@@ -228,6 +379,14 @@ private struct BrowseNote: View {
.fontDesign(.rounded)
}
#Preview("Search") {
NavigationStack {
SearchView(query: "containerfile")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Threads") {
NavigationStack {
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
+12 -23
View File
@@ -317,8 +317,8 @@ struct NeedsYouCard: View {
}
/// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
/// step it is on, or its latest message when the gateway names no step. A recent thread that failed
/// to settle shows why.
/// step it is on, or its latest message when the gateway names no step. A recent thread shows what
/// its latest run changed, such as "+42 −8 · 3 files", and why it failed to settle.
struct StatusCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
@@ -344,6 +344,12 @@ struct StatusCard: View {
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 3)
} else if !item.phase.isWorking, let changes = item.changes {
Text(changes: changes)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.padding(.top, 3)
}
if let error = model.settleErrors[item.id] {
Text(error)
@@ -374,7 +380,7 @@ struct ActivityLine: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Image(systemName: symbol)
Image(systemName: activity.kind.symbol)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.work)
.frame(width: 14)
@@ -393,30 +399,13 @@ struct ActivityLine: View {
}
private var label: Text {
// Marked as code, which Text sets in monospace. A monospaced Font loses to the app's rounded design.
let mono = { (text: String) -> Text in
var code = AttributedString(text)
code.inlinePresentationIntent = .code
return Text(code).font(.system(size: 11))
}
let mono = Text(code: activity.text).font(.system(size: 11))
return switch activity.kind {
case .command: mono(activity.text)
case .edit: Text("Editing \(mono(activity.text))")
case .command: mono
case .edit: Text("Editing \(mono)")
default: Text(activity.text)
}
}
private var symbol: String {
switch activity.kind {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
}
#Preview("Feed") {
+35 -1
View File
@@ -1,5 +1,5 @@
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// and red means broken.
// and red means broken. Green and red also count lines added and removed.
import SwiftUI
@@ -12,6 +12,40 @@ enum Theme {
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a
}
extension Text {
/// Text marked as inline code, which Text sets in monospace. A monospaced Font loses to the app's
/// rounded design.
init(code: String) {
var attributed = AttributedString(code)
attributed.inlinePresentationIntent = .code
self.init(attributed)
}
/// "+42 −8 · 3 files" in monospace, the additions green, the deletions red and the rest in the
/// current style. Without `files`, just "+42 −8", as on a changed file's row.
init(changes: ChangeTotals, files: Bool = true) {
let parts = changes.parts
let counts = Text("\(Text(code: parts.additions).foregroundStyle(Theme.add))\(Text(code: " "))\(Text(code: parts.deletions).foregroundStyle(Theme.bad))")
self = files ? Text("\(counts)\(Text(code: " · \(parts.files)"))") : counts
}
}
extension Activity.Kind {
/// The symbol in front of an activity line.
var symbol: String {
switch self {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
}
extension Phase {
+42 -6
View File
@@ -1,5 +1,6 @@
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
// them, the pending approval or question with its options, the thread's subagents, Reply by
// them, the pending approval or question with its options, the thread's subagents, the files its
// latest run changed with their line counts, Reply by
// dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop
// while a run is active. A subagent's request shows first, labeled with the subagent. Message text
// renders as markdown.
@@ -143,11 +144,7 @@ struct ThreadView: View {
}
if !detail.subagents.isEmpty {
Text("Subagents")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
sectionLabel("Subagents")
ForEach(detail.subagents) { row in
ThreadLine(row: row) {
Text(row.statusWord)
@@ -156,6 +153,13 @@ struct ThreadView: View {
}
}
if !detail.changedFiles.isEmpty {
sectionLabel("Changed")
ForEach(detail.changedFiles) { file in
ChangedFileRow(file: file)
}
}
if item.phase == .completed && item.request == nil {
QuickReplies { send($0) }
.disabled(sending)
@@ -196,6 +200,15 @@ struct ThreadView: View {
}
}
/// A small grey title over a list, such as Subagents or Changed.
private func sectionLabel(_ title: String) -> some View {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
}
@ViewBuilder
private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
if request.isQuestion {
@@ -517,6 +530,29 @@ private struct MessageRow: View {
}
}
/// A file the latest run changed: its name in monospace, and its line counts on the right. Read only.
private struct ChangedFileRow: View {
var file: ChangedFile
var body: some View {
Card(edge: Theme.card) {
HStack(spacing: 6) {
Text(code: file.name)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
Text(changes: ChangeTotals(files: 1, additions: file.additions, deletions: file.deletions), files: false)
.lineLimit(1)
.layoutPriority(1)
}
.font(.system(size: 12))
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(file.name), \(file.additions) added, \(file.deletions) removed")
}
}
/// One-tap replies for a finished turn, like mock B's chips.
private struct QuickReplies: View {
var send: (String) -> Void
+5 -1
View File
@@ -1,4 +1,5 @@
// App entry. Notification setup and APNs registration go through the WatchKit app delegate.
// App entry. Notification setup and APNs registration go through the WatchKit app delegate, and
// `input` pushes get their own long look, which sets a question's options as its actions.
import SwiftUI
import WatchKit
@@ -13,6 +14,7 @@ struct WristCodeApp: App {
RootView()
.environment(model)
}
WKNotificationScene(controller: InputNotificationController.self, category: PushCategory.input.rawValue)
}
}
@@ -47,6 +49,8 @@ struct RootView: View {
ProjectsView(hostId: hostId)
case let .projectThreads(hostId, projectId):
ProjectThreadsView(hostId: hostId, projectId: projectId)
case let .search(query):
SearchView(query: query)
}
}
}