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:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user