Watch: notification switches, a model list and Ask before acting on new threads, and Ask before acting on a thread

This commit is contained in:
gib committed 2026-11-02 12:10:48 -05:00
1 parent 3cc7e40852
commit 6bc9cac6ca
14 files changed
+623 -81

No files matched your search

+19 -3
View File
@@ -55,7 +55,8 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifi
`screenshots/` holds the screens captured this way. The complication, the multi-select question, the
status screens, the subagent card and thread, Recent with Settle all and the "N more" row, the full
Recent list, and a finished thread with Settle are rendered by `SnapshotTests` instead, which only run when given a directory:
Recent list, a finished thread with Settle, a running thread with Ask before acting, and New thread
with its model list are rendered by `SnapshotTests` instead, which only run when given a directory:
```sh
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
@@ -88,8 +89,23 @@ The gear at the top left of the feed opens the status overview (`GET /v1/status`
"All connected" or "Needs attention", lists only the issues worst first (offline hosts, a host on a
fallback route, tokens expiring within 7 days, push problems), and folds the healthy hosts into one
row. Each host opens its route ladder with Reconnect. The Notifications row shows whether this watch
is registered, the last push, Send test, the gateway, and Unpair. Each screen loads once when it
appears; nothing polls.
is registered, the last push, a switch per kind of push (Needs me, Finished, Failed, Subagents ask),
Send test, the gateway, and Unpair. A switch flips at once and sends only its own key with
`PUT /v1/device/notifications`. If that fails, it flips back and the error shows under the switches.
Host problems always notify. Each screen loads once when it appears; nothing polls.
## Models and Ask before acting
On New thread, the Agent row opens every model the host offers, grouped by provider, with the
project default's provider first. The watch remembers the last pick per host and falls back to the
project's default when the host stops offering it. It sends `model` only when the pick differs from
the default, so the default keeps its options, such as reasoning effort. The Ask before acting switch
under it remembers its last value. It is hidden, and the thread starts with `askFirst: false`, when
the provider cannot ask first.
A thread's screen has the same switch between Reply and Stop. It flips at once and posts
`/v1/threads/:hostId/:threadId/mode`. If that fails, it flips back and the error shows under it.
Against a gateway older than these routes, the switches and the model list stay hidden.
## Complication
+12
View File
@@ -51,6 +51,8 @@ protocol GatewayAPI: Sendable {
func stop(hostId: String, threadId: String) async throws
/// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
func settle(hostId: String, threadId: String) async throws
/// Turns Ask before acting on or off for a thread.
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws
/// Every unsettled finished thread, including those past the feed's first 10.
func recent() async throws -> RecentResponse
/// Settles every completed thread in Recent. Failed threads stay.
@@ -62,6 +64,8 @@ protocol GatewayAPI: Sendable {
/// Connects the host again from its first route.
func reconnect(hostId: String) async throws
func testPush() async throws -> PushResult
/// Turns one kind of push on or off for this watch, and returns all four switches.
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs
/// Revokes this watch's device token.
func unpair() async throws
}
@@ -105,6 +109,10 @@ struct GatewayClient: GatewayAPI {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "settle"], body: nil as SendMessageRequest?)
}
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "mode"], body: ThreadModeRequest(askFirst: askFirst))
}
func recent() async throws -> RecentResponse {
try await call("GET", ["v1", "recent"])
}
@@ -137,6 +145,10 @@ struct GatewayClient: GatewayAPI {
try await call("POST", ["v1", "device", "test-push"])
}
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs {
try await call("PUT", ["v1", "device", "notifications"], body: [key.rawValue: on])
}
func unpair() async throws {
try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?)
}
+107
View File
@@ -265,6 +265,14 @@ struct ThreadDetail: Codable, Hashable, Sendable {
/// Oldest first, at most 20.
var messages: [Message]
var canStop: Bool
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
/// leave it out, which decodes as nil, and the screen hides the switch.
var askFirst: Bool? = nil
}
/// `POST /v1/threads/:hostId/:threadId/mode`.
struct ThreadModeRequest: Codable, Sendable {
var askFirst: Bool
}
struct ProjectsResponse: Codable, Hashable, Sendable {
@@ -274,20 +282,85 @@ struct ProjectsResponse: Codable, Hashable, Sendable {
var status: HostStatus
var error: String?
var projects: [Project]
/// The providers that can start a thread now, each with its models. Empty from older
/// gateways, which only start threads on the project's default model.
var providers: [AgentProvider] = []
/// Projects a new thread can start in. A project without a default model is not offered.
var launchable: [Project] { projects.filter { $0.model != nil } }
func provider(_ instanceId: String) -> AgentProvider? {
providers.first { $0.instanceId == instanceId }
}
/// True when this host still offers the model.
func offers(_ model: ModelRef) -> Bool {
provider(model.instanceId)?.models.contains { $0.slug == model.slug } ?? false
}
}
struct Project: Codable, Hashable, Sendable, Identifiable {
var id: String
var title: String
/// The model a new thread uses by default, such as `claude-opus-5-5`.
var model: String?
/// The provider `model` belongs to. Older gateways leave it out.
var instanceId: String? = nil
/// The default model as a pick, when the gateway names its provider.
var defaultModel: ModelRef? {
guard let model, let instanceId else { return nil }
return ModelRef(instanceId: instanceId, slug: model)
}
}
var hosts: [Host]
}
extension ProjectsResponse.Host {
private enum CodingKeys: String, CodingKey {
case id, label, status, error, projects, providers
}
/// `providers` is newer than the rest, so a gateway that leaves it out offers no model list.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
label: try container.decode(String.self, forKey: .label),
status: try container.decode(HostStatus.self, forKey: .status),
error: try container.decodeIfPresent(String.self, forKey: .error),
projects: try container.decode([ProjectsResponse.Project].self, forKey: .projects),
providers: try container.decodeIfPresent([AgentProvider].self, forKey: .providers) ?? [])
}
}
/// An agent provider on a host, such as Claude or Codex, with the models a new thread can use.
struct AgentProvider: Codable, Hashable, Sendable, Identifiable {
struct Model: Codable, Hashable, Sendable, Identifiable {
var slug: String
/// Such as "Claude Opus 5.5".
var name: String
var id: String { slug }
}
var instanceId: String
/// Such as "Claude" or "Codex".
var name: String
/// False when the provider cannot run in approval-required mode, so Ask before acting is hidden.
var canAskFirst: Bool
var models: [Model]
var id: String { instanceId }
}
/// A model picked for a new thread: a slug and the provider instance it runs on.
struct ModelRef: Codable, Hashable, Sendable {
var instanceId: String
var slug: String
}
/// How a route reaches its host, guessed by the gateway from the URL.
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
@@ -386,6 +459,8 @@ struct StatusResponse: Codable, Hashable, Sendable {
/// True once the watch has sent its APNs token.
var pushRegistered: Bool
var apnsEnvironment: PushRegistration.Environment?
/// Which pushes this watch gets. Older gateways leave it out, and the screen hides the switches.
var notifications: NotificationPrefs? = nil
}
var hosts: [HostStatusDetail]
@@ -393,6 +468,34 @@ struct StatusResponse: Codable, Hashable, Sendable {
var device: Device
}
/// Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
/// `subagents` covers the same from subagents. Host problems and test pushes always go out.
struct NotificationPrefs: Codable, Hashable, Sendable {
/// One switch, named as `PUT /v1/device/notifications` names it.
enum Key: String, CaseIterable, Sendable {
case needsMe, finished, failed, subagents
var keyPath: WritableKeyPath<NotificationPrefs, Bool> {
switch self {
case .needsMe: \.needsMe
case .finished: \.finished
case .failed: \.failed
case .subagents: \.subagents
}
}
}
var needsMe: Bool
var finished: Bool
var failed: Bool
var subagents: Bool
subscript(key: Key) -> Bool {
get { self[keyPath: key.keyPath] }
set { self[keyPath: key.keyPath] = newValue }
}
}
struct PairRequest: Codable, Sendable {
var code: String
var deviceName: String
@@ -453,6 +556,10 @@ struct NewThreadRequest: Codable, Sendable {
var hostId: String
var projectId: String
var text: String
/// A model from the host's `providers`. Nil uses the project's default.
var model: ModelRef? = nil
/// Starts the thread in approval-required mode. Nil or false uses the host's default mode.
var askFirst: Bool? = nil
}
struct NewThreadResponse: Codable, Sendable {
+43 -9
View File
@@ -1,7 +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, settling
// hides a thread from the feed, and so on.
// hides a thread from the feed, Ask before acting and the notification switches stick, and so on.
import Foundation
@@ -15,6 +15,8 @@ actor DemoGateway: GatewayAPI {
private var statusResponse: StatusResponse
/// Ids of settled threads, which the feed leaves out.
private var settled: Set<String> = []
/// Ids of threads in Ask before acting.
private var askingFirst: Set<String> = []
private var nextId = 1
init(now: Date = .now) {
@@ -36,7 +38,9 @@ actor DemoGateway: GatewayAPI {
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)
return ThreadDetail(
item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking,
askFirst: askingFirst.contains(item.id))
}
func sendMessage(hostId: String, threadId: String, text: String) async throws {
@@ -88,6 +92,11 @@ actor DemoGateway: GatewayAPI {
settled.insert(item.id)
}
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
if askFirst { askingFirst.insert(item.id) } else { askingFirst.remove(item.id) }
}
func recent() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.recent(items.filter { !settled.contains($0.id) }))
}
@@ -106,6 +115,9 @@ actor DemoGateway: GatewayAPI {
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.") }
if let model = request.model, !host.offers(model) {
throw GatewayError.http(status: 400, message: "\(host.label) does not offer \(model.slug).")
}
let threadId = "demo-\(newId())"
let item = FeedItem(
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
@@ -113,6 +125,7 @@ actor DemoGateway: GatewayAPI {
updatedAt: .now, lastMessage: nil, request: nil)
items.append(item)
messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)]
if request.askFirst == true { askingFirst.insert(item.id) }
return NewThreadResponse(hostId: host.id, threadId: threadId)
}
@@ -130,6 +143,13 @@ actor DemoGateway: GatewayAPI {
return result
}
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs {
var prefs = statusResponse.device.notifications ?? NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: true)
prefs[key] = on
statusResponse.device.notifications = prefs
return prefs
}
func unpair() async throws {}
// MARK: - Helpers
@@ -331,21 +351,33 @@ struct DemoFixtures {
],
]
let claude = AgentProvider(
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
models: [
.init(slug: "claude-opus-5-5", name: "Claude Opus 5.5"),
.init(slug: "claude-sonnet-5-5", name: "Claude Sonnet 5.5"),
.init(slug: "claude-haiku-5-5", name: "Claude Haiku 5.5"),
])
let codex = AgentProvider(
instanceId: "codex", name: "Codex", canAskFirst: true,
models: [.init(slug: "gpt-5.5", name: "GPT-5.5"), .init(slug: "gpt-5-codex", name: "GPT-5 Codex")])
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-wc-mac", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent"),
.init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5", instanceId: "claudeAgent"),
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex", instanceId: "codex"),
.init(id: "p-scratch", title: "scratch", model: nil),
]),
],
providers: [claude, codex]),
.init(
id: "server-gib", label: "server-gib", status: .online, error: nil,
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5")]),
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent")],
providers: [claude]),
.init(
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]),
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5", instanceId: "claudeAgent")]),
]
// Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline.
@@ -382,7 +414,9 @@ struct DemoFixtures {
tokenExpiresAt: days(29.5), serverVersion: "0.1.4"),
],
gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)),
device: .init(name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production))
device: .init(
name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production,
notifications: NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: false)))
}
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
+222 -64
View File
@@ -1,5 +1,6 @@
// 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.
// Start a thread: pick where (host, then project), pick the model from the host's providers, choose
// whether it asks before acting, and dictate the task. Defaults to the last host and project a
// thread was started in, the last model picked on that host, and the last Ask before acting.
import SwiftUI
@@ -12,79 +13,170 @@ struct NewThreadView: View {
@Environment(AppModel.self) private var model
@AppStorage("lastHostId") private var lastHostId = ""
@AppStorage("lastProjectId") private var lastProjectId = ""
/// The last model picked on each host, a JSON `[hostId: ModelRef]`.
@AppStorage("lastModels") private var lastModelsData = Data()
@AppStorage("askFirst") private var askFirst = false
@State private var projects: ProjectsResponse?
@State private var selection: ProjectRef?
@State private var picking = false
@State private var choosingAgent = false
@State private var sending = false
@State private var error: String?
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
private let scrolls: Bool
/// `projects` starts the screen loaded, on the first project a thread can start in, for snapshots.
init(projects: ProjectsResponse? = nil, scrolls: Bool = true) {
self.scrolls = scrolls
_projects = State(initialValue: projects)
_selection = State(initialValue: projects.flatMap {
Self.defaultSelection(in: $0, last: ProjectRef(hostId: "", projectId: ""))
})
}
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)
if scrolls {
ScrollView { column }
.navigationTitle("New thread")
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
.sheet(isPresented: $picking) {
if let projects {
ProjectPicker(projects: projects) { ref in
selection = ref
picking = false
}
}
} 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)
.navigationDestination(isPresented: $choosingAgent) {
if let projects, let chosen = resolve(selection, in: projects) {
AgentList(
providers: chosen.host.providers, selected: agent(for: chosen),
defaultInstanceId: chosen.project.instanceId
) { pick in
lastModels[chosen.host.id] = pick
}
}
}
}
.padding(.horizontal, 4)
.task { await load() }
} else {
column
}
.navigationTitle("New thread")
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
.sheet(isPresented: $picking) {
}
private var column: some View {
VStack(alignment: .leading, spacing: 6) {
if let projects {
ProjectPicker(projects: projects) { ref in
selection = ref
picking = false
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 {
let agent = agent(for: chosen)
let provider = agent.flatMap { chosen.host.provider($0.instanceId) }
agentRow(chosen, agent: agent)
if provider?.canAskFirst == true {
SwitchCard(title: "Ask before acting", isOn: $askFirst, tint: Theme.you, edge: Theme.done)
}
TextFieldLink(prompt: Text("What should it do?")) {
Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill")
} onSubmit: { text in
start(text, in: chosen, agent: agent, provider: provider)
}
.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)
}
/// The model the thread will use. It opens the model list when the host lists its providers.
@ViewBuilder
private func agentRow(_ chosen: Chosen, agent: ModelRef?) -> some View {
let card = Card(edge: Theme.done) {
Text("Agent")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
HStack {
Text(agent.flatMap { modelName($0, on: chosen.host) } ?? chosen.project.model.map(Format.modelName) ?? "")
.font(.system(size: 15, weight: .semibold))
if !chosen.host.providers.isEmpty {
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
}
}
}
.task { await load() }
if chosen.host.providers.isEmpty {
card
} else {
Button { choosingAgent = true } label: {
card.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private func modelName(_ ref: ModelRef, on host: ProjectsResponse.Host) -> String? {
host.provider(ref.instanceId)?.models.first { $0.slug == ref.slug }?.name
}
private var lastModels: [String: ModelRef] {
get { (try? JSONDecoder().decode([String: ModelRef].self, from: lastModelsData)) ?? [:] }
nonmutating set { lastModelsData = (try? JSONEncoder().encode(newValue)) ?? Data() }
}
private func agent(for chosen: Chosen) -> ModelRef? {
Self.agent(on: chosen.host, project: chosen.project, last: lastModels[chosen.host.id])
}
/// The last model picked on the host while the host still offers it, else the project's default.
nonisolated static func agent(
on host: ProjectsResponse.Host, project: ProjectsResponse.Project, last: ModelRef?
) -> ModelRef? {
if let last, host.offers(last) { return last }
return project.defaultModel
}
/// What a new thread sends besides its text. The model goes only when it differs from the
/// project's default, so the default keeps its options, such as reasoning effort. Ask before
/// acting is false for a provider that cannot ask, and left out when the host lists no providers.
nonisolated static func options(
project: ProjectsResponse.Project, agent: ModelRef?, provider: AgentProvider?, askFirst: Bool
) -> (model: ModelRef?, askFirst: Bool?) {
let model = agent == project.defaultModel ? nil : agent
return (model, provider.map { $0.canAskFirst && askFirst })
}
private func load() async {
@@ -101,9 +193,9 @@ struct NewThreadView: View {
}
}
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse)
-> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)?
{
private typealias Chosen = (host: ProjectsResponse.Host, project: ProjectsResponse.Project)
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) -> Chosen? {
guard let ref,
let host = projects.hosts.first(where: { $0.id == ref.hostId }),
let project = host.launchable.first(where: { $0.id == ref.projectId })
@@ -128,15 +220,17 @@ struct NewThreadView: View {
return nil
}
private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) {
private func start(_ text: String, in chosen: Chosen, agent: ModelRef?, provider: AgentProvider?) {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
sending = true
error = nil
let options = Self.options(project: chosen.project, agent: agent, provider: provider, askFirst: askFirst)
Task {
do {
let created = try await model.api.newThread(
NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text))
let created = try await model.api.newThread(NewThreadRequest(
hostId: chosen.host.id, projectId: chosen.project.id, text: text,
model: options.model, askFirst: options.askFirst))
lastHostId = chosen.host.id
lastProjectId = chosen.project.id
await model.refreshFeed()
@@ -155,6 +249,70 @@ struct NewThreadView: View {
}
}
/// Every model the host offers, grouped by provider, with the project default's provider on top.
/// The current pick has a check. Tapping a model picks it and goes back.
struct AgentList: View {
@Environment(\.dismiss) private var dismiss
var providers: [AgentProvider]
var selected: ModelRef?
var defaultInstanceId: String?
var scrolls = true
var pick: (ModelRef) -> Void
var body: some View {
if scrolls {
ScrollView { column }
.navigationTitle("Agent")
} else {
column
}
}
private var column: some View {
let ordered = providers.filter { $0.instanceId == defaultInstanceId } + providers.filter { $0.instanceId != defaultInstanceId }
return VStack(alignment: .leading, spacing: 5) {
ForEach(ordered) { provider in
Text(provider.name)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
ForEach(provider.models) { model in
let ref = ModelRef(instanceId: provider.instanceId, slug: model.slug)
Button {
pick(ref)
dismiss()
} label: {
Card(edge: Theme.card) {
HStack {
Text(Self.shortName(model.name, provider: provider.name))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
Spacer(minLength: 4)
if ref == selected {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Theme.work)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(ref == selected ? .isSelected : [])
}
}
}
.padding(.horizontal, 4)
}
/// "Claude Opus 5.5" under the Claude label reads "Opus 5.5".
static func shortName(_ name: String, provider: String) -> String {
let prefix = provider + " "
return name.hasPrefix(prefix) && name.count > prefix.count ? String(name.dropFirst(prefix.count)) : name
}
}
/// Two steps: the environment (host), then one of its projects.
private struct ProjectPicker: View {
var projects: ProjectsResponse
+56 -2
View File
@@ -1,5 +1,6 @@
// Settings mock C: an overview that leads with what is wrong, an environment page with mock B's
// route ladder, and a notifications page with a test push and Unpair. Each screen loads
// route ladder, and a notifications page with a switch per kind of push, a test push and Unpair.
// Each screen loads
// `GET /v1/status` when it appears; nothing polls.
import SwiftUI
@@ -262,12 +263,16 @@ private struct RouteLadder: View {
}
}
/// Whether this watch gets pushes, the last push, a test push, the gateway, and Unpair.
/// Whether this watch gets pushes, the last push, which pushes it wants, a test push, the gateway,
/// and Unpair.
struct NotificationsView: View {
@Environment(AppModel.self) private var model
var status: StatusResponse?
var scrolls = true
@State private var reload = 0
/// The switches as last set here, ahead of the next status load.
@State private var prefs: NotificationPrefs?
@State private var prefsError: String?
@State private var sending = false
@State private var result: PushResult?
@State private var error: String?
@@ -335,6 +340,29 @@ struct NotificationsView: View {
}
}
if let current = prefs ?? device.notifications {
Text("Notify me when")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
ForEach(NotificationPrefs.Key.allCases, id: \.self) { key in
SwitchCard(title: Self.label(key), isOn: Binding(
get: { current[key] },
set: { set(key, $0, from: current) }))
}
Text("Host problems always notify.")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
if let prefsError {
Text(prefsError)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
.padding(.horizontal, 6)
}
}
Button(sending ? "Sending" : "Send test") { sendTest() }
.buttonStyle(PillButtonStyle(fill: Theme.you))
.disabled(sending)
@@ -381,6 +409,32 @@ struct NotificationsView: View {
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.bad))
}
private static func label(_ key: NotificationPrefs.Key) -> String {
switch key {
case .needsMe: "Needs me"
case .finished: "Finished"
case .failed: "Failed"
case .subagents: "Subagents ask"
}
}
/// Flips the switch at once and sends just that key. If the gateway refuses, it flips back and
/// a line under the switches shows why.
private func set(_ key: NotificationPrefs.Key, _ on: Bool, from current: NotificationPrefs) {
var next = current
next[key] = on
prefs = next
prefsError = nil
Task {
do {
prefs = try await model.api.setNotification(key, on)
} catch {
prefs?[key] = !on
prefsError = model.message(for: error)
}
}
}
private func sendTest() {
sending = true
result = nil
+46
View File
@@ -45,6 +45,52 @@ struct Card<Content: View>: View {
}
}
/// A card holding one switch, like the mocks' setting rows. `tint` fills the switch when it is on.
/// The whole card toggles it.
struct SwitchCard: View {
var title: String
@Binding var isOn: Bool
var tint: Color = Theme.work
var edge: Color = Theme.card
var body: some View {
Toggle(isOn: $isOn) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
.toggleStyle(CardSwitchStyle(tint: tint, edge: edge))
}
}
/// The mocks' switch drawn by hand: watchOS's own renders as a placeholder in snapshots.
private struct CardSwitchStyle: ToggleStyle {
var tint: Color
var edge: Color
func makeBody(configuration: Configuration) -> some View {
Button { configuration.isOn.toggle() } label: {
Card(edge: edge) {
HStack {
configuration.label
Spacer(minLength: 4)
Capsule()
.fill(configuration.isOn ? tint : Color(white: 0.23))
.frame(width: 40, height: 24)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle().fill(.white).padding(2.5)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) { configuration.label }
}
}
}
/// A full-width pill button, like mock B's Reply and Say the task.
struct PillButtonStyle: ButtonStyle {
var fill: Color
+36 -3
View File
@@ -1,6 +1,7 @@
// 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, Stop while a run is active, and
// Settle once it finished or failed. A subagent's request shows first, labeled with the subagent.
// or question with its options, Reply by dictation or Scribble, Settle once it finished or failed,
// an Ask before acting switch, and Stop while a run is active. A subagent's request shows first,
// labeled with the subagent.
import SwiftUI
@@ -19,6 +20,10 @@ struct ThreadView: View {
/// Options ticked so far on multi-select questions, by question id.
@State private var selections: [String: Set<String>] = [:]
@State private var scrolledToLatest = false
/// Why the last Ask before acting change failed. It shows under the switch.
@State private var modeError: String?
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
@State private var pendingAskFirst: Bool?
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
private let scrolls: Bool
@@ -149,6 +154,15 @@ struct ThreadView: View {
.disabled(sending)
}
if let askFirst = detail.askFirst {
SwitchCard(title: "Ask before acting", isOn: Binding(get: { askFirst }, set: { setAskFirst($0) }))
if let modeError {
Text(modeError)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
}
}
if detail.canStop {
Button(role: .destructive) { stop() } label: {
Label("Stop", systemImage: "stop.fill")
@@ -300,7 +314,9 @@ struct ThreadView: View {
private func load() async {
do {
detail = try await model.api.thread(hostId: hostId, threadId: threadId)
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
if let pendingAskFirst { fresh.askFirst = pendingAskFirst }
detail = fresh
error = nil
} catch {
self.error = model.message(for: error)
@@ -357,6 +373,23 @@ struct ThreadView: View {
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
}
/// Flips the switch at once and tells the gateway. If it refuses, the switch flips back and the
/// line under it shows why.
private func setAskFirst(_ on: Bool) {
detail?.askFirst = on
pendingAskFirst = on
modeError = nil
Task {
do {
try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on)
} catch {
detail?.askFirst = !on
modeError = model.message(for: error)
}
pendingAskFirst = nil
}
}
/// Settles the thread and goes back to the feed. On failure the screen stays and shows why.
private func settle() {
sending = true
+61
View File
@@ -155,6 +155,8 @@ private let feedItemJSON = """
"canStop": true }
""")
#expect(detail.canStop)
// An older gateway leaves out askFirst, so the switch stays hidden.
#expect(detail.askFirst == nil)
#expect(detail.messages.first?.role == .assistant)
#expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000))
}
@@ -168,6 +170,35 @@ private let feedItemJSON = """
let host = try #require(response.hosts.first)
#expect(host.projects.count == 2)
#expect(host.launchable.map(\.id) == ["a"])
#expect(host.providers.isEmpty)
#expect(host.projects.first?.defaultModel == nil)
}
@Test func phase2FromDocs() throws {
let projects = try decode(ProjectsResponse.self, """
{ "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
"projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
"providers": [{ "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
"models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }] }] }
""")
let host = try #require(projects.hosts.first)
#expect(host.projects.first?.defaultModel == ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5"))
#expect(host.offers(ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5")))
#expect(!host.offers(ModelRef(instanceId: "codex", slug: "claude-opus-5-5")))
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true, "askFirst": false }"#)
#expect(detail.askFirst == false)
let prefs = try decode(NotificationPrefs.self, #"{ "needsMe": true, "finished": true, "failed": true, "subagents": false }"#)
#expect(NotificationPrefs.Key.allCases.map { prefs[$0] } == [true, true, true, false])
let request = try encodeObject(NewThreadRequest(
hostId: "h", projectId: "p", text: "t",
model: ModelRef(instanceId: "claudeAgent", slug: "claude-sonnet-5-5"), askFirst: true))
#expect((request["model"] as? [String: String]) == ["instanceId": "claudeAgent", "slug": "claude-sonnet-5-5"])
#expect(request["askFirst"] as? Bool == true)
let plain = try encodeObject(NewThreadRequest(hostId: "h", projectId: "p", text: "t"))
#expect(plain["model"] == nil && plain["askFirst"] == nil)
}
@Test func smallResponses() throws {
@@ -285,6 +316,36 @@ private let feedItemJSON = """
== ProjectRef(hostId: "on", projectId: "a"))
}
@Test func newThreadAgentAndOptions() {
let claude = AgentProvider(
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
models: [.init(slug: "opus", name: "Claude Opus"), .init(slug: "sonnet", name: "Claude Sonnet")])
let other = AgentProvider(instanceId: "other", name: "Other", canAskFirst: false, models: [.init(slug: "o1", name: "O1")])
let project = ProjectsResponse.Project(id: "p", title: "p", model: "opus", instanceId: "claudeAgent")
let host = ProjectsResponse.Host(id: "h", label: "H", status: .online, error: nil, projects: [project], providers: [claude, other])
let sonnet = ModelRef(instanceId: "claudeAgent", slug: "sonnet")
// The last pick wins while the host still offers it, else the project's default.
#expect(NewThreadView.agent(on: host, project: project, last: sonnet) == sonnet)
#expect(NewThreadView.agent(on: host, project: project, last: ModelRef(instanceId: "claudeAgent", slug: "gone"))
== project.defaultModel)
#expect(NewThreadView.agent(on: host, project: project, last: nil) == project.defaultModel)
// The default model is left out so it keeps its options. A provider that cannot ask sends false.
let byDefault = NewThreadView.options(project: project, agent: project.defaultModel, provider: claude, askFirst: true)
#expect(byDefault.model == nil && byDefault.askFirst == true)
let picked = NewThreadView.options(project: project, agent: sonnet, provider: claude, askFirst: false)
#expect(picked.model == sonnet && picked.askFirst == false)
let o1 = ModelRef(instanceId: "other", slug: "o1")
#expect(NewThreadView.options(project: project, agent: o1, provider: other, askFirst: true).askFirst == false)
// An older gateway lists no providers, so neither is sent.
let old = NewThreadView.options(project: project, agent: nil, provider: nil, askFirst: true)
#expect(old.model == nil && old.askFirst == nil)
#expect(AgentList.shortName("Claude Opus 5.5", provider: "Claude") == "Opus 5.5")
#expect(AgentList.shortName("GPT-5.5", provider: "Codex") == "GPT-5.5")
}
@Test func summaryFromFeed() throws {
let feed = DemoFixtures.previewFeed()
let summary = Summary(feed)
+21
View File
@@ -73,6 +73,27 @@ struct SnapshotTests {
try write(titled("Thread", ThreadView(hostId: "laptop", threadId: "t-status", detail: detail, scrolls: false)), "thread-settle.png")
}
@Test func runningThreadWithAskFirst() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-scaffold")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-scaffold", detail: detail, scrolls: false)), "thread.png")
}
@Test func newThreadAndAgentList() async throws {
let projects = try await DemoGateway().projects()
// The demo opens on Mac mini › wristcode, with Ask before acting on as the mock shows it.
UserDefaults.standard.removeObject(forKey: "lastModels")
UserDefaults.standard.set(true, forKey: "askFirst")
defer { UserDefaults.standard.removeObject(forKey: "askFirst") }
try write(titled("New thread", NewThreadView(projects: projects, scrolls: false)), "new-thread.png")
let host = try #require(projects.hosts.first)
let project = try #require(host.projects.first)
let list = AgentList(
providers: host.providers, selected: project.defaultModel, defaultInstanceId: project.instanceId,
scrolls: false) { _ in }
try write(titled("Agent", list), "agent-list.png")
}
@Test func statusScreens() async throws {
let status = try await DemoGateway().status()
try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png")
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 48 KiB