Compare commits

...
2 Commits
Author SHA1 Message Date
gibandClaude Opus 5.5 9731fd621f Watch: demo screenshots, wrapping reply chips, feed toolbar and inset fixes, README
Screenshots of Pair, Feed, Thread (finished, approval, question) and New
thread, taken in the Series 12 46mm simulator in demo mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:15 -05:00
gibandClaude Opus 5.5 bdfb79b891 Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror
gateway/src/contract.ts, with lenient string enums so new phases or decisions
still decode. A DemoGateway serves fixtures from the docs/api.md examples for
previews and demo mode. Swift Testing covers decoding the documented JSON,
request encoding, notification action parsing and the small view helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:15 -05:00
26 changed files with 2513 additions and 0 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# WristCode watch app
SwiftUI, watch only, Swift 6 with strict concurrency, and no dependencies. It talks to the gateway
over HTTPS + JSON as described in [docs/api.md](../docs/api.md); `WristCode/API/Models.swift`
mirrors [gateway/src/contract.ts](../gateway/src/contract.ts).
## Build and test
```sh
cd watch
xcodegen generate # the .xcodeproj is generated and gitignored
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' build
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test
```
## Demo mode
`DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes:
Allow moves a thread to working, a reply restarts a finished thread. The SwiftUI previews use it,
the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
```sh
xcrun simctl launch <udid> org.gbrown.wristcode -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode -demo -screen new # new thread
```
`screenshots/` holds the screens captured this way.
## Layout
| Path | Job |
| --- | --- |
| `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration |
| `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing |
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
| `API/Models.swift` | Codable mirror of the contract |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the Keychain |
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
+152
View File
@@ -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<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
try await call(method, path, body: nil as SendMessageRequest?)
}
private func call<Body: Encodable, Response: Decodable>(
_ 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<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws {
_ = try await perform(method, path, body: body)
}
private func perform<Body: Encodable>(_ 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
}
}
+274
View File
@@ -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)
}
}
+168
View File
@@ -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 <hostId>/<threadId>` 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)
}
}
@@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "icon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+94
View File
@@ -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)
}
}
+239
View File
@@ -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)
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>WristCode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKWatchOnly</key>
<true/>
</dict>
</plist>
@@ -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<UNNotificationCategory> {
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)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
// 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")
.fontWeight(.bold)
.foregroundStyle(.black)
}
.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: 4, bottom: 3, trailing: 4))
}
}
/// 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)
}
+210
View File
@@ -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())
}
+92
View File
@@ -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)
}
+169
View File
@@ -0,0 +1,169 @@
// 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<Content: View>: 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: " ")
}
}
/// Lays children out left to right and wraps them onto new lines, like the mock's reply chips.
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: rows.last.map { $0.y + $0.height } ?? 0)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
for row in arrange(width: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
}
}
private struct Row {
var indices: [Int] = []
var y: CGFloat = 0
var width: CGFloat = 0
var height: CGFloat = 0
}
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var row = Row()
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
if !row.indices.isEmpty, row.width + spacing + size.width > width {
rows.append(row)
row = Row(y: row.y + row.height + spacing)
}
row.width += (row.indices.isEmpty ? 0 : spacing) + size.width
row.height = max(row.height, size.height)
row.indices.append(index)
}
if !row.indices.isEmpty { rows.append(row) }
return rows
}
}
/// A content-sized chip, for one-tap replies.
struct ChipButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Theme.card.opacity(configuration.isPressed ? 0.6 : 1), in: Capsule())
}
}
+309
View File
@@ -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[..<index])
latest = messages[index]
after = Array(messages[(index + 1)...])
}
}
private struct MessageRow: View {
var message: Message
var body: some View {
let mine = message.role == .user
Text(message.text)
.font(.system(size: 13))
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
.padding(.horizontal, mine ? 9 : 0)
.padding(.vertical, mine ? 6 : 0)
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
}
}
/// One-tap replies for a finished turn, like mock B's chips.
private struct QuickReplies: View {
var send: (String) -> Void
var body: some View {
FlowLayout {
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
Button(reply) { send(reply) }
.buttonStyle(ChipButtonStyle())
}
}
}
}
#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())
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+59
View File
@@ -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)")
}
}
+224
View File
@@ -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<T: Decodable>(_ 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))
}
}
}
+66
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB