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
@@ -59,6 +59,12 @@ protocol GatewayAPI: Sendable {
|
||||
func recent() async throws -> RecentResponse
|
||||
/// Settles every completed thread in Recent. Failed threads stay.
|
||||
func settleRecent() async throws -> SettleRecentResponse
|
||||
/// Finished threads snoozed for later, the soonest to come back first.
|
||||
func snoozed() async throws -> RecentResponse
|
||||
/// Hides a finished thread from Recent until `until`, at most 30 days away.
|
||||
func snooze(hostId: String, threadId: String, until: Date) async throws
|
||||
/// Puts a snoozed thread back in Recent now.
|
||||
func unsnooze(hostId: String, threadId: String) async throws
|
||||
func projects() async throws -> ProjectsResponse
|
||||
/// A project's newest 50 threads, settled ones included. Subagents are left out.
|
||||
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse
|
||||
@@ -131,6 +137,18 @@ struct GatewayClient: GatewayAPI {
|
||||
try await call("POST", ["v1", "recent", "settle"])
|
||||
}
|
||||
|
||||
func snoozed() async throws -> RecentResponse {
|
||||
try await call("GET", ["v1", "snoozed"])
|
||||
}
|
||||
|
||||
func snooze(hostId: String, threadId: String, until: Date) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "snooze"], body: SnoozeRequest(until: until))
|
||||
}
|
||||
|
||||
func unsnooze(hostId: String, threadId: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "unsnooze"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
func projects() async throws -> ProjectsResponse {
|
||||
try await call("GET", ["v1", "projects"])
|
||||
}
|
||||
@@ -173,6 +191,12 @@ struct GatewayClient: GatewayAPI {
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
/// A failed request as the app sees it. A request cancelled because its task was, as when a
|
||||
/// screen's `.task` ends or a refresh restarts, is a `CancellationError`, which no screen shows.
|
||||
static func transportError(_ error: URLError) -> any Error {
|
||||
error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription)
|
||||
}
|
||||
|
||||
private func call<Response: Decodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = []
|
||||
) async throws -> Response {
|
||||
@@ -216,7 +240,7 @@ struct GatewayClient: GatewayAPI {
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch let error as URLError {
|
||||
throw GatewayError.transport(error.localizedDescription)
|
||||
throw Self.transportError(error)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,8 @@ enum Route: Hashable, Sendable {
|
||||
case newThread
|
||||
/// Every thread in Recent, past the feed's first 10.
|
||||
case recent
|
||||
/// Finished threads snoozed for later.
|
||||
case snoozed
|
||||
/// The status overview, and from it one host or the notifications page.
|
||||
case status
|
||||
case host(id: String)
|
||||
@@ -20,7 +22,8 @@ enum Route: Hashable, Sendable {
|
||||
|
||||
static let scheme = "wristcode"
|
||||
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`,
|
||||
/// `wristcode://status`,
|
||||
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
|
||||
/// `wristcode://projects/<hostId>`, `wristcode://project/<hostId>/<projectId>` or `wristcode://search?q=<query>`.
|
||||
var url: URL {
|
||||
@@ -37,6 +40,8 @@ enum Route: Hashable, Sendable {
|
||||
components.host = "new"
|
||||
case .recent:
|
||||
components.host = "recent"
|
||||
case .snoozed:
|
||||
components.host = "snoozed"
|
||||
case .status:
|
||||
components.host = "status"
|
||||
case let .host(id):
|
||||
@@ -72,6 +77,8 @@ enum Route: Hashable, Sendable {
|
||||
self = .newThread
|
||||
case "recent":
|
||||
self = .recent
|
||||
case "snoozed":
|
||||
self = .snoozed
|
||||
case "status":
|
||||
self = .status
|
||||
case "host" where parts.count == 1 && !parts[0].isEmpty:
|
||||
|
||||
Reference in new issue
Block a user