diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift new file mode 100644 index 0000000..0818e0e --- /dev/null +++ b/watch/WristCode/API/GatewayClient.swift @@ -0,0 +1,152 @@ +// HTTPS + JSON calls to the WristCode gateway. docs/api.md lists every route. + +import Foundation + +enum GatewayError: LocalizedError, Equatable { + /// 401: the device token is missing or revoked. The app shows the pairing screen. + case unauthorized + /// Any other non-2xx status, with the gateway's `{ "error": ... }` message when it sent one. + case http(status: Int, message: String?) + case transport(String) + case decoding(String) + + var errorDescription: String? { + switch self { + case .unauthorized: + "This watch is no longer paired." + case let .http(status, message): + message ?? Self.defaultMessage(status) + case let .transport(message): + message + case let .decoding(message): + "The gateway sent something unexpected. \(message)" + } + } + + /// True for 409, when the request was stale, such as an approval someone already answered. + var isStale: Bool { + if case .http(409, _) = self { return true } + return false + } + + private static func defaultMessage(_ status: Int) -> String { + switch status { + case 404: "Not found." + case 409: "Already handled." + case 429: "Too many attempts. Wait a minute." + case 503: "That host is offline." + default: "The gateway returned \(status)." + } + } +} + +/// Everything the watch asks of a gateway. `GatewayClient` talks to the real one and `DemoGateway` +/// serves fixtures, so every screen works without a live gateway. +protocol GatewayAPI: Sendable { + func feed() async throws -> Feed + func thread(hostId: String, threadId: String) async throws -> ThreadDetail + 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 + func projects() async throws -> ProjectsResponse + func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse + func registerPush(_ registration: PushRegistration) async throws +} + +struct GatewayClient: GatewayAPI { + let baseURL: URL + let token: String? + var session: URLSession = .shared + + /// `POST /v1/pair`. The only call made without a device token. + static func pair(baseURL: URL, code: String, deviceName: String) async throws -> PairResponse { + let client = GatewayClient(baseURL: baseURL, token: nil) + return try await client.call("POST", ["v1", "pair"], body: PairRequest(code: code, deviceName: deviceName)) + } + + func feed() async throws -> Feed { + try await call("GET", ["v1", "feed"]) + } + + func thread(hostId: String, threadId: String) async throws -> ThreadDetail { + try await call("GET", ["v1", "threads", hostId, threadId]) + } + + func sendMessage(hostId: String, threadId: String, text: String) async throws { + try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text)) + } + + func respond(hostId: String, threadId: String, request: RespondRequest) async throws { + try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "respond"], body: request) + } + + func stop(hostId: String, threadId: String) async throws { + try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?) + } + + func projects() async throws -> ProjectsResponse { + try await call("GET", ["v1", "projects"]) + } + + func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { + try await call("POST", ["v1", "threads"], body: request) + } + + func registerPush(_ registration: PushRegistration) async throws { + try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration) + } + + // MARK: - Plumbing + + private func call(_ method: String, _ path: [String]) async throws -> Response { + try await call(method, path, body: nil as SendMessageRequest?) + } + + private func call( + _ method: String, _ path: [String], body: Body? + ) async throws -> Response { + let data = try await perform(method, path, body: body) + do { + return try JSON.decoder().decode(Response.self, from: data) + } catch { + throw GatewayError.decoding(String(describing: error)) + } + } + + private func callIgnoringBody(_ method: String, _ path: [String], body: Body?) async throws { + _ = try await perform(method, path, body: body) + } + + private func perform(_ method: String, _ path: [String], body: Body?) async throws -> Data { + // appending(component:) percent-encodes each segment, slashes included. + let url = path.reduce(baseURL) { $0.appending(component: $1) } + var request = URLRequest(url: url, timeoutInterval: 20) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSON.encoder().encode(body) + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let error as URLError { + throw GatewayError.transport(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw GatewayError.transport("No HTTP response.") + } + guard (200 ..< 300).contains(http.statusCode) else { + if http.statusCode == 401 { throw GatewayError.unauthorized } + let message = try? JSON.decoder().decode(ErrorResponse.self, from: data).error + throw GatewayError.http(status: http.statusCode, message: message) + } + return data + } +} diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift new file mode 100644 index 0000000..98becd3 --- /dev/null +++ b/watch/WristCode/API/Models.swift @@ -0,0 +1,274 @@ +// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans. +// Change both sides together. +// +// String enums from the gateway (phase, host status, approval decision) are modeled as +// RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still +// decodes instead of failing the whole feed. + +import Foundation + +struct Phase: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + init(rawValue: String) { self.rawValue = rawValue } + + static let starting = Phase(rawValue: "starting") + static let running = Phase(rawValue: "running") + static let waitingForApproval = Phase(rawValue: "waiting_for_approval") + static let waitingForInput = Phase(rawValue: "waiting_for_input") + static let completed = Phase(rawValue: "completed") + static let failed = Phase(rawValue: "failed") + + var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput } + var isWorking: Bool { self == .starting || self == .running } +} + +struct HostStatus: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + init(rawValue: String) { self.rawValue = rawValue } + + static let connecting = HostStatus(rawValue: "connecting") + static let online = HostStatus(rawValue: "online") + static let offline = HostStatus(rawValue: "offline") + static let authInvalid = HostStatus(rawValue: "auth_invalid") + static let incompatible = HostStatus(rawValue: "incompatible") + + /// A short label for a host that is not online, such as "offline" or "needs a new token". + var problem: String? { + switch self { + case .online: nil + case .connecting: "connecting" + case .authInvalid: "needs a new token" + case .incompatible: "incompatible" + default: "offline" + } + } +} + +struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + init(rawValue: String) { self.rawValue = rawValue } + + static let accept = ApprovalDecision(rawValue: "accept") + static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession") + static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways") + static let decline = ApprovalDecision(rawValue: "decline") + static let cancel = ApprovalDecision(rawValue: "cancel") +} + +struct HostSummary: Codable, Hashable, Sendable, Identifiable { + var id: String + var label: String + var status: HostStatus + var error: String? +} + +/// Something a thread is waiting on: an approval, or questions for the user. +struct PendingRequest: Codable, Hashable, Sendable, Identifiable { + struct Option: Codable, Hashable, Sendable { + var decision: ApprovalDecision + var label: String + } + + struct Question: Codable, Hashable, Sendable, Identifiable { + struct Choice: Codable, Hashable, Sendable { + var label: String + var value: String + } + + var id: String + var header: String + var question: String + var options: [Choice] + var allowCustomAnswer: Bool + } + + var id: String + /// `command`, `file-change`, `permission`, `user_input`, ... + var kind: String + var prompt: String? + var reason: String? + var options: [Option] + var questions: [Question] + + var isQuestion: Bool { !questions.isEmpty } + + /// 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 }), + let deny = options.first(where: { $0.decision == .decline }) + else { return nil } + return (allow, deny) + } + + /// One line saying what the agent wants, for a feed card. + var summary: String { + if let question = questions.first { return question.question } + if let prompt, !prompt.isEmpty { return prompt } + if let reason, !reason.isEmpty { return reason } + return switch kind { + case "command": "Run a command" + case "file-change", "file_change": "Change files" + default: "Approve a request" + } + } +} + +struct FeedItem: Codable, Hashable, Sendable, Identifiable { + var hostId: String + var hostLabel: String + var threadId: String + var projectId: String + var projectTitle: String + var title: String + var phase: Phase + var updatedAt: Date + var lastMessage: String? + var request: PendingRequest? + + var id: String { "\(hostId)/\(threadId)" } + var route: Route { .thread(hostId: hostId, threadId: threadId) } +} + +struct Feed: Codable, Hashable, Sendable { + var needsYou: [FeedItem] + var working: [FeedItem] + var recent: [FeedItem] + var hosts: [HostSummary] +} + +struct Message: Codable, Hashable, Sendable, Identifiable { + struct Role: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + init(rawValue: String) { self.rawValue = rawValue } + static let user = Role(rawValue: "user") + static let assistant = Role(rawValue: "assistant") + static let system = Role(rawValue: "system") + } + + var id: String + var role: Role + var text: String + var createdAt: Date +} + +struct ThreadDetail: Codable, Hashable, Sendable { + var item: FeedItem + /// Oldest first, at most 20. + var messages: [Message] + var canStop: Bool +} + +struct ProjectsResponse: Codable, Hashable, Sendable { + struct Host: Codable, Hashable, Sendable, Identifiable { + var id: String + var label: String + var status: HostStatus + var error: String? + var projects: [Project] + + /// Projects a new thread can start in. A project without a default model is not offered. + var launchable: [Project] { projects.filter { $0.model != nil } } + } + + struct Project: Codable, Hashable, Sendable, Identifiable { + var id: String + var title: String + var model: String? + } + + var hosts: [Host] +} + +struct PairRequest: Codable, Sendable { + var code: String + var deviceName: String +} + +struct PairResponse: Codable, Sendable { + var deviceId: String + var deviceToken: String +} + +struct PushRegistration: Codable, Sendable { + enum Environment: String, Codable, Sendable { + case sandbox, production + } + + var apnsToken: String + var apnsEnvironment: Environment +} + +struct SendMessageRequest: Codable, Sendable { + var text: String +} + +enum RespondRequest: Encodable, Sendable { + case decision(requestId: String, decision: ApprovalDecision) + case answers(requestId: String, answers: [String: String]) + + private enum CodingKeys: String, CodingKey { case requestId, decision, answers } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .decision(requestId, decision): + try container.encode(requestId, forKey: .requestId) + try container.encode(decision, forKey: .decision) + case let .answers(requestId, answers): + try container.encode(requestId, forKey: .requestId) + try container.encode(answers, forKey: .answers) + } + } +} + +struct NewThreadRequest: Codable, Sendable { + var hostId: String + var projectId: String + var text: String +} + +struct NewThreadResponse: Codable, Sendable { + var hostId: String + var threadId: String +} + +struct ErrorResponse: Codable, Sendable { + var error: String +} + +/// Notification categories the watch registers actions for. +enum PushCategory: String, CaseIterable, Sendable { + case approval, finished, failed, input, host +} + +enum JSON { + static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + guard let date = parseDate(string) else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "Not an ISO 8601 date: \(string)") + } + return date + } + return decoder + } + + static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)) + } + return encoder + } + + /// Parses ISO 8601 timestamps with or without fractional seconds. + static func parseDate(_ string: String) -> Date? { + let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + if let date = try? withFraction.parse(string) { return date } + return try? Date.ISO8601FormatStyle().parse(string) + } +} diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift new file mode 100644 index 0000000..6537fb1 --- /dev/null +++ b/watch/WristCode/AppModel.swift @@ -0,0 +1,168 @@ +// App-wide state: whether the watch is paired, the navigation path, and the latest feed. + +import Foundation +import Observation +import UserNotifications +import WatchKit + +enum Route: Hashable, Sendable { + case thread(hostId: String, threadId: String) + case newThread +} + +@MainActor @Observable +final class AppModel { + enum Mode: Equatable { + case unpaired + case paired(Credentials) + /// Serves fixtures from `DemoGateway`. Nothing leaves the watch. + case demo + } + + /// The one instance the app delegate and notification handler share with the views. + static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments) + + var mode: Mode + var path: [Route] = [] + private(set) var feed: Feed? + private(set) var feedError: String? + private var apnsToken: String? + private var refreshing = false + + init(mode: Mode, feed: Feed? = nil, path: [Route] = []) { + self.mode = mode + self.feed = feed + self.path = path + } + + /// `-demo` starts in demo mode. `-screen new` or `-thread /` opens that + /// screen, which is how the screenshots in watch/screenshots are taken. + convenience init(launchArguments args: [String]) { + let demo = args.contains("-demo") + let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired + var path: [Route] = [] + if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" { + path = [.newThread] + } + if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count { + let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init) + if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] } + } + self.init(mode: mode, path: path) + } + + var isPaired: Bool { + if case .paired = mode { return true } + return false + } + + var api: any GatewayAPI { + switch mode { + case let .paired(credentials): + GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token) + case .demo: + DemoGateway.shared + case .unpaired: + GatewayClient(baseURL: URL(string: DeviceStore.defaultGatewayURL)!, token: nil) + } + } + + // MARK: - Pairing + + func pair(gatewayURL: URL, code: String) async throws { + let deviceName = WKInterfaceDevice.current().name + let response = try await GatewayClient.pair(baseURL: gatewayURL, code: code, deviceName: deviceName) + let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken) + DeviceStore.save(credentials) + mode = .paired(credentials) + feed = nil + feedError = nil + path = [] + await enableNotifications() + } + + func startDemo() { + mode = .demo + feed = nil + feedError = nil + path = [] + } + + /// Back to the pairing screen. A real token is forgotten; the gateway URL is kept. + func signOut() { + if isPaired { DeviceStore.clearToken() } + mode = .unpaired + feed = nil + feedError = nil + path = [] + } + + /// The message to show for a failed call. A 401 sends the app back to pairing and returns nil. + func message(for error: any Error) -> String? { + if error is CancellationError { return nil } + if let error = error as? GatewayError, error == .unauthorized { + signOut() + return nil + } + return error.localizedDescription + } + + // MARK: - Feed + + func refreshFeed() async { + guard mode != .unpaired, !refreshing else { return } + refreshing = true + defer { refreshing = false } + do { + feed = try await api.feed() + feedError = nil + } catch { + feedError = message(for: error) + } + } + + func open(_ route: Route) { + path = [route] + } + + // MARK: - Notifications + + func enableNotifications() async { + guard isPaired else { return } + let center = UNUserNotificationCenter.current() + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + if granted { + WKApplication.shared().registerForRemoteNotifications() + } + } + + func didRegister(apnsToken data: Data) { + apnsToken = data.map { String(format: "%02x", $0) }.joined() + Task { await sendPushToken() } + } + + private func sendPushToken() async { + guard isPaired, let apnsToken else { return } + #if DEBUG + let environment = PushRegistration.Environment.sandbox + #else + let environment = PushRegistration.Environment.production + #endif + do { + try await api.registerPush(PushRegistration(apnsToken: apnsToken, apnsEnvironment: environment)) + } catch { + _ = message(for: error) + } + } + + func notificationArrived() { + Task { await refreshFeed() } + } +} + +extension AppModel { + /// A demo model with the fixture feed already loaded, for previews. + static func preview(path: [Route] = []) -> AppModel { + AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path) + } +} diff --git a/watch/WristCode/Assets.xcassets/AppIcon.appiconset/Contents.json b/watch/WristCode/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..fd577bc --- /dev/null +++ b/watch/WristCode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "icon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/watch/WristCode/Assets.xcassets/AppIcon.appiconset/icon.png b/watch/WristCode/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000..bd90db7 Binary files /dev/null and b/watch/WristCode/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/watch/WristCode/Assets.xcassets/Contents.json b/watch/WristCode/Assets.xcassets/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/watch/WristCode/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/watch/WristCode/Auth/DeviceStore.swift b/watch/WristCode/Auth/DeviceStore.swift new file mode 100644 index 0000000..271e9cc --- /dev/null +++ b/watch/WristCode/Auth/DeviceStore.swift @@ -0,0 +1,94 @@ +// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so +// a notification action handled in the background can still call the gateway. + +import Foundation +import Security + +struct Credentials: Equatable, Sendable { + var gatewayURL: URL + var token: String +} + +enum DeviceStore { + static let defaultGatewayURL = "https://wristcode.gbrown.org" + + private static let service = "org.gbrown.wristcode" + private static let tokenAccount = "deviceToken" + private static let urlAccount = "gatewayURL" + + static func load() -> Credentials? { + guard let token = read(tokenAccount), + let urlString = read(urlAccount), + let url = URL(string: urlString) + else { return nil } + return Credentials(gatewayURL: url, token: token) + } + + static func save(_ credentials: Credentials) { + write(credentials.gatewayURL.absoluteString, account: urlAccount) + write(credentials.token, account: tokenAccount) + } + + /// Forgets the token but keeps the URL, so the pairing screen is prefilled. + static func clearToken() { + delete(tokenAccount) + } + + /// The last gateway URL used, for prefilling the pairing screen. + static var lastGatewayURL: String { + read(urlAccount) ?? defaultGatewayURL + } + + /// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is + /// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL. + static func normalizeGatewayURL(_ input: String) -> URL? { + var text = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if !text.contains("://") { text = "https://" + text } + while text.hasSuffix("/") { text.removeLast() } + guard let components = URLComponents(string: text), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let host = components.host, !host.isEmpty + else { return nil } + return components.url + } + + // MARK: - Keychain + + private static func query(_ account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + private static func read(_ account: String) -> String? { + var query = query(account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func write(_ value: String, account: String) { + let data = Data(value.utf8) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + let item = query(account).merging(attributes) { _, new in new } + SecItemAdd(item as CFDictionary, nil) + } + } + + private static func delete(_ account: String) { + SecItemDelete(query(account) as CFDictionary) + } +} diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift new file mode 100644 index 0000000..2ab5bff --- /dev/null +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -0,0 +1,239 @@ +// 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, and so on. + +import Foundation + +actor DemoGateway: GatewayAPI { + static let shared = DemoGateway() + + private var items: [FeedItem] + private var messages: [String: [Message]] + private let hosts: [HostSummary] + private let projectHosts: [ProjectsResponse.Host] + private var nextId = 1 + + init(now: Date = .now) { + let fixtures = DemoFixtures(now: now) + items = fixtures.items + messages = fixtures.messages + hosts = fixtures.hosts + projectHosts = fixtures.projectHosts + } + + func feed() async throws -> Feed { + let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt } + let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt } + let recent = items.filter { $0.phase == .completed || $0.phase == .failed } + .sorted { $0.updatedAt > $1.updatedAt } + return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts) + } + + func thread(hostId: String, threadId: String) async throws -> ThreadDetail { + guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") } + return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking) + } + + func sendMessage(hostId: String, threadId: String, text: String) async throws { + try update(hostId, threadId) { item in + append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id) + if !item.phase.isWorking { + item.phase = .running + item.lastMessage = "Working on it." + } + } + } + + func respond(hostId: String, threadId: String, request: RespondRequest) async throws { + try update(hostId, threadId) { item in + guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") } + item.request = nil + switch request { + case let .decision(_, decision) where decision == .decline || decision == .cancel: + item.phase = .completed + item.lastMessage = "OK, I won't run it. Tell me what to do instead." + default: + item.phase = .running + item.lastMessage = "Thanks. Continuing." + } + if let line = item.lastMessage { + append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id) + } + } + } + + func stop(hostId: String, threadId: String) async throws { + try update(hostId, threadId) { item in + item.phase = .completed + item.lastMessage = "Stopped." + } + } + + func projects() async throws -> ProjectsResponse { + ProjectsResponse(hosts: projectHosts) + } + + func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { + guard let host = projectHosts.first(where: { $0.id == request.hostId }), + let project = host.projects.first(where: { $0.id == request.projectId }) + else { throw GatewayError.http(status: 404, message: "Unknown project.") } + let threadId = "demo-\(newId())" + let item = FeedItem( + hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id, + projectTitle: project.title, title: String(request.text.prefix(40)), phase: .starting, + updatedAt: .now, lastMessage: nil, request: nil) + items.append(item) + messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)] + return NewThreadResponse(hostId: host.id, threadId: threadId) + } + + func registerPush(_ registration: PushRegistration) async throws {} + + // MARK: - Helpers + + private func item(_ hostId: String, _ threadId: String) -> FeedItem? { + items.first { $0.hostId == hostId && $0.threadId == threadId } + } + + private func update(_ hostId: String, _ threadId: String, _ change: (inout FeedItem) throws -> Void) throws { + guard let index = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }) else { + throw GatewayError.http(status: 404, message: "Unknown thread.") + } + try change(&items[index]) + items[index].updatedAt = .now + } + + private func append(_ message: Message, to key: String) { + messages[key, default: []].append(message) + } + + private func newId() -> String { + nextId += 1 + return "m\(nextId)" + } +} + +struct DemoFixtures { + let items: [FeedItem] + let messages: [String: [Message]] + let hosts: [HostSummary] + let projectHosts: [ProjectsResponse.Host] + + init(now: Date) { + func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) } + + hosts = [ + HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil), + HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil), + HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"), + ] + + let approval = FeedItem( + hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-ocr", projectId: "p-bill", + projectTitle: "bill-tracker", title: "Receipt OCR", phase: .waitingForApproval, + updatedAt: ago(3), lastMessage: "Parser and tests are written. I want to run the suite.", + request: PendingRequest( + id: "r-ocr", kind: "command", prompt: "bun test --filter ocr", reason: nil, + options: [ + .init(decision: .accept, label: "Allow"), + .init(decision: .acceptForSession, label: "Allow this session"), + .init(decision: .decline, label: "Deny"), + ], + questions: [])) + + let question = FeedItem( + hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc", + projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput, + updatedAt: ago(1), lastMessage: "Two ways to store pairing codes.", + request: PendingRequest( + id: "r-db", kind: "user_input", prompt: nil, reason: nil, options: [], + questions: [ + .init( + id: "q1", header: "Storage", question: "Hash pairing codes or store them plain?", + options: [.init(label: "Hash them", value: "hash"), .init(label: "Plain", value: "plain")], + allowCustomAnswer: true), + ])) + + let scaffold = FeedItem( + hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac", + projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5), + lastMessage: "Writing ThreadListView.swift", request: nil) + + let containerfile = FeedItem( + hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc", + projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2), + lastMessage: "Running podman build", request: nil) + + let nextjs = FeedItem( + hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site", + projectTitle: "gbrown.org", title: "Bump Next.js", phase: .completed, updatedAt: ago(62), + lastMessage: "Done. Next.js is on 17.1 and the build passes. Want me to commit it?", request: nil) + + let cdn = FeedItem( + hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-cdn", projectId: "p-cdn", + projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140), + lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil) + + items = [approval, question, scaffold, containerfile, nextjs, cdn] + + func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message { + Message(id: id, role: role, text: text, createdAt: ago(minutes)) + } + + messages = [ + approval.id: [ + m("a1", .user, 20, "Add OCR for receipts. Use the existing parser module."), + m("a2", .assistant, 12, "I'll add a receipt parser in src/ocr and tests beside it."), + m("a3", .assistant, 3, "Parser and tests are written. I want to run the suite."), + ], + question.id: [ + m("b1", .user, 10, "Add pairing codes to the gateway."), + m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."), + ], + scaffold.id: [ + m("c1", .user, 4, "Scaffold the watch app with XcodeGen."), + m("c2", .assistant, 0.5, "Writing ThreadListView.swift"), + ], + containerfile.id: [ + m("d1", .user, 15, "Write a Containerfile for the gateway."), + m("d2", .assistant, 2, "Running podman build"), + ], + nextjs.id: [ + m("e1", .user, 90, "Bump Next.js to the latest release."), + m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."), + m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"), + ], + cdn.id: [ + m("f1", .user, 150, "Fix the cache headers the CDN sends for images."), + m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"), + ], + ] + + projectHosts = [ + .init( + id: "mac-mini", label: "Mac mini", status: .online, error: nil, + projects: [ + .init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5"), + .init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5"), + .init(id: "p-cdn", title: "cdn", model: "gpt-5-codex"), + .init(id: "p-scratch", title: "scratch", model: nil), + ]), + .init( + id: "server-gib", label: "server-gib", status: .online, error: nil, + projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5")]), + .init( + id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", + projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]), + ] + } + + /// A feed for previews, without going through the actor. + static func previewFeed(now: Date = .now) -> Feed { + let fixtures = DemoFixtures(now: now) + return Feed( + needsYou: fixtures.items.filter(\.phase.needsYou), + working: fixtures.items.filter(\.phase.isWorking), + recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking }, + hosts: fixtures.hosts) + } +} diff --git a/watch/WristCode/Info.plist b/watch/WristCode/Info.plist new file mode 100644 index 0000000..56d5495 --- /dev/null +++ b/watch/WristCode/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WristCode + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKWatchOnly + + + diff --git a/watch/WristCode/Notifications/NotificationHandler.swift b/watch/WristCode/Notifications/NotificationHandler.swift new file mode 100644 index 0000000..7bbc73c --- /dev/null +++ b/watch/WristCode/Notifications/NotificationHandler.swift @@ -0,0 +1,136 @@ +// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny +// and Reply run in the background against the gateway; tapping a notification opens its thread. + +import Foundation +import UserNotifications + +/// What a notification response asks for, parsed out of the payload. +enum NotificationCommand: Equatable, Sendable { + case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision) + case reply(hostId: String, threadId: String, text: String) + case open(Route?) + case none + + enum Action { + static let allow = "allow" + static let deny = "deny" + static let reply = "reply" + static let open = "open" + } + + /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`. + init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) { + let hostId = userInfo["hostId"] as? String + let threadId = userInfo["threadId"] as? String + let requestId = userInfo["requestId"] as? String + let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil } + + switch actionIdentifier { + case Action.allow, Action.deny: + guard let hostId, let threadId, let requestId else { self = .open(thread); return } + self = .respond( + hostId: hostId, threadId: threadId, requestId: requestId, + decision: actionIdentifier == Action.allow ? .accept : .decline) + case Action.reply: + let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return } + self = .reply(hostId: hostId, threadId: threadId, text: text) + case UNNotificationDismissActionIdentifier: + self = .none + default: + // The Open action, or a tap on the notification itself. + self = .open(thread) + } + } +} + +@MainActor +final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationHandler() + + func install() { + let center = UNUserNotificationCenter.current() + center.delegate = self + center.setNotificationCategories(Self.categories) + } + + static var categories: Set { + typealias Action = NotificationCommand.Action + let allow = UNNotificationAction(identifier: Action.allow, title: "Allow", options: []) + let deny = UNNotificationAction(identifier: Action.deny, title: "Deny", options: [.destructive]) + let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground]) + let reply = UNTextInputNotificationAction( + identifier: Action.reply, title: "Reply", options: [], + textInputButtonTitle: "Send", textInputPlaceholder: "Reply") + + func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory { + UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: []) + } + return [ + category(.approval, [allow, deny]), + category(.input, [open]), + category(.finished, [reply, open]), + category(.failed, [open]), + category(.host, []), + ] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + await AppModel.shared.notificationArrived() + return [.banner, .list, .sound] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse + ) async { + let command = NotificationCommand( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo, + text: (response as? UNTextInputNotificationResponse)?.userText) + await run(command) + } + + private func run(_ command: NotificationCommand) async { + let model = AppModel.shared + switch command { + case .none: + return + case let .open(route): + if let route { model.open(route) } else { model.path = [] } + await model.refreshFeed() + return + case let .respond(hostId, threadId, requestId, decision): + guard model.mode != .unpaired else { return } + await attempt("Couldn't answer") { + try await model.api.respond( + hostId: hostId, threadId: threadId, + request: .decision(requestId: requestId, decision: decision)) + } + case let .reply(hostId, threadId, text): + guard model.mode != .unpaired else { return } + await attempt("Couldn't send your reply") { + try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) + } + } + await model.refreshFeed() + } + + /// Runs a background action. If it fails, says so with a local notification, since nothing is + /// on screen. + private func attempt(_ title: String, _ action: () async throws -> Void) async { + do { + try await action() + } catch let error as GatewayError where error.isStale { + return + } catch { + guard let message = AppModel.shared.message(for: error) else { return } + let content = UNMutableNotificationContent() + content.title = title + content.body = message + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + try? await UNUserNotificationCenter.current().add(request) + } + } +} diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift new file mode 100644 index 0000000..8d7e908 --- /dev/null +++ b/watch/WristCode/Views/FeedView.swift @@ -0,0 +1,233 @@ +// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first +// (with Allow and Deny on the card), then working (with what the agent is doing), then recent. + +import SwiftUI + +struct FeedView: View { + @Environment(AppModel.self) private var model + @Environment(\.scenePhase) private var scenePhase + @State private var visible = false + + var body: some View { + List { + FeedHeader(feed: model.feed, error: model.feedError) + .feedRow() + + if let feed = model.feed { + ForEach(feed.needsYou) { item in + NeedsYouCard(item: item).feedRow() + } + ForEach(feed.working) { item in + StatusCard(item: item).feedRow() + } + ForEach(feed.recent) { item in + StatusCard(item: item).feedRow() + } + if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty { + Text("No threads in the last day.") + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + .feedRow() + } + } else if model.feedError != nil { + Button("Try again") { Task { await model.refreshFeed() } } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) + .feedRow() + } + } + .listStyle(.plain) + .refreshable { await model.refreshFeed() } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: Route.newThread) { + Image(systemName: "plus") + } + .accessibilityLabel("New thread") + } + } + .onAppear { visible = true } + .onDisappear { visible = false } + // Refresh on appear, when the app comes to the foreground, and every 10 s while on screen. + .task(id: visible && scenePhase == .active) { + guard visible && scenePhase == .active else { return } + while !Task.isCancelled { + await model.refreshFeed() + try? await Task.sleep(for: .seconds(10)) + } + } + } +} + +private extension View { + func feedRow() -> some View { + listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0)) + } +} + +/// The big count of threads waiting on you, plus offline hosts and refresh errors. +private struct FeedHeader: View { + var feed: Feed? + var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let feed { + let waiting = feed.needsYou.count + Text("\(waiting)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundStyle(waiting > 0 ? Theme.you : Theme.done) + Text(subtitle(feed)) + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + ForEach(feed.hosts.filter { $0.status != .online }) { host in + Label("\(host.label) \(host.status.problem ?? "")", systemImage: "wifi.slash") + .font(.system(size: 11)) + .foregroundStyle(Theme.done) + } + } else if error == nil { + Text("Loading") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + } + if let error { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Theme.done) + .lineLimit(3) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + } + + private func subtitle(_ feed: Feed) -> String { + let working = feed.working.count + let lead = feed.needsYou.isEmpty ? "nothing waiting" : "waiting on you" + return working > 0 ? "\(lead) · \(working) working" : lead + } +} + +/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline. +private struct NeedsYouCard: View { + @Environment(AppModel.self) private var model + var item: FeedItem + @State private var sending = false + @State private var error: String? + + var body: some View { + Card(edge: Theme.you) { + Button { model.path.append(item.route) } label: { + VStack(alignment: .leading, spacing: 1) { + Text("\(item.hostLabel) · \(item.projectTitle)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.you) + .lineLimit(1) + Text(item.title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(2) + if let request = item.request { + Text(request.summary) + .font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(3) + .padding(.top, 2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if let request = item.request, let quick = request.quickOptions { + HStack(spacing: 6) { + Button(quick.allow.label) { respond(request, quick.allow.decision) } + .buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black)) + Button(quick.deny.label) { respond(request, quick.deny.decision) } + .buttonStyle(ChoiceButtonStyle()) + } + .disabled(sending) + .opacity(sending ? 0.5 : 1) + .padding(.top, 7) + } else if item.request?.isQuestion == true { + Button("Answer") { model.path.append(item.route) } + .buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black)) + .padding(.top, 7) + } + + if let error { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + .padding(.top, 4) + } + } + } + + private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) { + sending = true + error = nil + Task { + do { + try await model.api.respond( + hostId: item.hostId, threadId: item.threadId, + request: .decision(requestId: request.id, decision: decision)) + } catch let failure as GatewayError where failure.isStale { + // Someone answered it already; the refresh below drops the card. + } catch { + self.error = model.message(for: error) + } + await model.refreshFeed() + sending = false + } + } +} + +/// A teal card for a working thread, or a grey one for a recent thread. +private struct StatusCard: View { + @Environment(AppModel.self) private var model + var item: FeedItem + + var body: some View { + Button { model.path.append(item.route) } label: { + Card(edge: item.phase.color) { + Text(meta) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(item.phase == .failed ? Theme.secondaryText : item.phase.color) + .lineLimit(1) + Text(item.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText) + .lineLimit(2) + .padding(.top, 1) + if item.phase.isWorking, let line = item.lastMessage { + Text(line) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(2) + .padding(.top, 3) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var meta: String { + if item.phase.isWorking { + return "\(item.hostLabel) · \(item.projectTitle)" + } + let age = Format.age(of: item.updatedAt) + let state = item.phase == .failed ? "Failed" : age == "now" ? "just now" : "\(age) ago" + return "\(item.hostLabel) · \(state)" + } +} + +#Preview("Feed") { + NavigationStack { + FeedView() + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} diff --git a/watch/WristCode/Views/NewThreadView.swift b/watch/WristCode/Views/NewThreadView.swift new file mode 100644 index 0000000..9fa19c9 --- /dev/null +++ b/watch/WristCode/Views/NewThreadView.swift @@ -0,0 +1,210 @@ +// Start a thread: pick where (host, then project), see the model it will use, dictate the task. +// Defaults to the last host and project a thread was started in. + +import SwiftUI + +struct ProjectRef: Hashable { + var hostId: String + var projectId: String +} + +struct NewThreadView: View { + @Environment(AppModel.self) private var model + @AppStorage("lastHostId") private var lastHostId = "" + @AppStorage("lastProjectId") private var lastProjectId = "" + + @State private var projects: ProjectsResponse? + @State private var selection: ProjectRef? + @State private var picking = false + @State private var sending = false + @State private var error: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + if let projects { + let chosen = resolve(selection, in: projects) + Button { picking = true } label: { + Card(edge: Theme.done) { + Text("Where") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + Text(chosen.map { "\($0.host.label) › \($0.project.title)" } ?? "Choose a project") + .font(.system(size: 15, weight: .semibold)) + .lineLimit(2) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if let chosen { + Card(edge: Theme.done) { + Text("Agent") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + Text(chosen.project.model.map(Format.modelName) ?? "") + .font(.system(size: 15, weight: .semibold)) + } + + TextFieldLink(prompt: Text("What should it do?")) { + Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill") + } onSubmit: { text in + start(text, in: chosen) + } + .buttonStyle(PillButtonStyle(fill: Theme.you)) + .disabled(sending || chosen.host.status != .online) + .padding(.top, 4) + + if chosen.host.status != .online { + Text("\(chosen.host.label) is \(chosen.host.status.problem ?? "offline").") + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + } + } + } else if error == nil { + Text("Loading") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + } + if let error { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + } + } + .padding(.horizontal, 4) + } + .navigationTitle("New thread") + .toolbarForegroundStyle(Theme.you, for: .navigationBar) + .sheet(isPresented: $picking) { + if let projects { + ProjectPicker(projects: projects) { ref in + selection = ref + picking = false + } + } + } + .task { await load() } + } + + private func load() async { + do { + let response = try await model.api.projects() + projects = response + if selection == nil { + selection = Self.defaultSelection( + in: response, last: ProjectRef(hostId: lastHostId, projectId: lastProjectId)) + } + error = nil + } catch { + self.error = model.message(for: error) + } + } + + private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) + -> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)? + { + guard let ref, + let host = projects.hosts.first(where: { $0.id == ref.hostId }), + let project = host.launchable.first(where: { $0.id == ref.projectId }) + else { return nil } + return (host, project) + } + + /// The last used project when it can still start threads, else the first project on the + /// first online host. + nonisolated static func defaultSelection(in projects: ProjectsResponse, last: ProjectRef) -> ProjectRef? { + if let host = projects.hosts.first(where: { $0.id == last.hostId }), + host.launchable.contains(where: { $0.id == last.projectId }) + { + return last + } + let hosts = projects.hosts.filter { $0.status == .online } + projects.hosts.filter { $0.status != .online } + for host in hosts { + if let project = host.launchable.first { + return ProjectRef(hostId: host.id, projectId: project.id) + } + } + return nil + } + + private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + sending = true + error = nil + Task { + do { + let created = try await model.api.newThread( + NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text)) + lastHostId = chosen.host.id + lastProjectId = chosen.project.id + await model.refreshFeed() + // Replace this screen with the new thread, so Back returns to the feed. + let route = Route.thread(hostId: created.hostId, threadId: created.threadId) + if model.path.last == .newThread { + model.path[model.path.count - 1] = route + } else { + model.path.append(route) + } + } catch { + self.error = model.message(for: error) + } + sending = false + } + } +} + +/// Two steps: the environment (host), then one of its projects. +private struct ProjectPicker: View { + var projects: ProjectsResponse + var pick: (ProjectRef) -> Void + + var body: some View { + NavigationStack { + List(projects.hosts) { host in + NavigationLink { + List(host.launchable) { project in + Button { + pick(ProjectRef(hostId: host.id, projectId: project.id)) + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(project.title) + .font(.system(size: 15, weight: .semibold)) + Text(project.model.map(Format.modelName) ?? "") + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + } + } + } + .navigationTitle(host.label) + .overlay { + if host.launchable.isEmpty { + Text("No projects with a default model.") + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + .multilineTextAlignment(.center) + } + } + } label: { + VStack(alignment: .leading, spacing: 1) { + Text(host.label) + .font(.system(size: 15, weight: .semibold)) + Text(host.status.problem ?? "\(host.launchable.count) projects") + .font(.system(size: 11)) + .foregroundStyle(host.status == .online ? Theme.secondaryText : Theme.done) + } + } + } + .navigationTitle("Where") + } + .fontDesign(.rounded) + } +} + +#Preview("New thread") { + NavigationStack { + NewThreadView() + } + .environment(AppModel.preview()) +} diff --git a/watch/WristCode/Views/PairView.swift b/watch/WristCode/Views/PairView.swift new file mode 100644 index 0000000..8a3c14f --- /dev/null +++ b/watch/WristCode/Views/PairView.swift @@ -0,0 +1,92 @@ +// Pairing: the gateway URL and the 6 digit code that `wristcode pair` prints on the gateway. +// Shown whenever there is no device token, or the gateway answers 401. + +import SwiftUI + +struct PairView: View { + @Environment(AppModel.self) private var model + @State private var url = DeviceStore.lastGatewayURL + @State private var code = "" + @State private var sending = false + @State private var error: String? + + private var digits: String { code.filter(\.isNumber) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("WristCode") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.you) + Text("Run wristcode pair on the gateway, then enter the code.") + .font(.system(size: 13)) + .foregroundStyle(Theme.secondaryText) + + field("Gateway") { + TextField("Gateway URL", text: $url) + .textContentType(.URL) + } + field("Code") { + TextField("6 digits", text: $code) + .textContentType(.oneTimeCode) + } + + Button(sending ? "Pairing" : "Pair") { pair() } + .buttonStyle(PillButtonStyle(fill: Theme.you)) + .disabled(sending || digits.count != 6) + .opacity(digits.count == 6 ? 1 : 0.5) + .padding(.top, 4) + + if let error { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + } + + Button("Try the demo") { model.startDemo() } + .buttonStyle(.plain) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.work) + .frame(maxWidth: .infinity) + .padding(.top, 6) + } + .padding(.horizontal, 4) + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + private func field(_ label: String, @ViewBuilder input: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + input() + } + } + + private func pair() { + guard let gatewayURL = DeviceStore.normalizeGatewayURL(url) else { + error = "That gateway URL doesn't look right." + return + } + sending = true + error = nil + Task { + do { + try await model.pair(gatewayURL: gatewayURL, code: digits) + } catch let failure as GatewayError where failure == .unauthorized { + error = "That code is wrong or expired." + } catch { + self.error = error.localizedDescription + } + sending = false + } + } +} + +#Preview("Pair") { + PairView() + .environment(AppModel(mode: .unpaired)) + .fontDesign(.rounded) +} diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift new file mode 100644 index 0000000..8d94f50 --- /dev/null +++ b/watch/WristCode/Views/Theme.swift @@ -0,0 +1,112 @@ +// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done. + +import SwiftUI + +enum Theme { + static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e + static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5 + static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70 + static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a + static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e + static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2 + static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea +} + +extension Phase { + var color: Color { + if needsYou { return Theme.you } + if isWorking { return Theme.work } + return Theme.done + } +} + +/// A dark rounded card with a 4 pt colored left edge. +struct Card: View { + var edge: Color + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 9) + .padding(.leading, 15) + .padding(.trailing, 11) + .background(alignment: .leading) { + ZStack(alignment: .leading) { + Theme.card + edge.frame(width: 4) + } + } + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } +} + +/// A full-width pill button, like mock B's Reply and Say the task. +struct PillButtonStyle: ButtonStyle { + var fill: Color + var foreground: Color = .black + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule()) + } +} + +/// A smaller button for choices on a card, such as Allow and Deny. +struct ChoiceButtonStyle: ButtonStyle { + var fill: Color = Theme.control + var foreground: Color = .white + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(foreground) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .padding(.horizontal, 4) + .background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +enum Format { + /// A short age for a card, such as "now", "4 min", "2 h" or "3 d". + static func age(of date: Date, now: Date = .now) -> String { + let minutes = Int(now.timeIntervalSince(date) / 60) + if minutes < 1 { return "now" } + if minutes < 60 { return "\(minutes) min" } + let hours = minutes / 60 + if hours < 24 { return "\(hours) h" } + return "\(hours / 24) d" + } + + /// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5". + static func modelName(_ id: String) -> String { + var words: [String] = [] + var number: [String] = [] + func flush() { + if !number.isEmpty { words.append(number.joined(separator: ".")) } + number = [] + } + for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) { + if part.allSatisfy(\.isNumber) { + number.append(part) + continue + } + flush() + switch part.lowercased() { + case "gpt": words.append("GPT") + default: words.append(part.prefix(1).uppercased() + part.dropFirst()) + } + } + flush() + return words.joined(separator: " ") + } +} diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift new file mode 100644 index 0000000..9aee869 --- /dev/null +++ b/watch/WristCode/Views/ThreadView.swift @@ -0,0 +1,309 @@ +// One thread: the agent's latest message large, older messages one scroll up, the pending approval +// or question with its options, Reply by dictation or Scribble, and Stop while a run is active. + +import SwiftUI + +struct ThreadView: View { + @Environment(AppModel.self) private var model + @Environment(\.scenePhase) private var scenePhase + let hostId: String + let threadId: String + + @State private var detail: ThreadDetail? + @State private var error: String? + @State private var sending = false + @State private var visible = false + @State private var answers: [String: String] = [:] + @State private var scrolledToLatest = false + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if let detail { + content(detail) + } else if error == nil { + Text("Loading") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.secondaryText) + } + if let error { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + } + } + .padding(.horizontal, 4) + } + .onChange(of: detail?.messages.last?.id) { + guard !scrolledToLatest, detail != nil else { return } + scrolledToLatest = true + proxy.scrollTo("latest", anchor: .top) + } + .onAppear { + if detail != nil, !scrolledToLatest { + scrolledToLatest = true + proxy.scrollTo("latest", anchor: .top) + } + } + } + .navigationTitle(detail?.item.title ?? "Thread") + .onAppear { visible = true } + .onDisappear { visible = false } + .task(id: visible && scenePhase == .active) { + guard visible && scenePhase == .active else { return } + while !Task.isCancelled { + await load() + try? await Task.sleep(for: .seconds(10)) + } + } + } + + @ViewBuilder + private func content(_ detail: ThreadDetail) -> some View { + let split = MessageSplit(detail.messages) + let item = detail.item + + ForEach(split.earlier) { message in + MessageRow(message: message) + } + + Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.") + .font(.system(size: 16)) + .foregroundStyle(Theme.bodyText) + .lineSpacing(1) + .frame(maxWidth: .infinity, alignment: .leading) + .id("latest") + .padding(.top, split.earlier.isEmpty ? 0 : 6) + + ForEach(split.after) { message in + MessageRow(message: message) + } + + Text(statusLine(item)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(item.phase.color) + + if let request = item.request { + if request.isQuestion { + questions(request) + } else { + approval(request) + } + } + + if item.phase == .completed && item.request == nil { + QuickReplies { send($0) } + .disabled(sending) + } + + TextFieldLink(prompt: Text("Reply")) { + Label("Reply", systemImage: "mic.fill") + } onSubmit: { text in + send(text) + } + .buttonStyle(PillButtonStyle(fill: Theme.work)) + .disabled(sending) + + if detail.canStop { + Button(role: .destructive) { stop() } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red)) + .disabled(sending) + } + } + + private func approval(_ request: PendingRequest) -> some View { + Card(edge: Theme.you) { + if let prompt = request.prompt { + Text(prompt) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.white) + } else { + Text(request.summary) + .font(.system(size: 14, weight: .semibold)) + } + if let reason = request.reason { + Text(reason) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + .padding(.top, 3) + } + VStack(spacing: 6) { + ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in + Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) } + .buttonStyle(ChoiceButtonStyle( + fill: index == 0 ? Theme.you : Theme.control, + foreground: index == 0 ? .black : .white)) + } + } + .padding(.top, 8) + .disabled(sending) + } + } + + private func questions(_ request: PendingRequest) -> some View { + ForEach(request.questions) { question in + Card(edge: Theme.you) { + Text(question.header) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.you) + Text(question.question) + .font(.system(size: 14, weight: .semibold)) + .padding(.top, 1) + VStack(spacing: 6) { + ForEach(question.options, id: \.value) { choice in + let chosen = answers[question.id] == choice.value + Button(choice.label) { answer(request, question.id, choice.value) } + .buttonStyle(ChoiceButtonStyle( + fill: chosen ? Theme.you : Theme.control, + foreground: chosen ? .black : .white)) + } + if question.allowCustomAnswer { + TextFieldLink(prompt: Text(question.question)) { + Text("Other…") + } onSubmit: { text in + answer(request, question.id, text) + } + .buttonStyle(ChoiceButtonStyle()) + } + } + .padding(.top, 8) + .disabled(sending) + } + } + } + + private func statusLine(_ item: FeedItem) -> String { + let place = "\(item.hostLabel) · \(item.projectTitle)" + return switch item.phase { + case .waitingForApproval: "Needs approval · \(place)" + case .waitingForInput: "Has a question · \(place)" + case .starting, .running: "Working · \(place)" + case .failed: "Failed · \(place)" + default: place + } + } + + // MARK: - Actions + + private func load() async { + do { + detail = try await model.api.thread(hostId: hostId, threadId: threadId) + error = nil + } catch { + self.error = model.message(for: error) + } + } + + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + sending = true + error = nil + Task { + do { + try await action() + } catch let failure as GatewayError where failure.isStale { + self.error = "Already answered." + } catch { + self.error = model.message(for: error) + } + await load() + await model.refreshFeed() + sending = false + } + } + + private func send(_ text: String) { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) } + } + + private func respond(_ request: RespondRequest) { + perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) } + } + + /// Records one answer. Once every question has one, sends them together. + private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) { + answers[questionId] = value + let all = request.questions.allSatisfy { answers[$0.id] != nil } + guard all else { return } + let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } } + answers = [:] + respond(.answers(requestId: request.id, answers: payload)) + } + + private func stop() { + perform { try await model.api.stop(hostId: hostId, threadId: threadId) } + } +} + +/// Splits a thread's messages around the agent's latest message, which the screen shows large. +struct MessageSplit { + var earlier: [Message] + var latest: Message? + var after: [Message] + + init(_ messages: [Message]) { + guard let index = messages.lastIndex(where: { $0.role == .assistant }) else { + earlier = messages + latest = nil + after = [] + return + } + earlier = Array(messages[.. Void + + var body: some View { + HStack(spacing: 6) { + ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in + Button(reply) { send(reply) } + .buttonStyle(ChoiceButtonStyle(fill: Theme.card)) + } + } + } +} + +#Preview("Finished") { + NavigationStack { + ThreadView(hostId: "littleman", threadId: "t-next") + } + .environment(AppModel.preview()) +} + +#Preview("Approval") { + NavigationStack { + ThreadView(hostId: "mac-mini", threadId: "t-ocr") + } + .environment(AppModel.preview()) +} + +#Preview("Question") { + NavigationStack { + ThreadView(hostId: "server-gib", threadId: "t-db") + } + .environment(AppModel.preview()) +} diff --git a/watch/WristCode/WristCode.entitlements b/watch/WristCode/WristCode.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/watch/WristCode/WristCode.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift new file mode 100644 index 0000000..1e5874c --- /dev/null +++ b/watch/WristCode/WristCodeApp.swift @@ -0,0 +1,59 @@ +// App entry. Notification setup and APNs registration go through the WatchKit app delegate. + +import SwiftUI +import WatchKit + +@main +struct WristCodeApp: App { + @WKApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @State private var model = AppModel.shared + + var body: some Scene { + WindowGroup { + RootView() + .environment(model) + } + } +} + +struct RootView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var model = model + Group { + if model.mode == .unpaired { + PairView() + } else { + NavigationStack(path: $model.path) { + FeedView() + .navigationDestination(for: Route.self) { route in + switch route { + case let .thread(hostId, threadId): + ThreadView(hostId: hostId, threadId: threadId) + case .newThread: + NewThreadView() + } + } + } + } + } + .fontDesign(.rounded) + .tint(Theme.work) + } +} + +final class AppDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + NotificationHandler.shared.install() + Task { await AppModel.shared.enableNotifications() } + } + + func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { + AppModel.shared.didRegister(apnsToken: deviceToken) + } + + func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) { + print("APNs registration failed: \(error.localizedDescription)") + } +} diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift new file mode 100644 index 0000000..6d9869a --- /dev/null +++ b/watch/WristCodeTests/ModelsTests.swift @@ -0,0 +1,224 @@ +// Decodes the docs/api.md examples into the Codable models, and checks the small bits of logic +// the screens rely on. + +import Foundation +import Testing +@testable import WristCode + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSON.decoder().decode(type, from: Data(json.utf8)) +} + +private func encodeObject(_ value: some Encodable) throws -> [String: Any] { + let data = try JSON.encoder().encode(value) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) +} + +private let feedItemJSON = """ +{ + "hostId": "...", "hostLabel": "Mac mini", + "threadId": "...", "projectId": "...", "projectTitle": "bill-tracker", + "title": "Receipt OCR", + "phase": "waiting_for_approval", + "updatedAt": "2026-11-01T09:41:00.000Z", + "lastMessage": "Parser and tests are written. I want to run the suite.", + "request": { + "id": "...", + "kind": "command", + "prompt": "bun test --filter ocr", + "reason": null, + "options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }], + "questions": [] + } +} +""" + +@Suite struct DecodingTests { + @Test func feedFromDocs() throws { + let feed = try decode(Feed.self, """ + { + "needsYou": [\(feedItemJSON)], + "working": [], + "recent": [], + "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }] + } + """) + #expect(feed.needsYou.count == 1) + #expect(feed.hosts.first?.status == .online) + #expect(feed.hosts.first?.error == nil) + + let item = try #require(feed.needsYou.first) + #expect(item.phase == .waitingForApproval) + #expect(item.phase.needsYou) + #expect(item.updatedAt == Date(timeIntervalSince1970: 1_793_526_060)) + let request = try #require(item.request) + #expect(request.prompt == "bun test --filter ocr") + #expect(request.reason == nil) + #expect(request.quickOptions?.allow.label == "Allow") + #expect(request.quickOptions?.deny.decision == .decline) + #expect(request.summary == "bun test --filter ocr") + } + + @Test func questionRequest() throws { + let request = try decode(PendingRequest.self, """ + { "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [], + "questions": [{ "id": "q1", "header": "Storage", "question": "Hash the codes?", + "options": [{ "label": "Yes", "value": "yes" }], "allowCustomAnswer": true }] } + """) + #expect(request.isQuestion) + #expect(request.quickOptions == nil) + #expect(request.summary == "Hash the codes?") + #expect(request.questions.first?.allowCustomAnswer == true) + } + + @Test func unknownEnumValuesStillDecode() throws { + let item = try decode(FeedItem.self, feedItemJSON + .replacingOccurrences(of: "waiting_for_approval", with: "hibernating") + .replacingOccurrences(of: "\"decline\"", with: "\"rejectForever\"")) + #expect(item.phase.rawValue == "hibernating") + #expect(!item.phase.needsYou && !item.phase.isWorking) + #expect(item.request?.options.last?.decision.rawValue == "rejectForever") + + let host = try decode(HostSummary.self, #"{ "id": "h", "label": "L", "status": "sleeping", "error": "zzz" }"#) + #expect(host.status.problem == "offline") + } + + @Test func threadFromDocs() throws { + let detail = try decode(ThreadDetail.self, """ + { "item": \(feedItemJSON), + "messages": [{ "id": "...", "role": "assistant", "text": "...", "createdAt": "2026-11-01T09:40:00Z" }], + "canStop": true } + """) + #expect(detail.canStop) + #expect(detail.messages.first?.role == .assistant) + #expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000)) + } + + @Test func projectsFromDocs() throws { + let response = try decode(ProjectsResponse.self, """ + { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null, + "projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5" }, + { "id": "b", "title": "scratch", "model": null }] }] } + """) + let host = try #require(response.hosts.first) + #expect(host.projects.count == 2) + #expect(host.launchable.map(\.id) == ["a"]) + } + + @Test func smallResponses() throws { + #expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x") + #expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t") + #expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline") + } + + @Test func badDateFails() { + #expect(throws: DecodingError.self) { + try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#) + } + } +} + +@Suite struct EncodingTests { + @Test func respondWithDecision() throws { + let object = try encodeObject(RespondRequest.decision(requestId: "r1", decision: .accept)) + #expect(object["requestId"] as? String == "r1") + #expect(object["decision"] as? String == "accept") + #expect(object["answers"] == nil) + } + + @Test func respondWithAnswers() throws { + let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"])) + #expect(object["answers"] as? [String: String] == ["q1": "yes"]) + #expect(object["decision"] == nil) + } + + @Test func pushRegistration() throws { + let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox)) + #expect(object["apnsEnvironment"] as? String == "sandbox") + } +} + +@Suite struct LogicTests { + @Test func gatewayURLNormalizing() { + #expect(DeviceStore.normalizeGatewayURL(" wristcode.gbrown.org/ ")?.absoluteString == "https://wristcode.gbrown.org") + #expect(DeviceStore.normalizeGatewayURL("http://10.0.0.5:8080")?.absoluteString == "http://10.0.0.5:8080") + #expect(DeviceStore.normalizeGatewayURL("") == nil) + #expect(DeviceStore.normalizeGatewayURL("ftp://x.org") == nil) + } + + @Test func modelNames() { + #expect(Format.modelName("claude-opus-5-5") == "Claude Opus 5.5") + #expect(Format.modelName("claude-haiku-5-5") == "Claude Haiku 5.5") + #expect(Format.modelName("gpt-5-codex") == "GPT 5 Codex") + } + + @Test func ages() { + let now = Date(timeIntervalSince1970: 100_000) + #expect(Format.age(of: now, now: now) == "now") + #expect(Format.age(of: now.addingTimeInterval(-4 * 60), now: now) == "4 min") + #expect(Format.age(of: now.addingTimeInterval(-62 * 60), now: now) == "1 h") + #expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d") + } + + @Test func messageSplitShowsLatestAgentMessage() { + func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) } + let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)]) + #expect(split.earlier.map(\.id) == ["1", "2"]) + #expect(split.latest?.id == "3") + #expect(split.after.map(\.id) == ["4"]) + #expect(MessageSplit([m("1", .user)]).latest == nil) + } + + @Test func defaultProjectPrefersLastUsed() { + let projects = ProjectsResponse(hosts: [ + .init(id: "off", label: "Off", status: .offline, error: nil, projects: [.init(id: "x", title: "x", model: "m")]), + .init(id: "on", label: "On", status: .online, error: nil, projects: [ + .init(id: "none", title: "none", model: nil), + .init(id: "a", title: "a", model: "m"), + .init(id: "b", title: "b", model: "m"), + ]), + ]) + let last = ProjectRef(hostId: "on", projectId: "b") + #expect(NewThreadView.defaultSelection(in: projects, last: last) == last) + // A forgotten or model-less project falls back to the first online host's first usable project. + #expect(NewThreadView.defaultSelection(in: projects, last: ProjectRef(hostId: "on", projectId: "none")) + == ProjectRef(hostId: "on", projectId: "a")) + } + + @Test func notificationCommands() { + let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"] + #expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil) + == .respond(hostId: "h", threadId: "t", requestId: "r", decision: .accept)) + #expect(NotificationCommand(actionIdentifier: "deny", userInfo: info, text: nil) + == .respond(hostId: "h", threadId: "t", requestId: "r", decision: .decline)) + #expect(NotificationCommand(actionIdentifier: "reply", userInfo: info, text: " Commit it ") + == .reply(hostId: "h", threadId: "t", text: "Commit it")) + #expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: info, text: nil) + == .open(.thread(hostId: "h", threadId: "t"))) + // A host notification has no thread, so tapping it opens the feed. + #expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: [:], text: nil) + == .open(nil)) + // Allow without a requestId cannot answer, so it opens the thread instead. + #expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil) + == .open(.thread(hostId: "h", threadId: "t"))) + } +} + +@Suite struct DemoGatewayTests { + @Test func allowMovesThreadToWorking() async throws { + let gateway = DemoGateway() + let before = try await gateway.feed() + let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil }) + try await gateway.respond( + hostId: item.hostId, threadId: item.threadId, + request: .decision(requestId: item.request!.id, decision: .accept)) + let after = try await gateway.feed() + #expect(!after.needsYou.contains { $0.id == item.id }) + #expect(after.working.contains { $0.id == item.id }) + await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) { + try await gateway.respond( + hostId: item.hostId, threadId: item.threadId, + request: .decision(requestId: item.request!.id, decision: .accept)) + } + } +} diff --git a/watch/project.yml b/watch/project.yml new file mode 100644 index 0000000..553373b --- /dev/null +++ b/watch/project.yml @@ -0,0 +1,66 @@ +name: WristCode +options: + bundleIdPrefix: org.gbrown + createIntermediateGroups: true + deploymentTarget: + watchOS: "27.0" +settings: + base: + DEVELOPMENT_TEAM: GQJNM5YK2M + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" +targets: + WristCode: + type: application + platform: watchOS + sources: + - WristCode + info: + path: WristCode/Info.plist + properties: + CFBundleDisplayName: WristCode + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + WKApplication: true + # Watch only, with no iPhone companion. watchOS refuses to install an app that sets both + # this and WKRunsIndependentlyOfCompanionApp, and requires WKCompanionAppBundleIdentifier + # when WKRunsIndependentlyOfCompanionApp is set without WKWatchOnly. + WKWatchOnly: true + entitlements: + path: WristCode/WristCode.entitlements + properties: + aps-environment: development + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + WristCodeTests: + type: bundle.unit-test + platform: watchOS + sources: + - WristCodeTests + dependencies: + - target: WristCode + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: $(BUILT_PRODUCTS_DIR)/WristCode.app/WristCode + BUNDLE_LOADER: $(TEST_HOST) +schemes: + WristCode: + build: + targets: + WristCode: all + WristCodeTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - WristCodeTests + archive: + config: Release