diff --git a/gateway/src/notify.ts b/gateway/src/notify.ts index 9706ac8..e65156b 100644 --- a/gateway/src/notify.ts +++ b/gateway/src/notify.ts @@ -4,7 +4,7 @@ // a host loads its first snapshot, so a restart never replays old news. import { threadPhase, type Phase } from "./awareness.ts"; -import type { PushCategory, PushPayload } from "./contract.ts"; +import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts"; import type { HostRow } from "./db.ts"; import type { T3Host, ThreadChange } from "./t3/host.ts"; import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts"; @@ -139,12 +139,32 @@ function category(phase: Phase): PushCategory { } } +/** + * An approval push's body: the files a file-change approval would edit, as "Wants to edit + * compose.yml +9 −6" or "Wants to edit 3 files: a.ts, b.ts, c.ts", or else the command it wants to + * run. The watch's cards word it the same way. + */ +export function approvalBody(request: PendingRequest | null): string { + const files = request?.kind === "file-change" ? request.files : []; + const [file] = files; + if (files.length === 1 && file) { + const counts = [ + file.additions === null ? null : `+${file.additions}`, + file.deletions === null ? null : `\u2212${file.deletions}`, + ].filter((count) => count !== null); + return trimText(["Wants to edit", file.name, ...counts].join(" "), BODY_CHARS); + } + if (files.length > 1) { + return trimText(`Wants to edit ${files.length} files: ${files.map((file) => file.name).join(", ")}`, BODY_CHARS); + } + const prompt = request?.prompt; + return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval."; +} + function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string { switch (phase) { - case "waiting_for_approval": { - const prompt = summary?.request?.prompt; - return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval."; - } + case "waiting_for_approval": + return approvalBody(summary?.request ?? null); case "waiting_for_input": { const question = summary?.request?.questions[0]?.question; return question ? trimText(question, BODY_CHARS) : "Has a question for you."; diff --git a/gateway/test/notify.test.ts b/gateway/test/notify.test.ts index 5bee7f4..5fa4b77 100644 --- a/gateway/test/notify.test.ts +++ b/gateway/test/notify.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { Notifier, type Push } from "../src/notify.ts"; +import { approvalBody, Notifier, type Push } from "../src/notify.ts"; import type { T3Host } from "../src/t3/host.ts"; import type { ThreadShell } from "../src/t3/types.ts"; import { SummaryCache } from "../src/views.ts"; @@ -145,3 +145,34 @@ describe("question pushes", () => { }); }); }); + +describe("approvalBody", () => { + const approval = (kind: string, files: Array<{ name: string; additions: number | null; deletions: number | null }>) => ({ + id: "r-1", + kind, + prompt: "Edit: deploy/compose.yml", + reason: null, + options: [], + questions: [], + files: files.map((file) => ({ ...file, path: `deploy/${file.name}`, preview: [] })), + }); + + it("names the file a file approval would edit, with counts only when known", () => { + expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: 9, deletions: 6 }]))).toBe( + "Wants to edit compose.yml +9 \u22126", + ); + expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: null, deletions: null }]))).toBe( + "Wants to edit compose.yml", + ); + }); + + it("counts several files and lists their names", () => { + const files = ["compose.yml", "Containerfile", "health.ts"].map((name) => ({ name, additions: null, deletions: null })); + expect(approvalBody(approval("file-change", files))).toBe("Wants to edit 3 files: compose.yml, Containerfile, health.ts"); + }); + + it("falls back to the prompt without files", () => { + expect(approvalBody(approval("file-change", []))).toBe("Wants to run: Edit: deploy/compose.yml"); + expect(approvalBody(null)).toBe("Needs your approval."); + }); +}); diff --git a/watch/README.md b/watch/README.md index b0c74f3..ccac1e9 100644 --- a/watch/README.md +++ b/watch/README.md @@ -42,7 +42,8 @@ Allow moves a thread to working, a reply restarts a finished thread, and Settle the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. Recent holds 14 threads, so the feed shows its first 10 and a "4 more" row. The working cards show activity lines, the Containerfile thread has two subagents, and "Fix hyprlock crash" has two older -pages behind Load earlier. The SwiftUI previews use it, +pages behind Load earlier. Two cards are file approvals, one with line counts and a preview and one +with the file name alone, and two finished threads are snoozed. The SwiftUI previews use it, the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: ```sh @@ -61,8 +62,11 @@ status screens, the subagent card and thread, Recent with Settle all and the "N Recent list, a finished thread with Settle, a running thread with Ask before acting, New thread with its model list, the working cards' activity lines, All threads' hosts and a project's threads, a thread's subagents collapsed and open, Load earlier with markdown, the changes line and Changed -list, search and its empty state, the complication's two modes, the Smart Stack widget, and a question -notification are rendered by `SnapshotTests` instead, which only run when given a directory: +list, search and its empty state, the complication's two modes, the Smart Stack widget, a question +notification, the Snooze until picker (`snooze-picker.png`), the Snoozed list (`snoozed.png`) and its +row under Recent (`feed-snoozed.png`), file approvals on the card (`approval-file.png`) and the thread +screen (`thread-approval-file.png`), and the feed shown from the cache (`feed-cached.png`) are rendered +by `SnapshotTests` instead, which only run when given a directory: ```sh TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ @@ -87,7 +91,39 @@ Swipe a Recent row left, here or on the full list, or tap Settle under Reply on row leaves at once and comes back with the error if its call fails. Settle all, on the Recent header and the full list, is one `POST /v1/recent/settle` for every completed thread in Recent, including those past the first 10. Failed threads stay. The completed rows leave at once, and the header says -so when the call fails or some threads did not settle. +so when the call fails or some threads did not settle. A finished notification has Settle too, +beside Reply and Open. It settles in the background without opening the app. + +## Snooze + +Snooze hides a finished thread from Recent until a time you pick, and the gateway brings it back on +its own. The Recent swipe has a violet Snooze beside Settle, and the thread screen has a Snooze +button beside Settle. Both open the Snooze until picker with four choices in the watch's time zone: +In 1 hour, This evening at 6 PM, Tomorrow at 9 AM, and Next week, which is next Monday at 9 AM. From +5 PM on, the picker leaves out This evening. Once `POST /v1/threads/:hostId/:threadId/snooze` succeeds, the thread leaves +Recent and the feed refreshes. When any thread is snoozed, a dim violet row under Recent counts them +and opens the Snoozed list (`GET /v1/snoozed`), where each card says when it wakes and swipes left +to Unsnooze. + +## File approvals + +A file-change approval names its files in place of the raw prompt, on the feed card, the thread +screen and the notification: "Wants to edit" and the file name, or "Wants to edit 3 files" with a +row for each. The line counts show on the right only when the gateway has them, and the thread +screen adds the first changed lines in red and green. Claude sends no diff before approval, so in +practice the name shows alone. Without files, the prompt shows as before. + +## Loading and launch + +Every screen that loads data shows a plain "Loading" until its first answer. Once a screen has +content, a refresh keeps it on screen, and a failed refresh only adds a small dim line under it. +The error and Try again replace the placeholder only when there is nothing to show. When a screen +goes away mid-request, the cancelled request shows no error. + +On every refresh the app saves the last good feed as JSON in its caches folder, and it saves each +thread's detail when the thread loads. Launch shows the saved feed at once, with a dim "Updating" by the +count until the first refresh answers, and a thread opens on its saved detail while it loads. +Unpairing clears both. Demo mode never touches them. ## Activity, All threads and history @@ -159,6 +195,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, | --- | --- | | `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration | | `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing | +| `FeedCache.swift` | The last feed and thread details on disk, for an instant launch | | `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client | | `API/Models.swift` | Codable mirror of the contract | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs | @@ -167,6 +204,9 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, | `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme | | `Views/BrowseView.swift` | All threads: hosts, a host's projects, a project's threads | +| `Views/SnoozeView.swift` | The Snooze until picker, the Snoozed row and the Snoozed list | +| `Views/FileEditRows.swift` | A file approval's files, counts and preview lines | +| `Views/Loading.swift` | Loading, and errors that never replace content | | `Views/Markdown.swift` | Message text as styled text | | `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions | diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index f368c37..b607d29 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -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( _ 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 { diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index 8e2763e..5661a69 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -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 diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift index 33fce0b..6cdfe78 100644 --- a/watch/WristCode/API/Route.swift +++ b/watch/WristCode/API/Route.swift @@ -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//`, `wristcode://new`, `wristcode://recent`, `wristcode://status`, + /// `wristcode://thread//`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`, + /// `wristcode://status`, /// `wristcode://host/`, `wristcode://notifications`, `wristcode://threads`, /// `wristcode://projects/`, `wristcode://project//` or `wristcode://search?q=`. 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: diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index b56ced1..736d291 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -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 = [] /// 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 ` 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) } } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 0769607..2dc3d44 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -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) } } diff --git a/watch/WristCode/FeedCache.swift b/watch/WristCode/FeedCache.swift new file mode 100644 index 0000000..9b8e708 --- /dev/null +++ b/watch/WristCode/FeedCache.swift @@ -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--.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(_ 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) + } +} diff --git a/watch/WristCode/Notifications/NotificationHandler.swift b/watch/WristCode/Notifications/NotificationHandler.swift index dbcf25c..1fde2c8 100644 --- a/watch/WristCode/Notifications/NotificationHandler.swift +++ b/watch/WristCode/Notifications/NotificationHandler.swift @@ -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() diff --git a/watch/WristCode/Views/BrowseView.swift b/watch/WristCode/Views/BrowseView.swift index 584fbf1..303ad18 100644 --- a/watch/WristCode/Views/BrowseView.swift +++ b/watch/WristCode/Views/BrowseView.swift @@ -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: 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: View { var scrolls: Bool var error: String? @@ -302,19 +303,10 @@ private struct BrowseList: 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() } } } diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index dba9fc3..15ac503 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -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) diff --git a/watch/WristCode/Views/FileEditRows.swift b/watch/WristCode/Views/FileEditRows.swift new file mode 100644 index 0000000..f8e0051 --- /dev/null +++ b/watch/WristCode/Views/FileEditRows.swift @@ -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 + } + } +} diff --git a/watch/WristCode/Views/Loading.swift b/watch/WristCode/Views/Loading.swift new file mode 100644 index 0000000..47bd911 --- /dev/null +++ b/watch/WristCode/Views/Loading.swift @@ -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() + } + } +} diff --git a/watch/WristCode/Views/NewThreadView.swift b/watch/WristCode/Views/NewThreadView.swift index a6dfa33..90dac47 100644 --- a/watch/WristCode/Views/NewThreadView.swift +++ b/watch/WristCode/Views/NewThreadView.swift @@ -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 } } } diff --git a/watch/WristCode/Views/RecentView.swift b/watch/WristCode/Views/RecentView.swift index 38d6477..403fdee 100644 --- a/watch/WristCode/Views/RecentView.swift +++ b/watch/WristCode/Views/RecentView.swift @@ -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() - } } } } diff --git a/watch/WristCode/Views/SnoozeView.swift b/watch/WristCode/Views/SnoozeView.swift new file mode 100644 index 0000000..e745707 --- /dev/null +++ b/watch/WristCode/Views/SnoozeView.swift @@ -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) +} diff --git a/watch/WristCode/Views/StatusView.swift b/watch/WristCode/Views/StatusView.swift index 936718c..f467510 100644 --- a/watch/WristCode/Views/StatusView.swift +++ b/watch/WristCode/Views/StatusView.swift @@ -486,21 +486,8 @@ private struct StatusLoader: 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: 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 } } } } diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index 6006db3..6b24c1a 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -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 { diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index 485ca59..e294bd7 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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 } } } diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index 9c3f6fe..43beb3d 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -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) diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index fabe230..f3073a4 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -289,6 +289,50 @@ private let feedItemJSON = """ #expect(search.unsearched == ["Laptop"]) } + @Test func phase5FromDocs() throws { + let feed = try decode(Feed.self, """ + { + "needsYou": [], "working": [], "recent": [], "recentTotal": 0, "snoozedTotal": 2, "hosts": [] + } + """) + #expect(feed.snoozedTotal == 2) + + let snoozed = try decode(RecentResponse.self, """ + { "items": [{ + "hostId": "h", "hostLabel": "Mac mini", "threadId": "t", "projectId": "p", "projectTitle": "wristcode", + "title": "Gateway Containerfile", "phase": "completed", "updatedAt": "2026-11-02T14:00:00.000Z", + "lastMessage": null, "activity": null, "changes": null, "snoozedUntil": "2026-11-03T14:00:00.000Z", + "request": null, "via": null + }] } + """) + #expect(snoozed.items.first?.snoozedUntil == JSON.parseDate("2026-11-03T14:00:00.000Z")) + + let request = try decode(PendingRequest.self, """ + { + "id": "r", "kind": "file-change", "prompt": "Edit: deploy/compose.yml", "reason": null, + "options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }], + "files": [ + { "path": "deploy/compose.yml", "name": "compose.yml", "additions": 9, "deletions": 6, + "preview": ["- image: bun:1", "+ image: bun:1-alpine"] }, + { "path": "gateway/src/health.ts", "name": "health.ts", "additions": null, "deletions": null, "preview": [] } + ], + "questions": [] + } + """) + #expect(request.files.map(\.name) == ["compose.yml", "health.ts"]) + #expect(request.files[0].additions == 9) + #expect(request.files[1].deletions == nil) + #expect(request.files[0].preview.count == 2) + + // Older gateways send neither, which decodes as nothing snoozed and no files. + let old = try decode(Feed.self, """ + { "needsYou": [\(feedItemJSON)], "working": [], "recent": [], "hosts": [] } + """) + #expect(old.snoozedTotal == 0) + #expect(old.needsYou.first?.snoozedUntil == nil) + #expect(old.needsYou.first?.request?.files == []) + } + @Test func pushQuestion() throws { let info: [AnyHashable: Any] = [ "hostId": "h", "threadId": "t", "requestId": "r", @@ -549,7 +593,7 @@ private let feedItemJSON = """ #expect(Route(url: thread.url) == thread) #expect(Route(url: Route.newThread.url) == .newThread) for route in [ - Route.recent, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"), + Route.recent, .snoozed, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"), .projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"), ] { #expect(Route(url: route.url) == route) @@ -576,6 +620,8 @@ private let feedItemJSON = """ // Allow without a requestId cannot answer, so it opens the thread instead. #expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil) == .open(.thread(hostId: "h", threadId: "t"))) + #expect(NotificationCommand(actionIdentifier: "settle", userInfo: info, text: nil) == .settle(hostId: "h", threadId: "t")) + #expect(NotificationCommand(actionIdentifier: "settle", userInfo: ["hostId": "h"], text: nil) == .open(nil)) // Answer opens the thread's question screen. #expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t"))) } @@ -605,6 +651,52 @@ private let feedItemJSON = """ #expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread) } + @MainActor @Test func finishedNotificationActions() throws { + let finished = try #require(NotificationHandler.categories.first { $0.identifier == PushCategory.finished.rawValue }) + #expect(finished.actions.map(\.title) == ["Reply", "Settle", "Open"]) + // Settle runs in the background, without opening the app. + #expect(!finished.actions[1].options.contains(.foreground)) + } + + @MainActor @Test func cancelledCallsShowNoError() { + let model = AppModel(mode: .demo) + #expect(model.message(for: CancellationError()) == nil) + #expect(model.message(for: URLError(.cancelled)) == nil) + #expect(GatewayClient.transportError(URLError(.cancelled)) is CancellationError) + #expect(model.message(for: GatewayClient.transportError(URLError(.cancelled))) == nil) + // Other transport failures still show. + let offline = GatewayClient.transportError(URLError(.notConnectedToInternet)) + #expect(offline as? GatewayError == .transport(URLError(.notConnectedToInternet).localizedDescription)) + #expect(model.message(for: offline) != nil) + } + + @Test func fileEditLines() throws { + func request(_ files: [PendingFile], kind: String = "file-change") -> PendingRequest { + PendingRequest(id: "r", kind: kind, prompt: "Edit: a", reason: nil, options: [], questions: [], files: files) + } + let compose = PendingFile( + path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6, + preview: ["- image: bun:1", "+ image: bun:1-alpine", " context"]) + let health = PendingFile(path: "src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: ["+x"]) + + let one = try #require(request([compose]).fileEdit) + #expect(one.title == "Wants to edit") + #expect(one.sentence == "Wants to edit compose.yml") + #expect(one.preview.map(\.change) == [.removed, .added, .context]) + #expect(compose.counts.additions == "+9" && compose.counts.deletions == "\u{2212}6") + #expect(health.counts.additions == nil && health.counts.deletions == nil) + + let two = try #require(request([compose, health]).fileEdit) + #expect(two.title == "Wants to edit 2 files") + #expect(two.sentence == "Wants to edit 2 files: compose.yml, health.ts") + // Several files show no preview. + #expect(two.preview.isEmpty) + + // No files, or another kind, keeps the prompt. + #expect(request([]).fileEdit == nil) + #expect(request([compose], kind: "command").fileEdit == nil) + } + @MainActor @Test func questionNotificationActions() throws { let question = PushQuestion( id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")], @@ -827,3 +919,106 @@ private let feedItemJSON = """ #expect(try await gateway.feed().working.contains { $0.id == item.id }) } } + +@Suite struct SnoozeTests { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Chicago")! + return calendar + } + + private func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 11, day: day, hour: hour, minute: minute))! + } + + @Test func choicesOnAMondayAfternoon() { + // Monday, November 2, 2026 at 2 PM. + let choices = SnoozeChoice.choices(now: date(2, 14), calendar: calendar) + #expect(choices.map(\.title) == ["In 1 hour", "This evening · 6 PM", "Tomorrow · 9 AM", "Next week"]) + #expect(choices.map(\.until) == [date(2, 15), date(2, 18), date(3, 9), date(9, 9)]) + } + + @Test func eveningLeavesFromFivePM() { + #expect(SnoozeChoice.choices(now: date(2, 16, 59), calendar: calendar).count == 4) + let late = SnoozeChoice.choices(now: date(2, 17, 30), calendar: calendar) + #expect(late.map(\.title) == ["In 1 hour", "Tomorrow · 9 AM", "Next week"]) + } + + @Test func nextWeekIsNextMonday() { + // Sunday the 8th: tomorrow is Monday. Saturday the 7th: two days on. + #expect(SnoozeChoice.choices(now: date(8, 10), calendar: calendar).last?.until == date(9, 9)) + #expect(SnoozeChoice.choices(now: date(7, 10), calendar: calendar).last?.until == date(9, 9)) + #expect(SnoozeChoice.choices(now: date(4, 10), calendar: calendar).last?.until == date(9, 9)) + } + + @Test func wakesWording() { + let now = date(2, 14) + #expect(Format.wakes(at: date(2, 18), now: now, calendar: calendar) == "Wakes at 6 PM") + #expect(Format.wakes(at: date(2, 15, 30), now: now, calendar: calendar) == "Wakes at 3:30 PM") + #expect(Format.wakes(at: date(3, 9), now: now, calendar: calendar) == "Wakes tomorrow 9 AM") + #expect(Format.wakes(at: date(6, 9), now: now, calendar: calendar) == "Wakes Friday 9 AM") + #expect(Format.wakes(at: date(30, 9), now: now, calendar: calendar) == "Wakes Nov 30, 9 AM") + } + + @Test func snoozeMovesAThreadFromRecentToSnoozed() async throws { + let gateway = DemoGateway() + let before = try await gateway.feed() + let item = try #require(before.recent.first) + try await gateway.snooze(hostId: item.hostId, threadId: item.threadId, until: .now.addingTimeInterval(3600)) + let after = try await gateway.feed() + #expect(!after.recent.contains { $0.id == item.id }) + #expect(after.snoozedTotal == before.snoozedTotal + 1) + #expect(try await gateway.snoozed().items.contains { $0.id == item.id && $0.snoozedUntil != nil }) + + try await gateway.unsnooze(hostId: item.hostId, threadId: item.threadId) + #expect(try await gateway.feed().recent.contains { $0.id == item.id }) + } +} + +@MainActor @Suite struct FeedCacheTests { + private func temporaryCache() -> FeedCache { + FeedCache(directory: FileManager.default.temporaryDirectory.appending(path: "FeedCacheTests-\(UUID().uuidString)")) + } + + /// Fixtures from a whole second, so their dates survive JSON, which keeps milliseconds. + private let gateway = DemoGateway(now: Date(timeIntervalSince1970: 1_793_646_000)) + + @Test func roundTrip() async throws { + let cache = temporaryCache() + defer { cache.clear() } + #expect(cache.feed() == nil) + let feed = try await gateway.feed() + cache.save(feed) + #expect(cache.feed() == feed) + + let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose") + cache.save(detail) + #expect(cache.thread(hostId: "server-gib", threadId: "t-compose") == detail) + #expect(cache.thread(hostId: "server-gib", threadId: "t-other") == nil) + } + + @Test func pairedModelStartsOnTheCacheAndSignOutClearsIt() async throws { + let cache = temporaryCache() + defer { cache.clear() } + let feed = try await gateway.feed() + cache.save(feed) + let credentials = Credentials(gatewayURL: URL(string: "https://gateway.invalid")!, token: "token") + let model = AppModel(mode: .paired(credentials), cache: cache) + #expect(model.feed == feed) + #expect(model.feedIsCached) + + model.signOut() + #expect(cache.feed() == nil) + #expect(model.feed == nil) + } + + @Test func demoModeKeepsOffTheCache() async throws { + let cache = temporaryCache() + defer { cache.clear() } + cache.save(try await DemoGateway().feed()) + let model = AppModel(mode: .demo, cache: cache) + #expect(model.feed == nil) + #expect(!model.feedIsCached) + #expect(model.cachedThread(hostId: "server-gib", threadId: "t-compose") == nil) + } +} diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index 2b146d3..49c5c9d 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -179,6 +179,40 @@ struct SnapshotTests { try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png") } + @Test func snooze() async throws { + let feed = try await DemoGateway().feed() + let item = try #require(feed.recent.first) + // 2 PM today, before This evening drops out, so all four choices show. + let afternoon = try #require(Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: .now)) + try write(titled("Snooze until", SnoozePicker(item: item, now: afternoon, scrolls: false)), "snooze-picker.png") + // The preview model has the demo's snoozed threads loaded. + try write(titled("Snoozed", SnoozedView(scrolls: false)), "snoozed.png") + try write(feedColumn { + RecentHeader(recent: feed.recent, more: feed.moreRecent) + ForEach(feed.recent.suffix(2)) { StatusCard(item: $0) } + MoreRecentRow(count: feed.moreRecent) + SnoozedRow(count: feed.snoozedTotal) + }, "feed-snoozed.png") + } + + @Test func fileApprovals() async throws { + let gateway = DemoGateway() + let feed = try await gateway.feed() + let files = feed.needsYou.filter { $0.request?.fileEdit != nil } + #expect(files.count == 2) + try write(feedColumn { ForEach(files) { NeedsYouCard(item: $0) } }, "approval-file.png") + let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose") + try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-compose", detail: detail, scrolls: false)), "thread-approval-file.png") + } + + @Test func cachedFeed() async throws { + let feed = try await DemoGateway().feed() + try write(feedColumn { + FeedHeader(feed: feed, cached: true) + ForEach(feed.needsYou.prefix(2)) { NeedsYouCard(item: $0) } + }, "feed-cached.png") + } + /// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank. private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View { VStack(spacing: 6) { diff --git a/watch/screenshots/approval-file.png b/watch/screenshots/approval-file.png new file mode 100644 index 0000000..8d06f59 Binary files /dev/null and b/watch/screenshots/approval-file.png differ diff --git a/watch/screenshots/complication-waiting.png b/watch/screenshots/complication-waiting.png index 69879ef..4dc1ef6 100644 Binary files a/watch/screenshots/complication-waiting.png and b/watch/screenshots/complication-waiting.png differ diff --git a/watch/screenshots/complication.png b/watch/screenshots/complication.png index b31c294..692584c 100644 Binary files a/watch/screenshots/complication.png and b/watch/screenshots/complication.png differ diff --git a/watch/screenshots/feed-cached.png b/watch/screenshots/feed-cached.png new file mode 100644 index 0000000..19cd834 Binary files /dev/null and b/watch/screenshots/feed-cached.png differ diff --git a/watch/screenshots/feed-snoozed.png b/watch/screenshots/feed-snoozed.png new file mode 100644 index 0000000..2a577dc Binary files /dev/null and b/watch/screenshots/feed-snoozed.png differ diff --git a/watch/screenshots/smart-stack.png b/watch/screenshots/smart-stack.png index d189506..d30e276 100644 Binary files a/watch/screenshots/smart-stack.png and b/watch/screenshots/smart-stack.png differ diff --git a/watch/screenshots/snooze-picker.png b/watch/screenshots/snooze-picker.png new file mode 100644 index 0000000..86b140d Binary files /dev/null and b/watch/screenshots/snooze-picker.png differ diff --git a/watch/screenshots/snoozed.png b/watch/screenshots/snoozed.png new file mode 100644 index 0000000..3294e60 Binary files /dev/null and b/watch/screenshots/snoozed.png differ diff --git a/watch/screenshots/thread-approval-file.png b/watch/screenshots/thread-approval-file.png new file mode 100644 index 0000000..36ad5ed Binary files /dev/null and b/watch/screenshots/thread-approval-file.png differ diff --git a/watch/screenshots/thread-changes.png b/watch/screenshots/thread-changes.png index 9ffd7a4..0693ac0 100644 Binary files a/watch/screenshots/thread-changes.png and b/watch/screenshots/thread-changes.png differ diff --git a/watch/screenshots/thread-earlier.png b/watch/screenshots/thread-earlier.png index ff2ac6b..07a0d5c 100644 Binary files a/watch/screenshots/thread-earlier.png and b/watch/screenshots/thread-earlier.png differ diff --git a/watch/screenshots/thread-settle.png b/watch/screenshots/thread-settle.png index c223a1a..349424e 100644 Binary files a/watch/screenshots/thread-settle.png and b/watch/screenshots/thread-settle.png differ