Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages
This commit is contained in:
1 parent
abd2fd0e13
commit
d916bfbab5
21 files changed
+928
-46
No files matched your search
@@ -46,6 +46,8 @@ protocol GatewayAPI: Sendable {
|
||||
func feed() async throws -> Feed
|
||||
func summary() async throws -> Summary
|
||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
|
||||
/// The 20 messages before `cursor`, a thread's `earlierCursor` or the last page's.
|
||||
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws
|
||||
func respond(hostId: String, threadId: String, request: RespondRequest) async throws
|
||||
func stop(hostId: String, threadId: String) async throws
|
||||
@@ -58,6 +60,8 @@ protocol GatewayAPI: Sendable {
|
||||
/// Settles every completed thread in Recent. Failed threads stay.
|
||||
func settleRecent() async throws -> SettleRecentResponse
|
||||
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
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
|
||||
func registerPush(_ registration: PushRegistration) async throws
|
||||
func status() async throws -> StatusResponse
|
||||
@@ -93,6 +97,10 @@ struct GatewayClient: GatewayAPI {
|
||||
try await call("GET", ["v1", "threads", hostId, threadId])
|
||||
}
|
||||
|
||||
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
|
||||
try await call("GET", ["v1", "threads", hostId, threadId, "history"], query: [URLQueryItem(name: "cursor", value: cursor)])
|
||||
}
|
||||
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text))
|
||||
}
|
||||
@@ -125,6 +133,10 @@ struct GatewayClient: GatewayAPI {
|
||||
try await call("GET", ["v1", "projects"])
|
||||
}
|
||||
|
||||
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
|
||||
try await call("GET", ["v1", "projects", hostId, projectId, "threads"])
|
||||
}
|
||||
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
||||
try await call("POST", ["v1", "threads"], body: request)
|
||||
}
|
||||
@@ -155,14 +167,16 @@ struct GatewayClient: GatewayAPI {
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
||||
try await call(method, path, body: nil as SendMessageRequest?)
|
||||
private func call<Response: Decodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = []
|
||||
) async throws -> Response {
|
||||
try await call(method, path, query: query, body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
private func call<Body: Encodable, Response: Decodable>(
|
||||
_ method: String, _ path: [String], body: Body?
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||
) async throws -> Response {
|
||||
let data = try await perform(method, path, body: body)
|
||||
let data = try await perform(method, path, query: query, body: body)
|
||||
do {
|
||||
return try JSON.decoder().decode(Response.self, from: data)
|
||||
} catch {
|
||||
@@ -174,9 +188,12 @@ struct GatewayClient: GatewayAPI {
|
||||
_ = try await perform(method, path, body: body)
|
||||
}
|
||||
|
||||
private func perform<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws -> Data {
|
||||
private func perform<Body: Encodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||
) async throws -> Data {
|
||||
// appending(component:) percent-encodes each segment, slashes included.
|
||||
let url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
var url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
if !query.isEmpty { url.append(queryItems: query) }
|
||||
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
@@ -154,6 +154,9 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||
/// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways
|
||||
/// leave it out, which decodes as nil.
|
||||
var via: Via? = nil
|
||||
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
|
||||
/// gateways.
|
||||
var activity: Activity? = nil
|
||||
|
||||
var id: String { "\(hostId)/\(threadId)" }
|
||||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||
@@ -164,6 +167,66 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One step a thread is taking now, for the working card's activity line.
|
||||
struct Activity: Codable, Hashable, Sendable {
|
||||
struct Kind: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let command = Kind(rawValue: "command")
|
||||
static let edit = Kind(rawValue: "edit")
|
||||
static let search = Kind(rawValue: "search")
|
||||
static let web = Kind(rawValue: "web")
|
||||
static let subagent = Kind(rawValue: "subagent")
|
||||
static let thinking = Kind(rawValue: "thinking")
|
||||
static let tool = Kind(rawValue: "tool")
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
/// The command line, file name, search, subagent title or tool name, trimmed by the gateway.
|
||||
var text: String
|
||||
/// When the step started, for its elapsed time.
|
||||
var startedAt: Date?
|
||||
}
|
||||
|
||||
/// A thread in a list: a project's threads, or a thread's subagents.
|
||||
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
|
||||
var hostId: String
|
||||
var threadId: String
|
||||
var title: String
|
||||
/// Nil for a thread that never ran or was stopped.
|
||||
var phase: Phase?
|
||||
var settled: Bool
|
||||
var updatedAt: Date
|
||||
|
||||
var id: String { "\(hostId)/\(threadId)" }
|
||||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||
|
||||
/// "working", "waiting", "settled", "failed" or "done", for a subagent's row.
|
||||
var statusWord: String {
|
||||
if phase?.isWorking == true { return "working" }
|
||||
if phase?.needsYou == true { return "waiting" }
|
||||
if settled { return "settled" }
|
||||
return phase == .failed ? "failed" : "done"
|
||||
}
|
||||
|
||||
/// False once it finished or settled, which dims its title.
|
||||
var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) }
|
||||
}
|
||||
|
||||
/// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first.
|
||||
struct ThreadListResponse: Codable, Hashable, Sendable {
|
||||
var items: [ThreadRow]
|
||||
}
|
||||
|
||||
/// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has.
|
||||
struct HistoryResponse: Codable, Hashable, Sendable {
|
||||
/// Oldest first.
|
||||
var messages: [Message]
|
||||
/// The cursor for the page before these. Nil at the start of the thread.
|
||||
var earlierCursor: String?
|
||||
}
|
||||
|
||||
struct Feed: Codable, Hashable, Sendable {
|
||||
var needsYou: [FeedItem]
|
||||
var working: [FeedItem]
|
||||
@@ -268,6 +331,29 @@ struct ThreadDetail: Codable, Hashable, Sendable {
|
||||
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
|
||||
/// leave it out, which decodes as nil, and the screen hides the switch.
|
||||
var askFirst: Bool? = nil
|
||||
/// Pass to the history route for older messages. Nil when `messages` starts the thread, and from
|
||||
/// older gateways, which hides Load earlier.
|
||||
var earlierCursor: String? = nil
|
||||
/// Subagents and delegated threads this thread started, newest first.
|
||||
var subagents: [ThreadRow] = []
|
||||
}
|
||||
|
||||
extension ThreadDetail {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case item, messages, canStop, askFirst, earlierCursor, subagents
|
||||
}
|
||||
|
||||
/// `subagents` is newer than the rest, so a gateway that leaves it out lists none.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.init(
|
||||
item: try container.decode(FeedItem.self, forKey: .item),
|
||||
messages: try container.decode([Message].self, forKey: .messages),
|
||||
canStop: try container.decode(Bool.self, forKey: .canStop),
|
||||
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
|
||||
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
|
||||
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v1/threads/:hostId/:threadId/mode`.
|
||||
|
||||
@@ -11,20 +11,26 @@ enum Route: Hashable, Sendable {
|
||||
case status
|
||||
case host(id: String)
|
||||
case notifications
|
||||
/// All threads: every host, then one host's projects, then one project's threads.
|
||||
case allThreads
|
||||
case hostProjects(hostId: String)
|
||||
case projectThreads(hostId: String, projectId: String)
|
||||
|
||||
static let scheme = "wristcode"
|
||||
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://status`,
|
||||
/// `wristcode://host/<hostId>` or `wristcode://notifications`.
|
||||
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
|
||||
/// `wristcode://projects/<hostId>` or `wristcode://project/<hostId>/<projectId>`.
|
||||
var url: URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = Self.scheme
|
||||
func path(_ parts: String...) -> String {
|
||||
"/" + parts.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }.joined(separator: "/")
|
||||
}
|
||||
switch self {
|
||||
case let .thread(hostId, threadId):
|
||||
components.host = "thread"
|
||||
components.percentEncodedPath = "/" + [hostId, threadId]
|
||||
.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }
|
||||
.joined(separator: "/")
|
||||
components.percentEncodedPath = path(hostId, threadId)
|
||||
case .newThread:
|
||||
components.host = "new"
|
||||
case .recent:
|
||||
@@ -33,9 +39,17 @@ enum Route: Hashable, Sendable {
|
||||
components.host = "status"
|
||||
case let .host(id):
|
||||
components.host = "host"
|
||||
components.percentEncodedPath = "/" + (id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id)
|
||||
components.percentEncodedPath = path(id)
|
||||
case .notifications:
|
||||
components.host = "notifications"
|
||||
case .allThreads:
|
||||
components.host = "threads"
|
||||
case let .hostProjects(hostId):
|
||||
components.host = "projects"
|
||||
components.percentEncodedPath = path(hostId)
|
||||
case let .projectThreads(hostId, projectId):
|
||||
components.host = "project"
|
||||
components.percentEncodedPath = path(hostId, projectId)
|
||||
}
|
||||
return components.url!
|
||||
}
|
||||
@@ -59,6 +73,12 @@ enum Route: Hashable, Sendable {
|
||||
self = .host(id: parts[0])
|
||||
case "notifications":
|
||||
self = .notifications
|
||||
case "threads":
|
||||
self = .allThreads
|
||||
case "projects" where parts.count == 1 && !parts[0].isEmpty:
|
||||
self = .hostProjects(hostId: parts[0])
|
||||
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
|
||||
self = .projectThreads(hostId: parts[0], projectId: parts[1])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user