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

+98 -3
View File
@@ -91,9 +91,19 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
var reason: String?
var options: [Option]
var questions: [Question]
/// On `file-change` approvals, the files the agent wants to change. Older gateways leave it out,
/// which decodes as none.
var files: [PendingFile] = []
var isQuestion: Bool { !questions.isEmpty }
/// What a file-change approval would edit, for its card, thread screen and notification. Nil for
/// other requests and when the gateway names no files, which show the prompt instead.
var fileEdit: FileEdit? {
guard kind == "file-change" || kind == "file_change", !files.isEmpty else { return nil }
return FileEdit(files: files)
}
/// The Allow and Deny pair shown on a feed card, when the server offers both.
var quickOptions: (allow: Option, deny: Option)? {
guard let allow = options.first(where: { $0.decision == .accept }),
@@ -115,6 +125,76 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
}
}
extension PendingRequest {
private enum CodingKeys: String, CodingKey {
case id, kind, prompt, reason, options, questions, files
}
/// `files` is newer than the rest, so a gateway that leaves it out names no files.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
kind: try container.decode(String.self, forKey: .kind),
prompt: try container.decodeIfPresent(String.self, forKey: .prompt),
reason: try container.decodeIfPresent(String.self, forKey: .reason),
options: try container.decode([Option].self, forKey: .options),
questions: try container.decode([Question].self, forKey: .questions),
files: try container.decodeIfPresent([PendingFile].self, forKey: .files) ?? [])
}
}
/// A file a file-change approval would change. `name` is the last part of `path`. The gateway
/// usually has no diff before approval, so the counts are nil and `preview` is empty.
struct PendingFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int?
var deletions: Int?
/// The first few changed lines, each starting with `+` or `-`.
var preview: [String]
var id: String { path }
/// "+9" and "−6", each nil when the gateway did not count it.
var counts: (additions: String?, deletions: String?) {
(additions.map { "+\($0)" }, deletions.map { "\u{2212}\($0)" })
}
}
/// The lines a file-change approval shows in place of its prompt: "Wants to edit" over one file, or
/// "Wants to edit 3 files" over a row for each. One file also shows its preview lines.
struct FileEdit: Hashable, Sendable {
/// A preview line and whether it adds or removes, which colors it green or red.
struct Line: Hashable, Sendable, Identifiable {
enum Change: Sendable { case added, removed, context }
var id: Int
var text: String
var change: Change
}
var files: [PendingFile]
var title: String { files.count == 1 ? "Wants to edit" : "Wants to edit \(files.count) files" }
/// The single file's first changed lines. Empty with several files, as their rows show no diff.
var preview: [Line] {
guard files.count == 1 else { return [] }
return files[0].preview.enumerated().map { index, text in
let change: Line.Change = text.hasPrefix("+") ? .added : text.hasPrefix("-") ? .removed : .context
return Line(id: index, text: text, change: change)
}
}
/// One line for a notification or the Smart Stack: "Wants to edit compose.yml" or "Wants to edit
/// 3 files: compose.yml, Containerfile, health.ts".
var sentence: String {
let names = files.map(\.name).joined(separator: ", ")
return files.count == 1 ? "\(title) \(names)" : "\(title): \(names)"
}
}
extension PendingRequest.Question {
private enum CodingKeys: String, CodingKey {
case id, header, question, options, allowCustomAnswer, multiSelect
@@ -159,6 +239,8 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
var activity: Activity? = nil
/// What the latest run changed, in total. Nil when it changed no files, and from older gateways.
var changes: ChangeTotals? = nil
/// When a snoozed thread comes back to Recent. Nil when it is not snoozed, and from older gateways.
var snoozedUntil: Date? = nil
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
@@ -292,14 +374,20 @@ struct Feed: Codable, Hashable, Sendable {
var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
/// Finished threads snoozed for later, which `GET /v1/snoozed` lists.
var snoozedTotal: 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]) {
init(
needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, snoozedTotal: Int = 0,
hosts: [HostSummary]
) {
self.needsYou = needsYou
self.working = working
self.recent = recent
self.recentTotal = recentTotal ?? recent.count
self.snoozedTotal = snoozedTotal
self.hosts = hosts
}
@@ -309,10 +397,11 @@ struct Feed: Codable, Hashable, Sendable {
extension Feed {
private enum CodingKeys: String, CodingKey {
case needsYou, working, recent, recentTotal, hosts
case needsYou, working, recent, recentTotal, snoozedTotal, hosts
}
/// `recentTotal` is newer than the rest, so a gateway that leaves it out has nothing past `recent`.
/// `recentTotal` and `snoozedTotal` are newer than the rest, so a gateway that leaves them out has
/// nothing past `recent` and nothing snoozed.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
@@ -320,6 +409,7 @@ extension Feed {
working: try container.decode([FeedItem].self, forKey: .working),
recent: try container.decode([FeedItem].self, forKey: .recent),
recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal),
snoozedTotal: try container.decodeIfPresent(Int.self, forKey: .snoozedTotal) ?? 0,
hosts: try container.decode([HostSummary].self, forKey: .hosts))
}
}
@@ -329,6 +419,11 @@ struct RecentResponse: Codable, Hashable, Sendable {
var items: [FeedItem]
}
/// `POST /v1/threads/:hostId/:threadId/snooze`. `until` is in the future and at most 30 days away.
struct SnoozeRequest: Codable, Sendable {
var until: Date
}
/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline.
struct SettleRecentResponse: Codable, Hashable, Sendable {
var settled: Int