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

+12
View File
@@ -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"])
}
+43
View File
@@ -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.
+7 -1
View File
@@ -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:
+58 -4
View File
@@ -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)
}
}
+56 -12
View File
@@ -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)
}
}
+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
// (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 {
+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)
case .newThread:
NewThreadView()
case .recent:
RecentView()
case .status:
StatusView()
case let .host(id):