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: an approval push for a file change says which files it would edit
No files matched your search
@@ -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.";
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 76 KiB |