Files
wristcode/watch/WristCode/Demo/DemoGateway.swift
T
gibandClaude Opus 5.5 0994fd7d5a Watch: waiting-count complication and multi-select questions
A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows
GET /v1/summary. The device token moves to the App Group's Keychain access
group on launch so the extension can read it without re-pairing; the app
reloads the timeline when its feed counts change and on notifications.
Multi-select questions toggle options and send an array of values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:06:38 -05:00

266 lines
12 KiB
Swift

// 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 summary() async throws -> Summary {
Summary(try await feed())
}
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 release = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
request: PendingRequest(
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
questions: [
.init(
id: "checks", header: "Checks", question: "Which checks should run before upload?",
options: [
.init(label: "Unit tests", value: "unit"),
.init(label: "UI tests", value: "ui"),
.init(label: "Lint", value: "lint"),
.init(label: "Archive", value: "archive"),
],
allowCustomAnswer: true, multiSelect: 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, release, 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."),
],
release.id: [
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
],
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)
}
}