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 -5
View File
@@ -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.";
+32 -1
View File
@@ -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.");
});
});
+44 -4
View File
@@ -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 |
+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)
+196 -1
View File
@@ -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)
}
}
+34
View File
@@ -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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 76 KiB