Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages

This commit is contained in:
gib committed 2026-11-02 13:10:02 -05:00
1 parent abd2fd0e13
commit d916bfbab5
21 files changed
+928 -46

No files matched your search

+23 -6
View File
@@ -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")
+86
View File
@@ -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`.
+25 -5
View File
@@ -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
}