Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications
This commit is contained in:
1 parent
f8241e42bb
commit
bfcbb69550
29 files changed
+1100
-137
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user