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:
1 parent
2a127327bf
commit
a5222e14e8
13 files changed
+377
-45
No files matched your search
+15
-7
@@ -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 |
|
||||||
@@ -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"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 more > 0 {
|
||||||
|
MoreRecentRow(count: more).feedRow()
|
||||||
}
|
}
|
||||||
}
|
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 {
|
||||||
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty {
|
Text("No threads right now.")
|
||||||
Text("No threads in the last day.")
|
|
||||||
.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))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
if let error = model.settleAllError {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
.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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 |
Reference in new issue
Block a user