diff --git a/watch/README.md b/watch/README.md index a7f5d43..2ccaf9a 100644 --- a/watch/README.md +++ b/watch/README.md @@ -55,7 +55,8 @@ xcrun simctl launch 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 diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index fdab6cd..ca48b59 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -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?) } diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index 21838c7..ea5d279 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -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 { + 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 { diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index b0af1b1..6782a79 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -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 = [] + /// Ids of threads in Ask before acting. + private var askingFirst: Set = [] 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 diff --git a/watch/WristCode/Views/NewThreadView.swift b/watch/WristCode/Views/NewThreadView.swift index 9fa19c9..a6dfa33 100644 --- a/watch/WristCode/Views/NewThreadView.swift +++ b/watch/WristCode/Views/NewThreadView.swift @@ -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 diff --git a/watch/WristCode/Views/StatusView.swift b/watch/WristCode/Views/StatusView.swift index e2937e0..936718c 100644 --- a/watch/WristCode/Views/StatusView.swift +++ b/watch/WristCode/Views/StatusView.swift @@ -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 diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index a401d7f..e68be1f 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -45,6 +45,52 @@ struct Card: 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 diff --git a/watch/WristCode/Views/ThreadView.swift b/watch/WristCode/Views/ThreadView.swift index fdf166a..4c2a057 100644 --- a/watch/WristCode/Views/ThreadView.swift +++ b/watch/WristCode/Views/ThreadView.swift @@ -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] = [:] @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 diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 8eb9a89..f8ebb4f 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -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) diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index 046b76d..5b3607b 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -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") diff --git a/watch/screenshots/agent-list.png b/watch/screenshots/agent-list.png new file mode 100644 index 0000000..c8fff2c Binary files /dev/null and b/watch/screenshots/agent-list.png differ diff --git a/watch/screenshots/new-thread.png b/watch/screenshots/new-thread.png index 5c2840f..8ec8151 100644 Binary files a/watch/screenshots/new-thread.png and b/watch/screenshots/new-thread.png differ diff --git a/watch/screenshots/status-notifications.png b/watch/screenshots/status-notifications.png index 02150b6..64e807d 100644 Binary files a/watch/screenshots/status-notifications.png and b/watch/screenshots/status-notifications.png differ diff --git a/watch/screenshots/thread.png b/watch/screenshots/thread.png index a4abd6e..a6e5945 100644 Binary files a/watch/screenshots/thread.png and b/watch/screenshots/thread.png differ