diff --git a/watch/README.md b/watch/README.md index 73c26d9..a7f5d43 100644 --- a/watch/README.md +++ b/watch/README.md @@ -39,21 +39,23 @@ register their token with the gateway as `production`, Debug builds as `sandbox` `DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes: Allow moves a thread to working, a reply restarts a finished thread, and Settle hides a thread from -the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. The SwiftUI previews use it, +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 SwiftUI previews use it, the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: ```sh xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo # feed xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen new # new thread +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen recent # full Recent list xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen status # status overview xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications ``` `screenshots/` holds the screens captured this way. The complication, the multi-select question, the -status screens, the subagent card and thread, Recent with Settle all, and a finished thread with -Settle are rendered by `SnapshotTests` instead, which only run when given a directory: +status screens, the subagent card and thread, Recent with Settle all and the "N more" row, the full +Recent list, and a finished thread with Settle are rendered by `SnapshotTests` instead, which only run when given a directory: ```sh TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ @@ -70,9 +72,15 @@ gateway puts its request on the parent's card and names it in `via`. The card sa wants to run:" or "asks:", and the parent's thread screen shows the request first, labeled "From subagent". Every answer goes to the subagent's thread. -Settle clears a finished or failed thread from the feed, as in T3. Swipe a Recent row left, tap -Settle all on the Recent header (completed threads only, failed ones stay), or tap Settle under -Reply on the thread screen. A row leaves at once and comes back with the error if its call fails. +Settle clears a finished or failed thread from the feed, as in T3. Recent keeps every finished or +failed thread until it is settled, however old. The feed shows the newest 10, then an "N more" row +that opens the full Recent list (`GET /v1/recent`, loaded when the screen appears). + +Swipe a Recent row left, here or on the full list, or tap Settle under Reply on the thread screen. A +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. ## Status and settings @@ -112,7 +120,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, | `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group | | `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures | -| `Views/` | Pair, Feed, Thread, New thread, Status, and the mock B theme | +| `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme | | `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 | | `../WristCodeWidget/` | The complication: timeline provider, and one view per family | diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index 8eda37a..fdab6cd 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -51,6 +51,10 @@ protocol GatewayAPI: Sendable { func stop(hostId: String, threadId: String) async throws /// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed. func settle(hostId: String, threadId: String) async throws + /// Every unsettled finished thread, including those past the feed's first 10. + func recent() async throws -> RecentResponse + /// Settles every completed thread in Recent. Failed threads stay. + func settleRecent() async throws -> SettleRecentResponse func projects() async throws -> ProjectsResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func registerPush(_ registration: PushRegistration) async throws @@ -101,6 +105,14 @@ struct GatewayClient: GatewayAPI { try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "settle"], body: nil as SendMessageRequest?) } + func recent() async throws -> RecentResponse { + try await call("GET", ["v1", "recent"]) + } + + func settleRecent() async throws -> SettleRecentResponse { + try await call("POST", ["v1", "recent", "settle"]) + } + func projects() async throws -> ProjectsResponse { try await call("GET", ["v1", "projects"]) } diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index c3d1982..21838c7 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -167,8 +167,51 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable { struct Feed: Codable, Hashable, Sendable { var needsYou: [FeedItem] var working: [FeedItem] + /// Finished or failed threads not settled yet, newest first and at most 10. var recent: [FeedItem] + /// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`. + var recentTotal: 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]) { + self.needsYou = needsYou + self.working = working + self.recent = recent + self.recentTotal = recentTotal ?? recent.count + self.hosts = hosts + } + + /// Recent threads the feed leaves out, which the "N more" row counts. + var moreRecent: Int { max(0, recentTotal - recent.count) } +} + +extension Feed { + private enum CodingKeys: String, CodingKey { + case needsYou, working, recent, recentTotal, hosts + } + + /// `recentTotal` is newer than the rest, so a gateway that leaves it out has nothing past `recent`. + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + needsYou: try container.decode([FeedItem].self, forKey: .needsYou), + working: try container.decode([FeedItem].self, forKey: .working), + recent: try container.decode([FeedItem].self, forKey: .recent), + recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal), + hosts: try container.decode([HostSummary].self, forKey: .hosts)) + } +} + +/// `GET /v1/recent`: every unsettled finished thread, newest first. Older ones may lack `lastMessage`. +struct RecentResponse: Codable, Hashable, Sendable { + var items: [FeedItem] +} + +/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline. +struct SettleRecentResponse: Codable, Hashable, Sendable { + var settled: Int + var failed: Int } /// `GET /v1/summary`: the counts behind the complication. diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift index c082f4f..5344524 100644 --- a/watch/WristCode/API/Route.swift +++ b/watch/WristCode/API/Route.swift @@ -5,6 +5,8 @@ import Foundation enum Route: Hashable, Sendable { case thread(hostId: String, threadId: String) case newThread + /// Every thread in Recent, past the feed's first 10. + case recent /// The status overview, and from it one host or the notifications page. case status case host(id: String) @@ -12,7 +14,7 @@ enum Route: Hashable, Sendable { static let scheme = "wristcode" - /// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://status`, + /// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`, /// `wristcode://host/<hostId>` or `wristcode://notifications`. var url: URL { var components = URLComponents() @@ -25,6 +27,8 @@ enum Route: Hashable, Sendable { .joined(separator: "/") case .newThread: components.host = "new" + case .recent: + components.host = "recent" case .status: components.host = "status" case let .host(id): @@ -47,6 +51,8 @@ enum Route: Hashable, Sendable { self = .thread(hostId: parts[0], threadId: parts[1]) case "new": self = .newThread + case "recent": + self = .recent case "status": self = .status case "host" where parts.count == 1 && !parts[0].isEmpty: diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index feaf74e..9727306 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -30,18 +30,26 @@ final class AppModel { private(set) var settling: Set<String> = [] /// Why settling an item failed, by item id. Its row shows the message. private(set) var settleErrors: [String: String] = [:] + /// Every thread in Recent, for the full Recent screen, which loads it when it appears. + private(set) var allRecent: [FeedItem]? + private(set) var allRecentError: String? + /// True while Settle all is out. Completed Recent threads are hidden meanwhile. + private(set) var settlingAll = false + /// Why Settle all failed, or how many threads it could not settle. The Recent header shows it. + private(set) var settleAllError: String? private var apnsToken: String? private var refreshing = false /// The counts the complication was last told about. private var sharedSummary: Summary? - init(mode: Mode, feed: Feed? = nil, path: [Route] = []) { + init(mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, path: [Route] = []) { self.mode = mode self.feed = feed + self.allRecent = allRecent self.path = path } - /// `-demo` starts in demo mode. `-screen new|status|notifications`, `-host <hostId>` or + /// `-demo` starts in demo mode. `-screen new|recent|status|notifications`, `-host <hostId>` or /// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in /// watch/screenshots are taken. convenience init(launchArguments args: [String]) { @@ -55,6 +63,7 @@ final class AppModel { var path: [Route] = [] switch argument("-screen") { case "new": path = [.newThread] + case "recent": path = [.recent] case "status": path = [.status] case "notifications": path = [.status, .notifications] default: break @@ -182,11 +191,56 @@ final class AppModel { for (id, error) in failures { settleErrors[id] = message(for: error) } - feed?.recent.removeAll { ids.contains($0.id) && failures[$0.id] == nil } + let settled = ids.filter { failures[$0] == nil } + removeFromRecent(settled.count) { settled.contains($0.id) } settling.subtract(ids) await refreshFeed() } + /// Recent threads to show: those not being settled. + func visibleRecent(_ items: [FeedItem]) -> [FeedItem] { + items.filter { !settling.contains($0.id) && !(settlingAll && $0.phase == .completed) } + } + + /// `GET /v1/recent`, for the full Recent screen. + func loadAllRecent() async { + guard mode != .unpaired else { return } + do { + allRecent = try await api.recent().items + allRecentError = nil + } catch { + allRecentError = message(for: error) + } + } + + /// Settles every completed thread in Recent with one call, including those past the feed's first + /// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh. + func settleAllRecent() async { + settlingAll = true + settleAllError = nil + do { + let result = try await api.settleRecent() + removeFromRecent(result.settled) { $0.phase == .completed } + if result.failed > 0 { + settleAllError = result.failed == 1 ? "1 thread didn't settle." : "\(result.failed) threads didn't settle." + } + } catch { + settleAllError = message(for: error) + } + settlingAll = false + await refreshFeed() + if allRecent != nil { await loadAllRecent() } + } + + /// 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) + guard var feed else { return } + feed.recent.removeAll(where: settled) + feed.recentTotal = max(feed.recent.count, feed.recentTotal - count) + self.feed = feed + } + func open(_ route: Route) { path = [route] } @@ -262,6 +316,6 @@ final class AppModel { extension AppModel { /// A demo model with the fixture feed already loaded, for previews. static func preview(path: [Route] = []) -> AppModel { - AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path) + AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(), path: path) } } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 5d9b713..b0af1b1 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -27,12 +27,7 @@ actor DemoGateway: GatewayAPI { } func feed() async throws -> Feed { - let items = items.filter { !settled.contains($0.id) } - let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt } - let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt } - let recent = items.filter { $0.phase == .completed || $0.phase == .failed } - .sorted { $0.updatedAt > $1.updatedAt } - return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts) + DemoFixtures.feed(items.filter { !settled.contains($0.id) }, hosts: hosts) } func summary() async throws -> Summary { @@ -93,6 +88,16 @@ actor DemoGateway: GatewayAPI { settled.insert(item.id) } + func recent() async throws -> RecentResponse { + RecentResponse(items: DemoFixtures.recent(items.filter { !settled.contains($0.id) })) + } + + func settleRecent() async throws -> SettleRecentResponse { + let completed = DemoFixtures.recent(items.filter { !settled.contains($0.id) }).filter { $0.phase == .completed } + settled.formUnion(completed.map(\.id)) + return SettleRecentResponse(settled: completed.count, failed: 0) + } + func projects() async throws -> ProjectsResponse { ProjectsResponse(hosts: projectHosts) } @@ -254,7 +259,28 @@ struct DemoFixtures { projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95), lastMessage: "Fixed. hyprlock no longer crashes when a monitor sleeps.", request: nil) - items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] + // Older finished threads, past the feed's first 10 in Recent. The gateway has not read most of + // them, so they have no last message. + func older(_ host: HostSummary, _ id: String, _ project: String, _ title: String, hours: Double, failed: Bool = false) -> FeedItem { + FeedItem( + hostId: host.id, hostLabel: host.label, threadId: id, projectId: "p-\(project)", projectTitle: project, + title: title, phase: failed ? .failed : .completed, updatedAt: ago(hours * 60), lastMessage: nil, request: nil) + } + let (mac, server, laptop) = (hosts[0], hosts[1], hosts[2]) + let olderRecent = [ + older(laptop, "t-icons", "wristcode", "App icon variants", hours: 4), + older(mac, "t-receipts", "bill-tracker", "Receipt upload form", hours: 6), + older(server, "t-backup", "homelab", "Nightly restic backup", hours: 9, failed: true), + older(mac, "t-tailwind", "gbrown.org", "Tailwind 5 migration", hours: 20), + older(server, "t-caddy", "homelab", "Caddy wildcard certs", hours: 27), + older(laptop, "t-readme", "wristcode", "README pass", hours: 30), + older(mac, "t-csv", "bill-tracker", "CSV export", hours: 50), + older(server, "t-immich", "homelab", "Immich upgrade", hours: 72), + older(mac, "t-lint", "cdn", "Clippy warnings", hours: 96), + older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120), + ] + + items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] + olderRecent func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message { Message(id: id, role: role, text: text, createdAt: ago(minutes)) @@ -359,13 +385,31 @@ struct DemoFixtures { device: .init(name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production)) } + /// 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) + 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, + 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 } + } + /// A feed for previews, without going through the actor. static func previewFeed(now: Date = .now) -> Feed { let fixtures = DemoFixtures(now: now) - return Feed( - needsYou: fixtures.items.filter(\.phase.needsYou), - working: fixtures.items.filter(\.phase.isWorking), - recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking }, - hosts: fixtures.hosts) + return feed(fixtures.items, hosts: fixtures.hosts) + } + + /// The full Recent list for previews. + static func previewRecent(now: Date = .now) -> [FeedItem] { + recent(DemoFixtures(now: now).items) } } diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index abdfbbc..c041225 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -1,6 +1,7 @@ // 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 what the agent is doing), then recent. -// A Recent row swipes left to settle, and the Recent header settles every completed thread. +// 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. import SwiftUI @@ -15,28 +16,25 @@ struct FeedView: View { .feedRow() if let feed = model.feed { - let recent = feed.recent.filter { !model.settling.contains($0.id) } + let recent = model.visibleRecent(feed.recent) + let more = model.settlingAll ? 0 : feed.moreRecent ForEach(feed.needsYou) { item in NeedsYouCard(item: item).feedRow() } ForEach(feed.working) { item in StatusCard(item: item).feedRow() } - if !recent.isEmpty { - RecentHeader(recent: recent).feedRow() + if !recent.isEmpty || more > 0 { + RecentHeader(recent: recent, more: more).feedRow() } ForEach(recent) { item in - StatusCard(item: item) - .feedRow() - .swipeActions(edge: .trailing) { - Button { Task { await model.settle([item]) } } label: { - Label("Settle", systemImage: "checkmark") - } - .tint(Theme.work) - } + RecentRow(item: item).feedRow() } - if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty { - Text("No threads in the last day.") + if more > 0 { + MoreRecentRow(count: more).feedRow() + } + if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 { + Text("No threads right now.") .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) .feedRow() @@ -79,7 +77,8 @@ struct FeedView: View { } } -private extension View { +extension View { + /// A clear List row with the feed's card insets. func feedRow() -> some View { listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) @@ -130,30 +129,84 @@ private struct FeedHeader: View { } } -/// "Recent" over the finished and failed threads, with Settle all when any completed. Settle all +/// A title over the finished and failed threads, with Settle all when any completed or when +/// threads past `recent` may have. Settle all is one call for every completed thread in Recent. It /// leaves failed threads, which settle one at a time by swipe. struct RecentHeader: View { @Environment(AppModel.self) private var model + var title = "Recent" var recent: [FeedItem] + /// Threads in Recent that `recent` leaves out. + var more = 0 var body: some View { - let completed = recent.filter { $0.phase == .completed } - HStack { - Text("Recent") - .foregroundStyle(Theme.secondaryText) - Spacer() - if !completed.isEmpty { - Button("Settle all") { Task { await model.settle(completed) } } - .buttonStyle(.plain) - .foregroundStyle(Theme.work) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(title) + .foregroundStyle(Theme.secondaryText) + Spacer() + if more > 0 || recent.contains(where: { $0.phase == .completed }) { + Button("Settle all") { Task { await model.settleAllRecent() } } + .buttonStyle(.plain) + .foregroundStyle(Theme.work) + .disabled(model.settlingAll) + } + } + .font(.system(size: 12, weight: .semibold)) + if let error = model.settleAllError { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) } } - .font(.system(size: 12, weight: .semibold)) .padding(.horizontal, 8) .padding(.top, 4) } } +/// A Recent thread: its card, which opens the thread, and a left swipe to settle it. +struct RecentRow: View { + @Environment(AppModel.self) private var model + var item: FeedItem + + var body: some View { + StatusCard(item: item) + .swipeActions(edge: .trailing) { + Button { Task { await model.settle([item]) } } label: { + Label("Settle", systemImage: "checkmark") + } + .tint(Theme.work) + } + } +} + +/// The last Recent row when the feed leaves threads out: "N more", dimmer than a card, opening the +/// full Recent list. +struct MoreRecentRow: View { + @Environment(AppModel.self) private var model + var count: Int + + var body: some View { + Button { model.path.append(.recent) } label: { + Card(edge: Theme.done.opacity(0.5)) { + HStack { + Text("\(count) more") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.done) + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Theme.done) + } + } + .opacity(0.8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(count) more recent threads") + } +} + /// 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. struct NeedsYouCard: View { diff --git a/watch/WristCode/Views/RecentView.swift b/watch/WristCode/Views/RecentView.swift new file mode 100644 index 0000000..38d6477 --- /dev/null +++ b/watch/WristCode/Views/RecentView.swift @@ -0,0 +1,61 @@ +// 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. + +import SwiftUI + +struct RecentView: 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.loadAllRecent() } + .task { await model.loadAllRecent() } + } else { + VStack(spacing: 6) { rows } + .padding(.horizontal, 4) + } + } + .navigationTitle("Recent") + } + + @ViewBuilder private var rows: some View { + if let all = model.allRecent { + let items = model.visibleRecent(all) + RecentHeader(title: items.count == 1 ? "1 thread" : "\(items.count) threads", recent: items) + .feedRow() + 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) + .feedRow() + if model.allRecent == nil { + Button("Try again") { Task { await model.loadAllRecent() } } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) + .feedRow() + } + } + } +} + +#Preview("Recent") { + NavigationStack { + RecentView() + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index 85387df..ae64e2c 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -33,6 +33,8 @@ struct RootView: View { ThreadView(hostId: hostId, threadId: threadId) case .newThread: NewThreadView() + case .recent: + RecentView() case .status: StatusView() case let .host(id): diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 0a8cd29..8eb9a89 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -58,6 +58,29 @@ private let feedItemJSON = """ #expect(request.quickOptions?.allow.label == "Allow") #expect(request.quickOptions?.deny.decision == .decline) #expect(request.summary == "bun test --filter ocr") + // An older gateway without `recentTotal` has nothing past `recent`. + #expect(feed.recentTotal == 0) + #expect(feed.moreRecent == 0) + } + + @Test func feedWithRecentTotal() throws { + let item = feedItemJSON.replacingOccurrences(of: "waiting_for_approval", with: "completed") + let feed = try decode(Feed.self, """ + { "needsYou": [], "working": [], "recent": [\(item)], "recentTotal": 12, "hosts": [] } + """) + #expect(feed.recentTotal == 12) + #expect(feed.moreRecent == 11) + #expect(Feed(needsYou: [], working: [], recent: feed.recent, hosts: []).moreRecent == 0) + } + + @Test func recentResponses() throws { + let item = feedItemJSON.replacingOccurrences( + of: "\"Parser and tests are written. I want to run the suite.\"", with: "null") + let recent = try decode(RecentResponse.self, "{ \"items\": [\(item)] }") + #expect(recent.items.count == 1) + #expect(recent.items.first?.lastMessage == nil) + let settled = try decode(SettleRecentResponse.self, #"{ "settled": 11, "failed": 1 }"#) + #expect(settled == SettleRecentResponse(settled: 11, failed: 1)) } @Test func subagentRequest() throws { @@ -277,7 +300,7 @@ private let feedItemJSON = """ #expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1") #expect(Route(url: thread.url) == thread) #expect(Route(url: Route.newThread.url) == .newThread) - for route in [Route.status, .host(id: "mac mini"), .notifications] { + for route in [Route.recent, .status, .host(id: "mac mini"), .notifications] { #expect(Route(url: route.url) == route) } #expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil) @@ -444,6 +467,26 @@ private let feedItemJSON = """ #expect(try await gateway.feed().working.contains { $0.id == item.id }) } + @Test func feedShowsTenRecentAndCountsTheRest() async throws { + let gateway = DemoGateway() + let feed = try await gateway.feed() + let all = try await gateway.recent().items + #expect(feed.recent.count == 10) + #expect(feed.recentTotal == all.count) + #expect(feed.moreRecent == all.count - 10) + #expect(feed.recent == Array(all.prefix(10))) + } + + @Test func settleRecentLeavesFailedThreads() async throws { + let gateway = DemoGateway() + let all = try await gateway.recent().items + let result = try await gateway.settleRecent() + #expect(result == SettleRecentResponse(settled: all.filter { $0.phase == .completed }.count, failed: 0)) + let left = try await gateway.recent().items + #expect(!left.isEmpty && left.allSatisfy { $0.phase == .failed }) + #expect(try await gateway.feed().moreRecent == 0) + } + @Test func multiSelectAnswerMovesThreadToWorking() async throws { let gateway = DemoGateway() let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index af08a5c..046b76d 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -52,11 +52,17 @@ struct SnapshotTests { @Test func recentWithSettleAll() async throws { let feed = try await DemoGateway().feed() try write(feedColumn { - RecentHeader(recent: feed.recent) + RecentHeader(recent: feed.recent, more: feed.moreRecent) ForEach(feed.recent) { StatusCard(item: $0) } + MoreRecentRow(count: feed.moreRecent) }, "feed-recent.png") } + @Test func fullRecentList() async throws { + // The preview model has every demo Recent thread loaded. + try write(titled("Recent", RecentView(scrolls: false)), "recent.png") + } + @Test func subagentThread() async throws { let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab") try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png") diff --git a/watch/screenshots/feed-recent.png b/watch/screenshots/feed-recent.png index 5e9c381..57b1a76 100644 Binary files a/watch/screenshots/feed-recent.png and b/watch/screenshots/feed-recent.png differ diff --git a/watch/screenshots/recent.png b/watch/screenshots/recent.png new file mode 100644 index 0000000..5e46dc4 Binary files /dev/null and b/watch/screenshots/recent.png differ