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]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 02:33:15 -05:00
1 parent ba1624eb9e
commit bdfb79b891
19 files changed
+2413

No files matched your search

+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)
}
}
}
+233
View File
@@ -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)
}
+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)
}
+112
View File
@@ -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<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: " ")
}
}
+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 {
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())
}
+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