Watch: show subagent requests on the parent's card, and settle threads by swipe, Settle all and the thread screen

This commit is contained in:
gib committed 2026-11-01 20:26:38 -05:00
1 parent a4754b65b0
commit cf8995e63f
16 files changed
+347 -31

No files matched your search

+56 -4
View File
@@ -1,6 +1,7 @@
// 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.
// answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, and so on.
import Foundation
@@ -12,6 +13,8 @@ actor DemoGateway: GatewayAPI {
private let hosts: [HostSummary]
private let projectHosts: [ProjectsResponse.Host]
private var statusResponse: StatusResponse
/// Ids of settled threads, which the feed leaves out.
private var settled: Set<String> = []
private var nextId = 1
init(now: Date = .now) {
@@ -24,6 +27,7 @@ actor DemoGateway: GatewayAPI {
}
func feed() async throws -> Feed {
let items = items.filter { !settled.contains($0.id) }
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 }
@@ -42,6 +46,7 @@ actor DemoGateway: GatewayAPI {
func sendMessage(hostId: String, threadId: String, text: String) async throws {
try update(hostId, threadId) { item in
settled.remove(item.id)
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
if !item.phase.isWorking {
item.phase = .running
@@ -50,10 +55,18 @@ actor DemoGateway: GatewayAPI {
}
}
/// Answers go to the thread that asked. For a card showing a subagent's request, that is the
/// subagent in `via`, and the parent itself has nothing to answer.
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.") }
let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
?? items.firstIndex { $0.hostId == hostId && $0.threadId == threadId }
guard let index else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
try update(items[index].hostId, items[index].threadId) { item in
guard item.request != nil, item.requestThread == (hostId, threadId) else {
throw GatewayError.http(status: 409, message: "Already answered.")
}
item.request = nil
item.via = nil
switch request {
case let .decision(_, decision) where decision == .decline || decision == .cancel:
item.phase = .completed
@@ -75,6 +88,11 @@ actor DemoGateway: GatewayAPI {
}
}
func settle(hostId: String, threadId: String) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
settled.insert(item.id)
}
func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts)
}
@@ -194,6 +212,17 @@ struct DemoFixtures {
allowCustomAnswer: true, multiSelect: true),
]))
// A subagent's request, shown on the card of the thread that started it.
let homelab = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-homelab", projectId: "p-wristlab",
projectTitle: "WristLab", title: "HomeLab Stats Apple Watch App", phase: .waitingForApproval,
updatedAt: ago(4), lastMessage: "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build.",
request: PendingRequest(
id: "r-path", kind: "command", prompt: "brew install jq", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: []),
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
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),
@@ -214,7 +243,18 @@ struct DemoFixtures {
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]
let statusScreens = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-status", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Status screens", phase: .completed, updatedAt: ago(35),
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
request: nil)
let hyprlock = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-hyprlock", projectId: "p-panama",
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
lastMessage: "Fixed. hyprlock no longer crashes when a monitor sleeps.", request: nil)
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes))
@@ -234,6 +274,10 @@ struct DemoFixtures {
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
],
homelab.id: [
m("h1", .user, 30, "Build a HomeLab stats app for the watch."),
m("h2", .assistant, 4, "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build."),
],
scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
@@ -247,6 +291,14 @@ struct DemoFixtures {
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?"),
],
statusScreens.id: [
m("s1", .user, 120, "Build the status, environment and notification screens."),
m("s2", .assistant, 35, "Done. Status, environment and notification screens are in TestFlight build 202611011516."),
],
hyprlock.id: [
m("k1", .user, 110, "hyprlock crashes when a monitor sleeps. Fix it."),
m("k2", .assistant, 95, "Fixed. hyprlock no longer crashes when a monitor sleeps."),
],
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"),