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: an approval push for a file change says which files it would edit
This commit is contained in:
1 parent
4dc4e57dac
commit
edc4b88990
35 files changed
+1162
-144
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user