Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
gib committed 2026-11-02 14:45:34 -05:00
1 parent 4dc4e57dac
commit edc4b88990
35 files changed
+1162 -144

No files matched your search

+25 -1
View File
@@ -59,6 +59,12 @@ protocol GatewayAPI: Sendable {
func recent() async throws -> RecentResponse
/// Settles every completed thread in Recent. Failed threads stay.
func settleRecent() async throws -> SettleRecentResponse
/// Finished threads snoozed for later, the soonest to come back first.
func snoozed() async throws -> RecentResponse
/// Hides a finished thread from Recent until `until`, at most 30 days away.
func snooze(hostId: String, threadId: String, until: Date) async throws
/// Puts a snoozed thread back in Recent now.
func unsnooze(hostId: String, threadId: String) async throws
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
@@ -131,6 +137,18 @@ struct GatewayClient: GatewayAPI {
try await call("POST", ["v1", "recent", "settle"])
}
func snoozed() async throws -> RecentResponse {
try await call("GET", ["v1", "snoozed"])
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "snooze"], body: SnoozeRequest(until: until))
}
func unsnooze(hostId: String, threadId: String) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "unsnooze"], body: nil as SendMessageRequest?)
}
func projects() async throws -> ProjectsResponse {
try await call("GET", ["v1", "projects"])
}
@@ -173,6 +191,12 @@ struct GatewayClient: GatewayAPI {
// MARK: - Plumbing
/// A failed request as the app sees it. A request cancelled because its task was, as when a
/// screen's `.task` ends or a refresh restarts, is a `CancellationError`, which no screen shows.
static func transportError(_ error: URLError) -> any Error {
error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription)
}
private func call<Response: Decodable>(
_ method: String, _ path: [String], query: [URLQueryItem] = []
) async throws -> Response {
@@ -216,7 +240,7 @@ struct GatewayClient: GatewayAPI {
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError {
throw GatewayError.transport(error.localizedDescription)
throw Self.transportError(error)
}
guard let http = response as? HTTPURLResponse else {
+98 -3
View File
@@ -91,9 +91,19 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
var reason: String?
var options: [Option]
var questions: [Question]
/// On `file-change` approvals, the files the agent wants to change. Older gateways leave it out,
/// which decodes as none.
var files: [PendingFile] = []
var isQuestion: Bool { !questions.isEmpty }
/// What a file-change approval would edit, for its card, thread screen and notification. Nil for
/// other requests and when the gateway names no files, which show the prompt instead.
var fileEdit: FileEdit? {
guard kind == "file-change" || kind == "file_change", !files.isEmpty else { return nil }
return FileEdit(files: files)
}
/// The Allow and Deny pair shown on a feed card, when the server offers both.
var quickOptions: (allow: Option, deny: Option)? {
guard let allow = options.first(where: { $0.decision == .accept }),
@@ -115,6 +125,76 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
}
}
extension PendingRequest {
private enum CodingKeys: String, CodingKey {
case id, kind, prompt, reason, options, questions, files
}
/// `files` is newer than the rest, so a gateway that leaves it out names no files.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
kind: try container.decode(String.self, forKey: .kind),
prompt: try container.decodeIfPresent(String.self, forKey: .prompt),
reason: try container.decodeIfPresent(String.self, forKey: .reason),
options: try container.decode([Option].self, forKey: .options),
questions: try container.decode([Question].self, forKey: .questions),
files: try container.decodeIfPresent([PendingFile].self, forKey: .files) ?? [])
}
}
/// A file a file-change approval would change. `name` is the last part of `path`. The gateway
/// usually has no diff before approval, so the counts are nil and `preview` is empty.
struct PendingFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int?
var deletions: Int?
/// The first few changed lines, each starting with `+` or `-`.
var preview: [String]
var id: String { path }
/// "+9" and "−6", each nil when the gateway did not count it.
var counts: (additions: String?, deletions: String?) {
(additions.map { "+\($0)" }, deletions.map { "\u{2212}\($0)" })
}
}
/// The lines a file-change approval shows in place of its prompt: "Wants to edit" over one file, or
/// "Wants to edit 3 files" over a row for each. One file also shows its preview lines.
struct FileEdit: Hashable, Sendable {
/// A preview line and whether it adds or removes, which colors it green or red.
struct Line: Hashable, Sendable, Identifiable {
enum Change: Sendable { case added, removed, context }
var id: Int
var text: String
var change: Change
}
var files: [PendingFile]
var title: String { files.count == 1 ? "Wants to edit" : "Wants to edit \(files.count) files" }
/// The single file's first changed lines. Empty with several files, as their rows show no diff.
var preview: [Line] {
guard files.count == 1 else { return [] }
return files[0].preview.enumerated().map { index, text in
let change: Line.Change = text.hasPrefix("+") ? .added : text.hasPrefix("-") ? .removed : .context
return Line(id: index, text: text, change: change)
}
}
/// One line for a notification or the Smart Stack: "Wants to edit compose.yml" or "Wants to edit
/// 3 files: compose.yml, Containerfile, health.ts".
var sentence: String {
let names = files.map(\.name).joined(separator: ", ")
return files.count == 1 ? "\(title) \(names)" : "\(title): \(names)"
}
}
extension PendingRequest.Question {
private enum CodingKeys: String, CodingKey {
case id, header, question, options, allowCustomAnswer, multiSelect
@@ -159,6 +239,8 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
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
/// When a snoozed thread comes back to Recent. Nil when it is not snoozed, and from older gateways.
var snoozedUntil: Date? = nil
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
@@ -292,14 +374,20 @@ struct Feed: Codable, Hashable, Sendable {
var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
/// Finished threads snoozed for later, which `GET /v1/snoozed` lists.
var snoozedTotal: Int
var hosts: [HostSummary]
/// `recentTotal` defaults to the threads in `recent`, as when nothing is left out.
init(needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, hosts: [HostSummary]) {
init(
needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, snoozedTotal: Int = 0,
hosts: [HostSummary]
) {
self.needsYou = needsYou
self.working = working
self.recent = recent
self.recentTotal = recentTotal ?? recent.count
self.snoozedTotal = snoozedTotal
self.hosts = hosts
}
@@ -309,10 +397,11 @@ struct Feed: Codable, Hashable, Sendable {
extension Feed {
private enum CodingKeys: String, CodingKey {
case needsYou, working, recent, recentTotal, hosts
case needsYou, working, recent, recentTotal, snoozedTotal, hosts
}
/// `recentTotal` is newer than the rest, so a gateway that leaves it out has nothing past `recent`.
/// `recentTotal` and `snoozedTotal` are newer than the rest, so a gateway that leaves them out has
/// nothing past `recent` and nothing snoozed.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
@@ -320,6 +409,7 @@ extension Feed {
working: try container.decode([FeedItem].self, forKey: .working),
recent: try container.decode([FeedItem].self, forKey: .recent),
recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal),
snoozedTotal: try container.decodeIfPresent(Int.self, forKey: .snoozedTotal) ?? 0,
hosts: try container.decode([HostSummary].self, forKey: .hosts))
}
}
@@ -329,6 +419,11 @@ struct RecentResponse: Codable, Hashable, Sendable {
var items: [FeedItem]
}
/// `POST /v1/threads/:hostId/:threadId/snooze`. `until` is in the future and at most 30 days away.
struct SnoozeRequest: Codable, Sendable {
var until: Date
}
/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline.
struct SettleRecentResponse: Codable, Hashable, Sendable {
var settled: Int
+8 -1
View File
@@ -7,6 +7,8 @@ enum Route: Hashable, Sendable {
case newThread
/// Every thread in Recent, past the feed's first 10.
case recent
/// Finished threads snoozed for later.
case snoozed
/// The status overview, and from it one host or the notifications page.
case status
case host(id: String)
@@ -20,7 +22,8 @@ enum Route: Hashable, Sendable {
static let scheme = "wristcode"
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`,
/// `wristcode://status`,
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
/// `wristcode://projects/<hostId>`, `wristcode://project/<hostId>/<projectId>` or `wristcode://search?q=<query>`.
var url: URL {
@@ -37,6 +40,8 @@ enum Route: Hashable, Sendable {
components.host = "new"
case .recent:
components.host = "recent"
case .snoozed:
components.host = "snoozed"
case .status:
components.host = "status"
case let .host(id):
@@ -72,6 +77,8 @@ enum Route: Hashable, Sendable {
self = .newThread
case "recent":
self = .recent
case "snoozed":
self = .snoozed
case "status":
self = .status
case "host" where parts.count == 1 && !parts[0].isEmpty:
+100 -20
View File
@@ -1,5 +1,6 @@
// App-wide state: whether the watch is paired, the navigation path, the latest feed, and the hosts
// and projects All threads browses. It also
// App-wide state: whether the watch is paired, the navigation path, the latest feed, the hosts
// and projects All threads browses, and the thread being snoozed. The last good feed is saved to
// disk, so launch shows it at once while the first refresh runs. It also
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
// group and reloads the complication's timeline when they change.
@@ -27,6 +28,8 @@ final class AppModel {
var path: [Route] = []
private(set) var feed: Feed?
private(set) var feedError: String?
/// True while the feed on screen came from the cache and the first refresh has not answered.
private(set) var feedIsCached = false
/// Ids of feed items whose settle call is still out. The feed hides them meanwhile.
private(set) var settling: Set<String> = []
/// Why settling an item failed, by item id. Its row shows the message.
@@ -41,20 +44,35 @@ final class AppModel {
/// Hosts and their projects for All threads, loaded when its screens appear.
private(set) var projects: ProjectsResponse?
private(set) var projectsError: String?
/// Snoozed threads, for the Snoozed screen, which loads them when it appears.
private(set) var snoozed: [FeedItem]?
private(set) var snoozedError: String?
/// The thread the Snooze until picker is open for.
var snoozing: FeedItem?
/// Where the feed and thread details are saved. Only used while paired, never in demo mode.
private let cache: FeedCache?
private var apnsToken: String?
private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
/// With a `cache` and no `feed`, a paired model starts on the saved feed.
init(
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, projects: ProjectsResponse? = nil,
path: [Route] = []
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil,
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil
) {
self.mode = mode
self.feed = feed
self.cache = cache
self.allRecent = allRecent
self.snoozed = snoozed
self.projects = projects
self.path = path
if feed == nil, case .paired = mode, let saved = cache?.feed() {
self.feed = saved
feedIsCached = true
} else {
self.feed = feed
}
}
/// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-host <hostId>` or
@@ -84,7 +102,7 @@ final class AppModel {
let parts = thread.split(separator: "/", maxSplits: 1).map(String.init)
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
}
self.init(mode: mode, path: path)
self.init(mode: mode, path: path, cache: .standard)
shareMode()
}
@@ -93,6 +111,9 @@ final class AppModel {
return false
}
/// The cache while paired. Demo mode keeps off it.
private var pairedCache: FeedCache? { isPaired ? cache : nil }
var api: any GatewayAPI {
switch mode {
case let .paired(credentials):
@@ -112,27 +133,32 @@ final class AppModel {
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
DeviceStore.save(credentials)
mode = .paired(credentials)
feed = nil
feedError = nil
projects = nil
path = []
resetContent()
await enableNotifications()
}
func startDemo() {
mode = .demo
feed = nil
feedError = nil
projects = nil
path = []
resetContent()
}
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept.
/// Back to the pairing screen. A real token and the saved feed and threads are forgotten; the
/// gateway URL is kept.
func signOut() {
if isPaired { DeviceStore.clearToken() }
cache?.clear()
mode = .unpaired
resetContent()
}
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out.
private func resetContent() {
feed = nil
feedError = nil
feedIsCached = false
allRecent = nil
snoozed = nil
snoozing = nil
projects = nil
path = []
}
@@ -151,8 +177,9 @@ final class AppModel {
}
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
/// A cancelled call, as when a screen's `.task` ends or a refresh restarts, returns nil too.
func message(for error: any Error) -> String? {
if error is CancellationError { return nil }
if error is CancellationError || (error as? URLError)?.code == .cancelled { return nil }
if let error = error as? GatewayError, error == .unauthorized {
signOut()
return nil
@@ -170,12 +197,27 @@ final class AppModel {
let feed = try await api.feed()
self.feed = feed
feedError = nil
feedIsCached = false
share(Summary(feed))
pairedCache?.save(feed)
} catch {
feedError = message(for: error)
// A cancelled refresh changes nothing; the next one answers.
guard let message = message(for: error) else { return }
feedError = message
feedIsCached = false
}
}
/// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode.
func cachedThread(hostId: String, threadId: String) -> ThreadDetail? {
pairedCache?.thread(hostId: hostId, threadId: threadId)
}
/// Saves a thread's detail for the next time it opens.
func saveThread(_ detail: ThreadDetail) {
pairedCache?.save(detail)
}
/// Settles items concurrently. Each one leaves the feed at once and comes back with its error
/// if its call fails. Refreshes once every call has answered.
func settle(_ items: [FeedItem]) async {
@@ -221,7 +263,7 @@ final class AppModel {
allRecent = try await api.recent().items
allRecentError = nil
} catch {
allRecentError = message(for: error)
if let message = message(for: error) { allRecentError = message }
}
}
@@ -244,6 +286,44 @@ final class AppModel {
if allRecent != nil { await loadAllRecent() }
}
// MARK: - Snooze
/// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker
/// closes, and its screen does too if it is on top. Then the feed refreshes.
func snooze(_ item: FeedItem, until: Date) async throws {
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
snoozing = nil
removeFromRecent(1) { $0.id == item.id }
feed?.snoozedTotal += 1
if path.last == item.route { path.removeLast() }
await refreshFeed()
}
/// `GET /v1/snoozed`, for the Snoozed screen.
func loadSnoozed() async {
guard mode != .unpaired else { return }
do {
snoozed = try await api.snoozed().items
snoozedError = nil
} catch {
if let message = message(for: error) { snoozedError = message }
}
}
/// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and
/// the list refresh. On failure the list shows why.
func unsnooze(_ item: FeedItem) async {
snoozed?.removeAll { $0.id == item.id }
do {
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
snoozedError = nil
} catch {
snoozedError = message(for: error)
}
await refreshFeed()
await loadSnoozed()
}
/// Drops settled threads from the feed and the full list until the next refresh confirms it.
private func removeFromRecent(_ count: Int, where settled: (FeedItem) -> Bool) {
allRecent?.removeAll(where: settled)
@@ -262,7 +342,7 @@ final class AppModel {
projects = try await api.projects()
projectsError = nil
} catch {
projectsError = message(for: error)
if let message = message(for: error) { projectsError = message }
}
}
@@ -343,6 +423,6 @@ extension AppModel {
static func preview(path: [Route] = []) -> AppModel {
AppModel(
mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(),
projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
snoozed: DemoFixtures.previewSnoozed(), projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
}
}
+91 -14
View File
@@ -1,7 +1,8 @@
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
// 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.
// hides a thread from the feed, snoozing moves it from Recent to Snoozed, 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. Search matches
// titles and messages on the online hosts.
@@ -43,7 +44,7 @@ actor DemoGateway: GatewayAPI {
}
func feed() async throws -> Feed {
DemoFixtures.feed(unsettled, hosts: hosts)
DemoFixtures.feed(unsettled, hosts: hosts, now: .now)
}
func summary() async throws -> Summary {
@@ -129,15 +130,32 @@ actor DemoGateway: GatewayAPI {
}
func recent() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.recent(unsettled))
RecentResponse(items: DemoFixtures.recent(unsettled, now: .now))
}
func settleRecent() async throws -> SettleRecentResponse {
let completed = DemoFixtures.recent(unsettled).filter { $0.phase == .completed }
let completed = DemoFixtures.recent(unsettled, now: .now).filter { $0.phase == .completed }
settled.formUnion(completed.map(\.id))
return SettleRecentResponse(settled: completed.count, failed: 0)
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.snoozed(unsettled, now: .now))
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try update(hostId, threadId) { item in
guard item.phase == .completed || item.phase == .failed else {
throw GatewayError.http(status: 409, message: "Only a finished thread can be snoozed.")
}
item.snoozedUntil = until
}
}
func unsnooze(hostId: String, threadId: String) async throws {
try update(hostId, threadId) { $0.snoozedUntil = nil }
}
func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts)
}
@@ -292,6 +310,31 @@ struct DemoFixtures {
],
questions: []))
// File approvals: one with the diff the gateway rarely has before approval, one with the name
// only, which is what Claude sends.
func fileApproval(_ id: String, _ files: [PendingFile]) -> PendingRequest {
PendingRequest(
id: id, kind: "file-change", prompt: "Edit: \(files[0].path)", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: [], files: files)
}
let compose = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-compose", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway restart policy", phase: .waitingForApproval,
updatedAt: ago(2), lastMessage: "Switching the gateway to the Alpine image and restarting it on failure.",
request: fileApproval("r-compose", [
PendingFile(
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
preview: ["- image: oven/bun:1", "+ image: oven/bun:1-alpine", "+ restart: always"]),
]))
let healthRoute = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-health-route", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Health route", phase: .waitingForApproval,
updatedAt: ago(0.8), lastMessage: "Adding a /healthz route the container check can call.",
request: fileApproval("r-health", [
PendingFile(path: "gateway/src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: []),
]))
let question = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
@@ -408,6 +451,15 @@ struct DemoFixtures {
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
]
// Finished threads snoozed for later: one back in a few hours, one tomorrow morning.
let calendar = Calendar.current
let later = calendar.nextDate(after: now.addingTimeInterval(2 * 3600), matching: DateComponents(minute: 0), matchingPolicy: .nextTime)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))?.addingTimeInterval(9 * 3600)
var dependabot = older(mac, "t-deps", "gbrown.org", "Dependabot bumps", hours: 3)
dependabot.snoozedUntil = later
var plex = older(server, "t-plex", "homelab", "Plex transcoding", hours: 8)
plex.snoozedUntil = tomorrow
// Settled threads, which only the project's thread list shows.
let settledThreads = [
older(mac, "t-switches", "wristcode", "Phase 2 switches", hours: 26),
@@ -428,8 +480,8 @@ struct DemoFixtures {
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
items = ([approval, compose, healthRoute, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + [dependabot, plex] + settledThreads + [path, image, healthcheck]).map { item in
guard let files = changed[item.id] else { return item }
var item = item
item.changes = ChangeTotals(
@@ -572,20 +624,40 @@ struct DemoFixtures {
}
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
/// newest first, and only the first 10 of Recent.
static func feed(_ items: [FeedItem], hosts: [HostSummary]) -> Feed {
let recent = recent(items)
/// newest first, only the first 10 of Recent, and a count of the snoozed.
static func feed(_ items: [FeedItem], hosts: [HostSummary], now: Date) -> Feed {
let recent = recent(items, now: now)
return Feed(
needsYou: items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt },
working: items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt },
recent: Array(recent.prefix(10)),
recentTotal: recent.count,
snoozedTotal: snoozed(items, now: now).count,
hosts: hosts)
}
/// Every finished or failed thread, newest first, as `GET /v1/recent` lists them.
static func recent(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }.sorted { $0.updatedAt > $1.updatedAt }
/// Every finished or failed thread not snoozed, newest first, as `GET /v1/recent` lists them. A
/// snooze whose time has come no longer counts.
static func recent(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) <= now }
.map { item in
var item = item
item.snoozedUntil = nil
return item
}
.sorted { $0.updatedAt > $1.updatedAt }
}
/// Finished threads still snoozed, the soonest to come back first, as `GET /v1/snoozed` lists them.
static func snoozed(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) > now }
.sorted { $0.snoozedUntil! < $1.snoozedUntil! }
}
private static func finished(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }
}
/// Threads the feed lists: not settled, and not a subagent.
@@ -597,11 +669,16 @@ struct DemoFixtures {
/// A feed for previews, without going through the actor.
static func previewFeed(now: Date = .now) -> Feed {
let fixtures = DemoFixtures(now: now)
return feed(fixtures.listed, hosts: fixtures.hosts)
return feed(fixtures.listed, hosts: fixtures.hosts, now: now)
}
/// The full Recent list for previews.
static func previewRecent(now: Date = .now) -> [FeedItem] {
recent(DemoFixtures(now: now).listed)
recent(DemoFixtures(now: now).listed, now: now)
}
/// The Snoozed list for previews.
static func previewSnoozed(now: Date = .now) -> [FeedItem] {
snoozed(DemoFixtures(now: now).listed, now: now)
}
}
+49
View File
@@ -0,0 +1,49 @@
// The last good feed, and the last detail of each thread opened, saved as JSON in the app's caches
// folder. Launch shows them at once while the first refresh runs. Signing out clears them, and demo
// mode never reads or writes them.
import Foundation
struct FeedCache: Sendable {
let directory: URL
static let standard = FeedCache(directory: .cachesDirectory.appending(path: "WristCode", directoryHint: .isDirectory))
func feed() -> Feed? {
read(Feed.self, "feed.json")
}
func save(_ feed: Feed) {
write(feed, "feed.json")
}
func thread(hostId: String, threadId: String) -> ThreadDetail? {
read(ThreadDetail.self, Self.threadFile(hostId, threadId))
}
func save(_ detail: ThreadDetail) {
write(detail, Self.threadFile(detail.item.hostId, detail.item.threadId))
}
/// Deletes everything saved, as when the watch unpairs.
func clear() {
try? FileManager.default.removeItem(at: directory)
}
/// `thread-<hostId>-<threadId>.json`, with the ids percent-encoded so any id is a safe file name.
private static func threadFile(_ hostId: String, _ threadId: String) -> String {
let safe = { (id: String) in id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id }
return "thread-\(safe(hostId))-\(safe(threadId)).json"
}
private func read<Value: Decodable>(_ type: Value.Type, _ name: String) -> Value? {
guard let data = try? Data(contentsOf: directory.appending(path: name)) else { return nil }
return try? JSON.decoder().decode(type, from: data)
}
private func write(_ value: some Encodable, _ name: String) {
guard let data = try? JSON.encoder().encode(value) else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try? data.write(to: directory.appending(path: name), options: .atomic)
}
}
@@ -1,6 +1,6 @@
// 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.
// Reply, Settle 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
@@ -18,6 +18,7 @@ enum NotificationCommand: Equatable, Sendable {
/// 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 settle(hostId: String, threadId: String)
case open(Route?)
case none
@@ -25,6 +26,7 @@ enum NotificationCommand: Equatable, Sendable {
static let allow = "allow"
static let deny = "deny"
static let reply = "reply"
static let settle = "settle"
static let open = "open"
/// Opens the thread, on a question the notification cannot answer itself.
static let answer = "answer"
@@ -64,6 +66,9 @@ 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.settle:
guard let hostId, let threadId else { self = .open(thread); return }
self = .settle(hostId: hostId, threadId: threadId)
case Action.say:
self = answer(text)
case let id where id.hasPrefix(Action.optionPrefix):
@@ -110,6 +115,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
identifier: Action.reply, title: "Reply", options: [],
textInputButtonTitle: "Send", textInputPlaceholder: "Reply")
let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground])
let settle = UNNotificationAction(identifier: Action.settle, title: "Settle", options: [])
func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory {
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
@@ -118,7 +124,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
category(.approval, [allow, deny]),
// A question push replaces these with its options; see `inputActions`.
category(.input, [answer, open]),
category(.finished, [reply, open]),
category(.finished, [reply, settle, open]),
category(.failed, [open]),
category(.host, []),
category(.test, []),
@@ -180,6 +186,11 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
await attempt("Couldn't send your reply") {
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
}
case let .settle(hostId, threadId):
guard model.mode != .unpaired else { return }
await attempt("Couldn't settle") {
try await model.api.settle(hostId: hostId, threadId: threadId)
}
}
model.reloadComplication()
await model.refreshFeed()
+8 -16
View File
@@ -104,7 +104,7 @@ struct ProjectThreadsView: View {
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
error = nil
} catch {
self.error = model.message(for: error)
if let message = model.message(for: error) { self.error = message }
}
}
}
@@ -166,7 +166,7 @@ struct SearchView: View {
response = try await model.api.search(query)
error = nil
} catch {
self.error = model.message(for: error)
if let message = model.message(for: error) { self.error = message }
}
}
}
@@ -276,8 +276,9 @@ struct ThreadLine<Trailing: View>: View {
}
}
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
/// `header` shows above the rows even while they load.
/// The List each browse screen scrolls, with Loading, an error and Try again. Loads on appear.
/// `header` shows above the rows even while they load. Once rows show, a failed reload only adds a
/// dim line under them.
private struct BrowseList<Header: View, Rows: View>: View {
var scrolls: Bool
var error: String?
@@ -302,19 +303,10 @@ private struct BrowseList<Header: View, Rows: View>: View {
header
if loaded {
rows
} else if error == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 20)
.feedRow()
}
if let error {
BrowseNote(text: error)
if !loaded {
Button("Try again") { Task { await reload() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
.feedRow()
}
if !loaded || error != nil {
LoadStatus(loaded: loaded, error: error, retry: reload)
.feedRow()
}
}
}
+39 -14
View File
@@ -1,8 +1,10 @@
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
// (with Allow and Deny on the card), then working (with the step the agent is on and how long it
// has run), then recent. A Recent row swipes left to settle, and the Recent header settles every
// completed thread. Past Recent's first 10, an "N more" row opens the full Recent list. The last
// row opens All threads.
// (with Allow and Deny on the card, and the files a file approval would edit), then working (with
// the step the agent is on and how long it has run), then recent. A Recent row swipes left to settle
// or snooze, and the Recent header settles every completed thread. Past Recent's first 10, an "N
// more" row opens the full Recent list, and a violet row under it opens the snoozed threads. The last
// row opens All threads. At launch the feed saved last time shows at once, marked Updating until the
// first refresh answers.
import SwiftUI
@@ -13,7 +15,7 @@ struct FeedView: View {
var body: some View {
List {
FeedHeader(feed: model.feed, error: model.feedError)
FeedHeader(feed: model.feed, error: model.feedError, cached: model.feedIsCached)
.feedRow()
if let feed = model.feed {
@@ -34,6 +36,9 @@ struct FeedView: View {
if more > 0 {
MoreRecentRow(count: more).feedRow()
}
if feed.snoozedTotal > 0 {
SnoozedRow(count: feed.snoozedTotal).feedRow()
}
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 {
Text("No threads right now.")
.font(.system(size: 13))
@@ -87,18 +92,28 @@ extension View {
}
}
/// The big count of threads waiting on you, plus offline hosts and refresh errors.
private struct FeedHeader: View {
/// The big count of threads waiting on you, plus offline hosts and refresh errors. A feed from the
/// cache says Updating beside the count until the first refresh answers.
struct FeedHeader: View {
var feed: Feed?
var error: String?
var cached = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
if let feed {
let waiting = feed.needsYou.count
Text("\(waiting)")
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
HStack(alignment: .firstTextBaseline) {
Text("\(waiting)")
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
Spacer(minLength: 4)
if cached {
Text("Updating")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.done)
}
}
Text(subtitle(feed))
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
@@ -166,7 +181,8 @@ struct RecentHeader: View {
}
}
/// A Recent thread: its card, which opens the thread, and a left swipe to settle it.
/// A Recent thread: its card, which opens the thread, and a left swipe to settle it or to snooze it,
/// which opens the Snooze until picker.
struct RecentRow: View {
@Environment(AppModel.self) private var model
var item: FeedItem
@@ -174,10 +190,15 @@ struct RecentRow: View {
var body: some View {
StatusCard(item: item)
.swipeActions(edge: .trailing) {
// The first button sits at the edge, and a full swipe runs it.
Button { Task { await model.settle([item]) } } label: {
Label("Settle", systemImage: "checkmark")
}
.tint(Theme.work)
Button { model.snoozing = item } label: {
Label("Snooze", systemImage: "moon.fill")
}
.tint(Theme.snooze)
}
}
}
@@ -232,7 +253,8 @@ struct AllThreadsRow: View {
}
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
/// When a subagent asked, one line names it, and the answer goes to the subagent.
/// When a subagent asked, one line names it, and the answer goes to the subagent. A file approval
/// shows "Wants to edit" and the files in place of its prompt.
struct NeedsYouCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
@@ -252,13 +274,16 @@ struct NeedsYouCard: View {
.lineLimit(2)
if let via = item.via, let request = item.request {
let name = Text(via.title).bold().foregroundStyle(.white)
Text(request.isQuestion ? "Subagent \(name) asks:" : "Subagent \(name) wants to run:")
Text(request.isQuestion ? "Subagent \(name) asks:" : request.fileEdit != nil ? "Subagent \(name):" : "Subagent \(name) wants to run:")
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 2)
}
if let request = item.request {
if let edit = item.request?.fileEdit {
FileEditRows(edit: edit, limit: 3)
.padding(.top, 2)
} else if let request = item.request {
Text(request.summary)
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
.foregroundStyle(Theme.secondaryText)
+72
View File
@@ -0,0 +1,72 @@
// What a file approval would edit, shown on the feed card and the thread screen in place of the raw
// prompt. Claude sends no diff before approval, so most show the file name alone.
import SwiftUI
/// "Wants to edit" over one file, or "Wants to edit 3 files" over a row for each: the name in
/// monospace and, when the gateway counted them, the lines added and removed on the right. On the
/// thread screen the title is amber and one file's first changed lines follow, removed in red and
/// added in green. Past `limit` files, one line counts the rest.
struct FileEditRows: View {
var edit: FileEdit
var limit = Int.max
var inThread = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(edit.title)
.font(.system(size: inThread ? 11 : 12, weight: inThread ? .semibold : .regular))
.foregroundStyle(inThread ? Theme.you : Theme.secondaryText)
ForEach(edit.files.prefix(limit)) { file in
HStack(spacing: 6) {
Text(code: file.name)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
if let counts = counts(file) {
counts
.lineLimit(1)
.layoutPriority(1)
}
}
.font(.system(size: 12))
}
if edit.files.count > limit {
Text("\(edit.files.count - limit) more")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
if inThread, !edit.preview.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(edit.preview) { line in
Text(code: line.text)
.foregroundStyle(color(line.change))
.lineLimit(1)
}
}
.font(.system(size: 11))
.padding(.top, 4)
}
}
.accessibilityElement(children: .combine)
}
/// "+9 −6" in monospace, green and red, leaving out a count the gateway did not send.
private func counts(_ file: PendingFile) -> Text? {
let parts = [
file.counts.additions.map { Text(code: $0).foregroundStyle(Theme.add) },
file.counts.deletions.map { Text(code: $0).foregroundStyle(Theme.bad) },
].compactMap(\.self)
guard let first = parts.first else { return nil }
return parts.dropFirst().reduce(first) { Text("\($0)\(Text(code: " "))\($1)") }
}
private func color(_ change: FileEdit.Line.Change) -> Color {
switch change {
case .added: Theme.add
case .removed: Theme.bad
case .context: Theme.secondaryText
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// How every screen that loads data shows it: a static "Loading" before the first answer, the error
// and Try again when there is nothing to show, and once content is on screen, a small dim line under
// it when a refresh fails. Content is never replaced by an error.
import SwiftUI
/// The placeholder before a screen's first answer, the same as the feed's. Static, with no spinner.
struct LoadingText: View {
var body: some View {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.top, 4)
}
}
/// What a screen shows under its content about loading. `loaded` is true once content is on screen.
/// Before then: Loading, or the error with Try again when `retry` is set. After: only a failed
/// refresh's error, small and dim.
struct LoadStatus: View {
var loaded: Bool
var error: String?
var retry: (() async -> Void)?
var body: some View {
if let error {
if loaded {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.done)
.lineLimit(3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
} else {
VStack(alignment: .leading, spacing: 8) {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
if let retry {
Button("Try again") { Task { await retry() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} else if !loaded {
LoadingText()
}
}
}
+2 -10
View File
@@ -106,16 +106,8 @@ struct NewThreadView: View {
.foregroundStyle(Theme.secondaryText)
}
}
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
LoadStatus(loaded: projects != nil, error: error, retry: load)
}
.padding(.horizontal, 4)
}
@@ -189,7 +181,7 @@ struct NewThreadView: View {
}
error = nil
} catch {
self.error = model.message(for: error)
if let message = model.message(for: error) { self.error = message }
}
}
+4 -16
View File
@@ -1,5 +1,6 @@
// The full Recent list, opened from the feed's "N more" row. It loads `GET /v1/recent` when it
// appears; nothing polls. Rows look and swipe like the feed's, and the header has Settle all.
// appears; nothing polls. Rows look and swipe like the feed's, to settle or snooze, and the header
// has Settle all.
import SwiftUI
@@ -31,23 +32,10 @@ struct RecentView: View {
ForEach(items) { item in
RecentRow(item: item).feedRow()
}
} else if model.allRecentError == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 20)
.feedRow()
}
if let error = model.allRecentError {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
if model.allRecent == nil || model.allRecentError != nil {
LoadStatus(loaded: model.allRecent != nil, error: model.allRecentError, retry: model.loadAllRecent)
.feedRow()
if model.allRecent == nil {
Button("Try again") { Task { await model.loadAllRecent() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
.feedRow()
}
}
}
}
+236
View File
@@ -0,0 +1,236 @@
// Snoozing a finished thread hides it from Recent until a time picked from four fixed choices, and
// the gateway brings it back on its own. A Recent row's swipe and the thread screen's Snooze button
// open the same picker. When any thread is snoozed, a dim violet row under Recent counts them and
// opens the Snoozed list, where each card says when it wakes and swipes to Unsnooze.
import SwiftUI
/// One of the picker's times, worked out in the watch's time zone.
struct SnoozeChoice: Hashable, Identifiable {
var title: String
var until: Date
var id: String { title }
/// In 1 hour, This evening at 6 PM (left out from 5 PM on), Tomorrow at 9 AM, and Next week,
/// which is next Monday at 9 AM.
static func choices(now: Date = .now, calendar: Calendar = .current) -> [SnoozeChoice] {
let today = calendar.startOfDay(for: now)
func at(_ hour: Int, daysAfter days: Int) -> Date? {
calendar.date(byAdding: .day, value: days, to: today)
.flatMap { calendar.date(bySettingHour: hour, minute: 0, second: 0, of: $0) }
}
var choices = [SnoozeChoice(title: "In 1 hour", until: now.addingTimeInterval(3600))]
if calendar.component(.hour, from: now) < 17, let evening = at(18, daysAfter: 0) {
choices.append(SnoozeChoice(title: "This evening · 6 PM", until: evening))
}
if let tomorrow = at(9, daysAfter: 1) {
choices.append(SnoozeChoice(title: "Tomorrow · 9 AM", until: tomorrow))
}
// Days until the next Monday, a full week when today is Monday. Weekday 2 is Monday.
let days = (9 - calendar.component(.weekday, from: now)) % 7
if let monday = at(9, daysAfter: days == 0 ? 7 : days) {
choices.append(SnoozeChoice(title: "Next week", until: monday))
}
return choices
}
}
extension Format {
/// When a snoozed thread comes back: "Wakes at 6 PM" today, "Wakes tomorrow 9 AM", "Wakes Monday
/// 9 AM" within the week, and "Wakes Nov 30, 9 AM" past that.
static func wakes(at date: Date, now: Date = .now, calendar: Calendar = .current) -> String {
let style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), calendar: calendar, timeZone: calendar.timeZone)
// "6:00 PM" becomes "6 PM". Some formatters put a narrow space before PM.
let time = date.formatted(style.hour(.defaultDigits(amPM: .abbreviated)).minute())
.replacingOccurrences(of: ":00", with: "")
.replacingOccurrences(of: "\u{202F}", with: " ")
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date)).day ?? 0
switch days {
case ...0: return "Wakes at \(time)"
case 1: return "Wakes tomorrow \(time)"
case 2 ..< 7:
return "Wakes \(date.formatted(style.weekday(.wide))) \(time)"
default:
let day = date.formatted(style.month(.abbreviated).day())
return "Wakes \(day), \(time)"
}
}
}
/// "Snooze until" and the four choices. Picking one snoozes the thread; on success the picker closes,
/// and on failure it stays and says why.
struct SnoozePicker: View {
@Environment(AppModel.self) private var model
var item: FeedItem
/// Fixed for snapshots. Live, the choices are worked out when the picker opens.
var now: Date = .now
/// Snapshots render the choices without their ScrollView, which ImageRenderer leaves blank.
var scrolls = true
@State private var sending = false
@State private var error: String?
var body: some View {
if scrolls {
ScrollView { choices }
.navigationTitle("Snooze until")
} else {
choices
}
}
private var choices: some View {
VStack(spacing: 6) {
ForEach(SnoozeChoice.choices(now: now)) { choice in
Button(choice.title) { snooze(until: choice.until) }
.buttonStyle(PillButtonStyle(fill: Theme.card, foreground: .white))
}
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
}
}
.disabled(sending)
.opacity(sending ? 0.5 : 1)
.padding(.horizontal, 4)
}
private func snooze(until: Date) {
sending = true
error = nil
Task {
do {
try await model.snooze(item, until: until)
} catch {
self.error = model.message(for: error)
}
sending = false
}
}
}
/// The dim violet row under Recent, "☾ 2 snoozed", opening the Snoozed list.
struct SnoozedRow: View {
@Environment(AppModel.self) private var model
var count: Int
var body: some View {
Button { model.path.append(.snoozed) } label: {
Card(edge: Theme.snooze) {
HStack(spacing: 5) {
Image(systemName: "moon.fill")
.font(.system(size: 11, weight: .bold))
Text("\(count) snoozed")
.font(.system(size: 13, weight: .semibold))
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Theme.snooze)
}
.opacity(0.85)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(count) snoozed threads")
}
}
/// Every snoozed thread, the soonest to wake first. It loads `GET /v1/snoozed` when it appears;
/// nothing polls.
struct SnoozedView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
var scrolls = true
var body: some View {
Group {
if scrolls {
List { rows }
.listStyle(.plain)
.refreshable { await model.loadSnoozed() }
.task { await model.loadSnoozed() }
} else {
VStack(spacing: 6) { rows }
.padding(.horizontal, 4)
}
}
.navigationTitle("Snoozed")
}
@ViewBuilder private var rows: some View {
if let items = model.snoozed {
ForEach(items) { item in
SnoozedCard(item: item).feedRow()
}
if items.isEmpty {
Text("Nothing snoozed.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
.feedRow()
}
}
if model.snoozed == nil || model.snoozedError != nil {
LoadStatus(loaded: model.snoozed != nil, error: model.snoozedError, retry: model.loadSnoozed)
.feedRow()
}
}
}
/// A snoozed thread: when it wakes in violet, its title, and where it runs. Opens the thread, and a
/// left swipe unsnoozes it.
private struct SnoozedCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
var body: some View {
Button { model.path.append(item.route) } label: {
Card(edge: Theme.snooze) {
if let until = item.snoozedUntil {
Text(Format.wakes(at: until))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.snooze)
.lineLimit(1)
}
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
Text("\(item.hostLabel) · \(item.projectTitle)")
.font(.system(size: 11))
.foregroundStyle(Theme.done)
.lineLimit(1)
.padding(.top, 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button { Task { await model.unsnooze(item) } } label: {
Label("Unsnooze", systemImage: "sun.max.fill")
}
.tint(Theme.snooze)
}
}
}
#Preview("Snoozed") {
NavigationStack {
SnoozedView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Snooze until") {
NavigationStack {
SnoozePicker(item: DemoFixtures.previewRecent()[0])
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+2 -15
View File
@@ -486,21 +486,8 @@ private struct StatusLoader<Content: View>: View {
VStack(alignment: .leading, spacing: 6) {
if let status {
content(status)
} else if error == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 20)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 4)
if status == nil {
Button("Try again") { Task { await load() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
}
}
LoadStatus(loaded: status != nil, error: error, retry: load)
}
.padding(.horizontal, 2)
}
@@ -510,7 +497,7 @@ private struct StatusLoader<Content: View>: View {
status = try await model.api.status()
error = nil
} catch {
self.error = model.message(for: error)
if let message = model.message(for: error) { self.error = message }
}
}
}
+2 -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. Green and red also count lines added and removed.
// red means broken, and violet means snoozed. Green and red also count lines added and removed.
import SwiftUI
@@ -13,6 +13,7 @@ enum Theme {
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
static let snooze = Color(red: 0.608, green: 0.549, blue: 1.0) // #9b8cff
}
extension Text {
+19 -18
View File
@@ -1,9 +1,10 @@
// 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 under a header
// that collapses them, 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.
// dictation or Scribble, Settle and Snooze 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. A file
// approval names the files and shows the first changed lines when the gateway has them. Message
// text renders as markdown. The detail saved when the thread last loaded shows while it loads again.
import SwiftUI
@@ -63,16 +64,8 @@ struct ThreadView: View {
VStack(alignment: .leading, spacing: 8) {
if let detail {
content(detail)
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
LoadStatus(loaded: detail != nil, error: error)
}
.padding(.horizontal, 4)
}
@@ -95,7 +88,10 @@ struct ThreadView: View {
}
}
.navigationTitle(detail?.item.title ?? "Thread")
.onAppear { visible = true }
.onAppear {
if detail == nil { detail = model.cachedThread(hostId: hostId, threadId: threadId) }
visible = true
}
.onDisappear { visible = false }
.task(id: visible && scenePhase == .active) {
guard visible && scenePhase == .active else { return }
@@ -183,10 +179,12 @@ struct ThreadView: View {
.disabled(sending)
if settleable {
Button { settle() } label: {
Label("Settle", systemImage: "checkmark")
HStack(spacing: 6) {
Button("Settle") { settle() }
.buttonStyle(PillButtonStyle(fill: Theme.work))
Button("Snooze") { model.snoozing = item }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.snooze))
}
.buttonStyle(PillButtonStyle(fill: Theme.work))
.disabled(sending)
}
@@ -272,7 +270,9 @@ struct ThreadView: View {
private func approval(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
Card(edge: Theme.you) {
subagentLabel(via)
if let prompt = request.prompt {
if let edit = request.fileEdit {
FileEditRows(edit: edit, inThread: true)
} else if let prompt = request.prompt {
Text(prompt)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.white)
@@ -393,8 +393,9 @@ struct ThreadView: View {
if let detail { history.keep(detail.messages) }
detail = fresh
error = nil
model.saveThread(fresh)
} catch {
self.error = model.message(for: error)
if let message = model.message(for: error) { self.error = message }
}
}
+9 -1
View File
@@ -1,5 +1,6 @@
// 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.
// `input` pushes get their own long look, which sets a question's options as its actions. The Snooze
// until picker opens over every screen, from a Recent row or the thread screen.
import SwiftUI
import WatchKit
@@ -37,6 +38,8 @@ struct RootView: View {
NewThreadView()
case .recent:
RecentView()
case .snoozed:
SnoozedView()
case .status:
StatusView()
case let .host(id):
@@ -54,6 +57,11 @@ struct RootView: View {
}
}
}
.sheet(item: $model.snoozing) { item in
NavigationStack {
SnoozePicker(item: item)
}
}
}
}
.fontDesign(.rounded)