Watch: keep Recent past 10 behind an N more row with a full list, and settle all in one call

This commit is contained in:
gib committed 2026-11-01 22:32:19 -05:00
1 parent 2a127327bf
commit a5222e14e8
13 files changed
+384 -52

No files matched your search

+15 -7
View File
@@ -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: `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 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: the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
```sh ```sh
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen recent # full Recent list
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status # status overview xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status # status overview
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications
``` ```
`screenshots/` holds the screens captured this way. The complication, the multi-select question, the `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 status screens, the subagent card and thread, Recent with Settle all and the "N more" row, the full
Settle are rendered by `SnapshotTests` instead, which only run when given a directory: Recent list, and a finished thread with Settle are rendered by `SnapshotTests` instead, which only run when given a directory:
```sh ```sh
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ 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
<title> wants to run:" or "asks:", and the parent's thread screen shows the request first, labeled <title> 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. "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 clears a finished or failed thread from the feed, as in T3. Recent keeps every finished or
Settle all on the Recent header (completed threads only, failed ones stay), or tap Settle under failed thread until it is settled, however old. The feed shows the newest 10, then an "N more" row
Reply on the thread screen. A row leaves at once and comes back with the error if its call fails. 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 ## 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 | | `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 | | `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication |
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `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 | | `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 | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions |
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family | | `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
+12
View File
@@ -51,6 +51,10 @@ protocol GatewayAPI: Sendable {
func stop(hostId: String, threadId: String) async throws func stop(hostId: String, threadId: String) async throws
/// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed. /// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
func settle(hostId: String, threadId: String) async throws 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 projects() async throws -> ProjectsResponse
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
func registerPush(_ registration: PushRegistration) async throws 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?) 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 { func projects() async throws -> ProjectsResponse {
try await call("GET", ["v1", "projects"]) try await call("GET", ["v1", "projects"])
} }
+43
View File
@@ -167,8 +167,51 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
struct Feed: Codable, Hashable, Sendable { struct Feed: Codable, Hashable, Sendable {
var needsYou: [FeedItem] var needsYou: [FeedItem]
var working: [FeedItem] var working: [FeedItem]
/// Finished or failed threads not settled yet, newest first and at most 10.
var recent: [FeedItem] var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
var hosts: [HostSummary] 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. /// `GET /v1/summary`: the counts behind the complication.
+7 -1
View File
@@ -5,6 +5,8 @@ import Foundation
enum Route: Hashable, Sendable { enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String) case thread(hostId: String, threadId: String)
case newThread 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. /// The status overview, and from it one host or the notifications page.
case status case status
case host(id: String) case host(id: String)
@@ -12,7 +14,7 @@ enum Route: Hashable, Sendable {
static let scheme = "wristcode" 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`. /// `wristcode://host/<hostId>` or `wristcode://notifications`.
var url: URL { var url: URL {
var components = URLComponents() var components = URLComponents()
@@ -25,6 +27,8 @@ enum Route: Hashable, Sendable {
.joined(separator: "/") .joined(separator: "/")
case .newThread: case .newThread:
components.host = "new" components.host = "new"
case .recent:
components.host = "recent"
case .status: case .status:
components.host = "status" components.host = "status"
case let .host(id): case let .host(id):
@@ -47,6 +51,8 @@ enum Route: Hashable, Sendable {
self = .thread(hostId: parts[0], threadId: parts[1]) self = .thread(hostId: parts[0], threadId: parts[1])
case "new": case "new":
self = .newThread self = .newThread
case "recent":
self = .recent
case "status": case "status":
self = .status self = .status
case "host" where parts.count == 1 && !parts[0].isEmpty: case "host" where parts.count == 1 && !parts[0].isEmpty:
+58 -4
View File
@@ -30,18 +30,26 @@ final class AppModel {
private(set) var settling: Set<String> = [] private(set) var settling: Set<String> = []
/// Why settling an item failed, by item id. Its row shows the message. /// Why settling an item failed, by item id. Its row shows the message.
private(set) var settleErrors: [String: String] = [:] 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 apnsToken: String?
private var refreshing = false private var refreshing = false
/// The counts the complication was last told about. /// The counts the complication was last told about.
private var sharedSummary: Summary? 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.mode = mode
self.feed = feed self.feed = feed
self.allRecent = allRecent
self.path = path 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 /// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in
/// watch/screenshots are taken. /// watch/screenshots are taken.
convenience init(launchArguments args: [String]) { convenience init(launchArguments args: [String]) {
@@ -55,6 +63,7 @@ final class AppModel {
var path: [Route] = [] var path: [Route] = []
switch argument("-screen") { switch argument("-screen") {
case "new": path = [.newThread] case "new": path = [.newThread]
case "recent": path = [.recent]
case "status": path = [.status] case "status": path = [.status]
case "notifications": path = [.status, .notifications] case "notifications": path = [.status, .notifications]
default: break default: break
@@ -182,11 +191,56 @@ final class AppModel {
for (id, error) in failures { for (id, error) in failures {
settleErrors[id] = message(for: error) 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) settling.subtract(ids)
await refreshFeed() 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) { func open(_ route: Route) {
path = [route] path = [route]
} }
@@ -262,6 +316,6 @@ final class AppModel {
extension AppModel { extension AppModel {
/// A demo model with the fixture feed already loaded, for previews. /// A demo model with the fixture feed already loaded, for previews.
static func preview(path: [Route] = []) -> AppModel { 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)
} }
} }
+56 -12
View File
@@ -27,12 +27,7 @@ actor DemoGateway: GatewayAPI {
} }
func feed() async throws -> Feed { func feed() async throws -> Feed {
let items = items.filter { !settled.contains($0.id) } DemoFixtures.feed(items.filter { !settled.contains($0.id) }, hosts: hosts)
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)
} }
func summary() async throws -> Summary { func summary() async throws -> Summary {
@@ -93,6 +88,16 @@ actor DemoGateway: GatewayAPI {
settled.insert(item.id) 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 { func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts) ProjectsResponse(hosts: projectHosts)
} }
@@ -254,7 +259,28 @@ struct DemoFixtures {
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95), projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
lastMessage: "Fixed. hyprlock no longer crashes when a monitor sleeps.", request: nil) 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 { func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes)) 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)) 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. /// A feed for previews, without going through the actor.
static func previewFeed(now: Date = .now) -> Feed { static func previewFeed(now: Date = .now) -> Feed {
let fixtures = DemoFixtures(now: now) let fixtures = DemoFixtures(now: now)
return Feed( return feed(fixtures.items, hosts: fixtures.hosts)
needsYou: fixtures.items.filter(\.phase.needsYou), }
working: fixtures.items.filter(\.phase.isWorking),
recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking }, /// The full Recent list for previews.
hosts: fixtures.hosts) static func previewRecent(now: Date = .now) -> [FeedItem] {
recent(DemoFixtures(now: now).items)
} }
} }
+79 -26
View File
@@ -1,6 +1,7 @@
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first // 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. // (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 import SwiftUI
@@ -15,28 +16,25 @@ struct FeedView: View {
.feedRow() .feedRow()
if let feed = model.feed { 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 ForEach(feed.needsYou) { item in
NeedsYouCard(item: item).feedRow() NeedsYouCard(item: item).feedRow()
} }
ForEach(feed.working) { item in ForEach(feed.working) { item in
StatusCard(item: item).feedRow() StatusCard(item: item).feedRow()
} }
if !recent.isEmpty { if !recent.isEmpty || more > 0 {
RecentHeader(recent: recent).feedRow() RecentHeader(recent: recent, more: more).feedRow()
} }
ForEach(recent) { item in ForEach(recent) { item in
StatusCard(item: item) RecentRow(item: item).feedRow()
.feedRow()
.swipeActions(edge: .trailing) {
Button { Task { await model.settle([item]) } } label: {
Label("Settle", systemImage: "checkmark")
}
.tint(Theme.work)
}
} }
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty { if more > 0 {
Text("No threads in the last day.") MoreRecentRow(count: more).feedRow()
}
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 {
Text("No threads right now.")
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
.feedRow() .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 { func feedRow() -> some View {
listRowBackground(Color.clear) listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) .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. /// leaves failed threads, which settle one at a time by swipe.
struct RecentHeader: View { struct RecentHeader: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
var title = "Recent"
var recent: [FeedItem] var recent: [FeedItem]
/// Threads in Recent that `recent` leaves out.
var more = 0
var body: some View { var body: some View {
let completed = recent.filter { $0.phase == .completed } VStack(alignment: .leading, spacing: 2) {
HStack { HStack {
Text("Recent") Text(title)
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
Spacer() Spacer()
if !completed.isEmpty { if more > 0 || recent.contains(where: { $0.phase == .completed }) {
Button("Settle all") { Task { await model.settle(completed) } } Button("Settle all") { Task { await model.settleAllRecent() } }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Theme.work) .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(.horizontal, 8)
.padding(.top, 4) .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. /// 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.
struct NeedsYouCard: View { struct NeedsYouCard: View {
+61
View File
@@ -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)
}
+2
View File
@@ -33,6 +33,8 @@ struct RootView: View {
ThreadView(hostId: hostId, threadId: threadId) ThreadView(hostId: hostId, threadId: threadId)
case .newThread: case .newThread:
NewThreadView() NewThreadView()
case .recent:
RecentView()
case .status: case .status:
StatusView() StatusView()
case let .host(id): case let .host(id):
+44 -1
View File
@@ -58,6 +58,29 @@ private let feedItemJSON = """
#expect(request.quickOptions?.allow.label == "Allow") #expect(request.quickOptions?.allow.label == "Allow")
#expect(request.quickOptions?.deny.decision == .decline) #expect(request.quickOptions?.deny.decision == .decline)
#expect(request.summary == "bun test --filter ocr") #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 { @Test func subagentRequest() throws {
@@ -277,7 +300,7 @@ private let feedItemJSON = """
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1") #expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
#expect(Route(url: thread.url) == thread) #expect(Route(url: thread.url) == thread)
#expect(Route(url: Route.newThread.url) == .newThread) #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: route.url) == route)
} }
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil) #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 }) #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 { @Test func multiSelectAnswerMovesThreadToWorking() async throws {
let gateway = DemoGateway() let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true }) let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
+7 -1
View File
@@ -52,11 +52,17 @@ struct SnapshotTests {
@Test func recentWithSettleAll() async throws { @Test func recentWithSettleAll() async throws {
let feed = try await DemoGateway().feed() let feed = try await DemoGateway().feed()
try write(feedColumn { try write(feedColumn {
RecentHeader(recent: feed.recent) RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent) { StatusCard(item: $0) } ForEach(feed.recent) { StatusCard(item: $0) }
MoreRecentRow(count: feed.moreRecent)
}, "feed-recent.png") }, "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 { @Test func subagentThread() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab") 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") try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB