Watch: notification switches, a model list and Ask before acting on new threads, and Ask before acting on a thread
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 48 KiB |