Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
gib committed 2026-11-02 14:45:34 -05:00
1 parent 4dc4e57dac
commit edc4b88990
35 files changed
+1162 -144

No files matched your search

+91 -14
View File
@@ -1,7 +1,8 @@
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
// answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, Ask before acting and the notification switches stick, and so on.
// hides a thread from the feed, snoozing moves it from Recent to Snoozed, Ask before acting and the
// notification switches stick, and so on.
// Subagent threads open like any other but stay out of the feed, as the gateway's do. Search matches
// titles and messages on the online hosts.
@@ -43,7 +44,7 @@ actor DemoGateway: GatewayAPI {
}
func feed() async throws -> Feed {
DemoFixtures.feed(unsettled, hosts: hosts)
DemoFixtures.feed(unsettled, hosts: hosts, now: .now)
}
func summary() async throws -> Summary {
@@ -129,15 +130,32 @@ actor DemoGateway: GatewayAPI {
}
func recent() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.recent(unsettled))
RecentResponse(items: DemoFixtures.recent(unsettled, now: .now))
}
func settleRecent() async throws -> SettleRecentResponse {
let completed = DemoFixtures.recent(unsettled).filter { $0.phase == .completed }
let completed = DemoFixtures.recent(unsettled, now: .now).filter { $0.phase == .completed }
settled.formUnion(completed.map(\.id))
return SettleRecentResponse(settled: completed.count, failed: 0)
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.snoozed(unsettled, now: .now))
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try update(hostId, threadId) { item in
guard item.phase == .completed || item.phase == .failed else {
throw GatewayError.http(status: 409, message: "Only a finished thread can be snoozed.")
}
item.snoozedUntil = until
}
}
func unsnooze(hostId: String, threadId: String) async throws {
try update(hostId, threadId) { $0.snoozedUntil = nil }
}
func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts)
}
@@ -292,6 +310,31 @@ struct DemoFixtures {
],
questions: []))
// File approvals: one with the diff the gateway rarely has before approval, one with the name
// only, which is what Claude sends.
func fileApproval(_ id: String, _ files: [PendingFile]) -> PendingRequest {
PendingRequest(
id: id, kind: "file-change", prompt: "Edit: \(files[0].path)", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: [], files: files)
}
let compose = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-compose", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway restart policy", phase: .waitingForApproval,
updatedAt: ago(2), lastMessage: "Switching the gateway to the Alpine image and restarting it on failure.",
request: fileApproval("r-compose", [
PendingFile(
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
preview: ["- image: oven/bun:1", "+ image: oven/bun:1-alpine", "+ restart: always"]),
]))
let healthRoute = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-health-route", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Health route", phase: .waitingForApproval,
updatedAt: ago(0.8), lastMessage: "Adding a /healthz route the container check can call.",
request: fileApproval("r-health", [
PendingFile(path: "gateway/src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: []),
]))
let question = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
@@ -408,6 +451,15 @@ struct DemoFixtures {
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
]
// Finished threads snoozed for later: one back in a few hours, one tomorrow morning.
let calendar = Calendar.current
let later = calendar.nextDate(after: now.addingTimeInterval(2 * 3600), matching: DateComponents(minute: 0), matchingPolicy: .nextTime)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))?.addingTimeInterval(9 * 3600)
var dependabot = older(mac, "t-deps", "gbrown.org", "Dependabot bumps", hours: 3)
dependabot.snoozedUntil = later
var plex = older(server, "t-plex", "homelab", "Plex transcoding", hours: 8)
plex.snoozedUntil = tomorrow
// Settled threads, which only the project's thread list shows.
let settledThreads = [
older(mac, "t-switches", "wristcode", "Phase 2 switches", hours: 26),
@@ -428,8 +480,8 @@ struct DemoFixtures {
cdn.id: [file("src/headers.rs", 14, 6)],
]
changedFiles = changed
items = ([approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + settledThreads + [path, image, healthcheck]).map { item in
items = ([approval, compose, healthRoute, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + [dependabot, plex] + settledThreads + [path, image, healthcheck]).map { item in
guard let files = changed[item.id] else { return item }
var item = item
item.changes = ChangeTotals(
@@ -572,20 +624,40 @@ struct DemoFixtures {
}
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
/// newest first, and only the first 10 of Recent.
static func feed(_ items: [FeedItem], hosts: [HostSummary]) -> Feed {
let recent = recent(items)
/// newest first, only the first 10 of Recent, and a count of the snoozed.
static func feed(_ items: [FeedItem], hosts: [HostSummary], now: Date) -> Feed {
let recent = recent(items, now: now)
return Feed(
needsYou: items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt },
working: items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt },
recent: Array(recent.prefix(10)),
recentTotal: recent.count,
snoozedTotal: snoozed(items, now: now).count,
hosts: hosts)
}
/// Every finished or failed thread, newest first, as `GET /v1/recent` lists them.
static func recent(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }.sorted { $0.updatedAt > $1.updatedAt }
/// Every finished or failed thread not snoozed, newest first, as `GET /v1/recent` lists them. A
/// snooze whose time has come no longer counts.
static func recent(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) <= now }
.map { item in
var item = item
item.snoozedUntil = nil
return item
}
.sorted { $0.updatedAt > $1.updatedAt }
}
/// Finished threads still snoozed, the soonest to come back first, as `GET /v1/snoozed` lists them.
static func snoozed(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) > now }
.sorted { $0.snoozedUntil! < $1.snoozedUntil! }
}
private static func finished(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }
}
/// Threads the feed lists: not settled, and not a subagent.
@@ -597,11 +669,16 @@ struct DemoFixtures {
/// A feed for previews, without going through the actor.
static func previewFeed(now: Date = .now) -> Feed {
let fixtures = DemoFixtures(now: now)
return feed(fixtures.listed, hosts: fixtures.hosts)
return feed(fixtures.listed, hosts: fixtures.hosts, now: now)
}
/// The full Recent list for previews.
static func previewRecent(now: Date = .now) -> [FeedItem] {
recent(DemoFixtures(now: now).listed)
recent(DemoFixtures(now: now).listed, now: now)
}
/// The Snoozed list for previews.
static func previewSnoozed(now: Date = .now) -> [FeedItem] {
snoozed(DemoFixtures(now: now).listed, now: now)
}
}