Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror gateway/src/contract.ts, with lenient string enums so new phases or decisions still decode. A DemoGateway serves fixtures from the docs/api.md examples for previews and demo mode. Swift Testing covers decoding the documented JSON, request encoding, notification action parsing and the small view helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
|
||||
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FeedView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var visible = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
FeedHeader(feed: model.feed, error: model.feedError)
|
||||
.feedRow()
|
||||
|
||||
if let feed = model.feed {
|
||||
ForEach(feed.needsYou) { item in
|
||||
NeedsYouCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.working) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
}
|
||||
ForEach(feed.recent) { item in
|
||||
StatusCard(item: item).feedRow()
|
||||
}
|
||||
if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty {
|
||||
Text("No threads in the last day.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.feedRow()
|
||||
}
|
||||
} else if model.feedError != nil {
|
||||
Button("Try again") { Task { await model.refreshFeed() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await model.refreshFeed() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink(value: Route.newThread) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("New thread")
|
||||
}
|
||||
}
|
||||
.onAppear { visible = true }
|
||||
.onDisappear { visible = false }
|
||||
// Refresh on appear, when the app comes to the foreground, and every 10 s while on screen.
|
||||
.task(id: visible && scenePhase == .active) {
|
||||
guard visible && scenePhase == .active else { return }
|
||||
while !Task.isCancelled {
|
||||
await model.refreshFeed()
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func feedRow() -> some View {
|
||||
listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The big count of threads waiting on you, plus offline hosts and refresh errors.
|
||||
private struct FeedHeader: View {
|
||||
var feed: Feed?
|
||||
var error: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let feed {
|
||||
let waiting = feed.needsYou.count
|
||||
Text("\(waiting)")
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
|
||||
Text(subtitle(feed))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
ForEach(feed.hosts.filter { $0.status != .online }) { host in
|
||||
Label("\(host.label) \(host.status.problem ?? "")", systemImage: "wifi.slash")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
}
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 4)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func subtitle(_ feed: Feed) -> String {
|
||||
let working = feed.working.count
|
||||
let lead = feed.needsYou.isEmpty ? "nothing waiting" : "waiting on you"
|
||||
return working > 0 ? "\(lead) · \(working) working" : lead
|
||||
}
|
||||
}
|
||||
|
||||
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||
private struct NeedsYouCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
@State private var sending = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
Card(edge: Theme.you) {
|
||||
Button { model.path.append(item.route) } label: {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("\(item.hostLabel) · \(item.projectTitle)")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.you)
|
||||
.lineLimit(1)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let request = item.request {
|
||||
Text(request.summary)
|
||||
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(3)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let request = item.request, let quick = request.quickOptions {
|
||||
HStack(spacing: 6) {
|
||||
Button(quick.allow.label) { respond(request, quick.allow.decision) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||
Button(quick.deny.label) { respond(request, quick.deny.decision) }
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
}
|
||||
.disabled(sending)
|
||||
.opacity(sending ? 0.5 : 1)
|
||||
.padding(.top, 7)
|
||||
} else if item.request?.isQuestion == true {
|
||||
Button("Answer") { model.path.append(item.route) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||
.padding(.top, 7)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.api.respond(
|
||||
hostId: item.hostId, threadId: item.threadId,
|
||||
request: .decision(requestId: request.id, decision: decision))
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
// Someone answered it already; the refresh below drops the card.
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
await model.refreshFeed()
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A teal card for a working thread, or a grey one for a recent thread.
|
||||
private struct StatusCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(item.route) } label: {
|
||||
Card(edge: item.phase.color) {
|
||||
Text(meta)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(item.phase == .failed ? Theme.secondaryText : item.phase.color)
|
||||
.lineLimit(1)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(line)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var meta: String {
|
||||
if item.phase.isWorking {
|
||||
return "\(item.hostLabel) · \(item.projectTitle)"
|
||||
}
|
||||
let age = Format.age(of: item.updatedAt)
|
||||
let state = item.phase == .failed ? "Failed" : age == "now" ? "just now" : "\(age) ago"
|
||||
return "\(item.hostLabel) · \(state)"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Feed") {
|
||||
NavigationStack {
|
||||
FeedView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// 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.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ProjectRef: Hashable {
|
||||
var hostId: String
|
||||
var projectId: String
|
||||
}
|
||||
|
||||
struct NewThreadView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@AppStorage("lastHostId") private var lastHostId = ""
|
||||
@AppStorage("lastProjectId") private var lastProjectId = ""
|
||||
|
||||
@State private var projects: ProjectsResponse?
|
||||
@State private var selection: ProjectRef?
|
||||
@State private var picking = false
|
||||
@State private var sending = false
|
||||
@State private var error: String?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
.navigationTitle("New thread")
|
||||
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
|
||||
.sheet(isPresented: $picking) {
|
||||
if let projects {
|
||||
ProjectPicker(projects: projects) { ref in
|
||||
selection = ref
|
||||
picking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
let response = try await model.api.projects()
|
||||
projects = response
|
||||
if selection == nil {
|
||||
selection = Self.defaultSelection(
|
||||
in: response, last: ProjectRef(hostId: lastHostId, projectId: lastProjectId))
|
||||
}
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse)
|
||||
-> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)?
|
||||
{
|
||||
guard let ref,
|
||||
let host = projects.hosts.first(where: { $0.id == ref.hostId }),
|
||||
let project = host.launchable.first(where: { $0.id == ref.projectId })
|
||||
else { return nil }
|
||||
return (host, project)
|
||||
}
|
||||
|
||||
/// The last used project when it can still start threads, else the first project on the
|
||||
/// first online host.
|
||||
nonisolated static func defaultSelection(in projects: ProjectsResponse, last: ProjectRef) -> ProjectRef? {
|
||||
if let host = projects.hosts.first(where: { $0.id == last.hostId }),
|
||||
host.launchable.contains(where: { $0.id == last.projectId })
|
||||
{
|
||||
return last
|
||||
}
|
||||
let hosts = projects.hosts.filter { $0.status == .online } + projects.hosts.filter { $0.status != .online }
|
||||
for host in hosts {
|
||||
if let project = host.launchable.first {
|
||||
return ProjectRef(hostId: host.id, projectId: project.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
let created = try await model.api.newThread(
|
||||
NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text))
|
||||
lastHostId = chosen.host.id
|
||||
lastProjectId = chosen.project.id
|
||||
await model.refreshFeed()
|
||||
// Replace this screen with the new thread, so Back returns to the feed.
|
||||
let route = Route.thread(hostId: created.hostId, threadId: created.threadId)
|
||||
if model.path.last == .newThread {
|
||||
model.path[model.path.count - 1] = route
|
||||
} else {
|
||||
model.path.append(route)
|
||||
}
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two steps: the environment (host), then one of its projects.
|
||||
private struct ProjectPicker: View {
|
||||
var projects: ProjectsResponse
|
||||
var pick: (ProjectRef) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(projects.hosts) { host in
|
||||
NavigationLink {
|
||||
List(host.launchable) { project in
|
||||
Button {
|
||||
pick(ProjectRef(hostId: host.id, projectId: project.id))
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(project.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text(project.model.map(Format.modelName) ?? "")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(host.label)
|
||||
.overlay {
|
||||
if host.launchable.isEmpty {
|
||||
Text("No projects with a default model.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(host.label)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text(host.status.problem ?? "\(host.launchable.count) projects")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(host.status == .online ? Theme.secondaryText : Theme.done)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Where")
|
||||
}
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("New thread") {
|
||||
NavigationStack {
|
||||
NewThreadView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Pairing: the gateway URL and the 6 digit code that `wristcode pair` prints on the gateway.
|
||||
// Shown whenever there is no device token, or the gateway answers 401.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PairView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var url = DeviceStore.lastGatewayURL
|
||||
@State private var code = ""
|
||||
@State private var sending = false
|
||||
@State private var error: String?
|
||||
|
||||
private var digits: String { code.filter(\.isNumber) }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("WristCode")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.you)
|
||||
Text("Run wristcode pair on the gateway, then enter the code.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
|
||||
field("Gateway") {
|
||||
TextField("Gateway URL", text: $url)
|
||||
.textContentType(.URL)
|
||||
}
|
||||
field("Code") {
|
||||
TextField("6 digits", text: $code)
|
||||
.textContentType(.oneTimeCode)
|
||||
}
|
||||
|
||||
Button(sending ? "Pairing" : "Pair") { pair() }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||
.disabled(sending || digits.count != 6)
|
||||
.opacity(digits.count == 6 ? 1 : 0.5)
|
||||
.padding(.top, 4)
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Button("Try the demo") { model.startDemo() }
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.work)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
private func field(_ label: String, @ViewBuilder input: () -> some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
input()
|
||||
}
|
||||
}
|
||||
|
||||
private func pair() {
|
||||
guard let gatewayURL = DeviceStore.normalizeGatewayURL(url) else {
|
||||
error = "That gateway URL doesn't look right."
|
||||
return
|
||||
}
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.pair(gatewayURL: gatewayURL, code: digits)
|
||||
} catch let failure as GatewayError where failure == .unauthorized {
|
||||
error = "That code is wrong or expired."
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Pair") {
|
||||
PairView()
|
||||
.environment(AppModel(mode: .unpaired))
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e
|
||||
static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5
|
||||
static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70
|
||||
static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a
|
||||
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
|
||||
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
|
||||
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
|
||||
}
|
||||
|
||||
extension Phase {
|
||||
var color: Color {
|
||||
if needsYou { return Theme.you }
|
||||
if isWorking { return Theme.work }
|
||||
return Theme.done
|
||||
}
|
||||
}
|
||||
|
||||
/// A dark rounded card with a 4 pt colored left edge.
|
||||
struct Card<Content: View>: View {
|
||||
var edge: Color
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.leading, 15)
|
||||
.padding(.trailing, 11)
|
||||
.background(alignment: .leading) {
|
||||
ZStack(alignment: .leading) {
|
||||
Theme.card
|
||||
edge.frame(width: 4)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-width pill button, like mock B's Reply and Say the task.
|
||||
struct PillButtonStyle: ButtonStyle {
|
||||
var fill: Color
|
||||
var foreground: Color = .black
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(foreground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// A smaller button for choices on a card, such as Allow and Deny.
|
||||
struct ChoiceButtonStyle: ButtonStyle {
|
||||
var fill: Color = Theme.control
|
||||
var foreground: Color = .white
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(foreground)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 4)
|
||||
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
enum Format {
|
||||
/// A short age for a card, such as "now", "4 min", "2 h" or "3 d".
|
||||
static func age(of date: Date, now: Date = .now) -> String {
|
||||
let minutes = Int(now.timeIntervalSince(date) / 60)
|
||||
if minutes < 1 { return "now" }
|
||||
if minutes < 60 { return "\(minutes) min" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours) h" }
|
||||
return "\(hours / 24) d"
|
||||
}
|
||||
|
||||
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
|
||||
static func modelName(_ id: String) -> String {
|
||||
var words: [String] = []
|
||||
var number: [String] = []
|
||||
func flush() {
|
||||
if !number.isEmpty { words.append(number.joined(separator: ".")) }
|
||||
number = []
|
||||
}
|
||||
for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) {
|
||||
if part.allSatisfy(\.isNumber) {
|
||||
number.append(part)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
switch part.lowercased() {
|
||||
case "gpt": words.append("GPT")
|
||||
default: words.append(part.prefix(1).uppercased() + part.dropFirst())
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return words.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// 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, and Stop while a run is active.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ThreadView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let hostId: String
|
||||
let threadId: String
|
||||
|
||||
@State private var detail: ThreadDetail?
|
||||
@State private var error: String?
|
||||
@State private var sending = false
|
||||
@State private var visible = false
|
||||
@State private var answers: [String: String] = [:]
|
||||
@State private var scrolledToLatest = false
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let detail {
|
||||
content(detail)
|
||||
} 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)
|
||||
}
|
||||
.onChange(of: detail?.messages.last?.id) {
|
||||
guard !scrolledToLatest, detail != nil else { return }
|
||||
scrolledToLatest = true
|
||||
proxy.scrollTo("latest", anchor: .top)
|
||||
}
|
||||
.onAppear {
|
||||
if detail != nil, !scrolledToLatest {
|
||||
scrolledToLatest = true
|
||||
proxy.scrollTo("latest", anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(detail?.item.title ?? "Thread")
|
||||
.onAppear { visible = true }
|
||||
.onDisappear { visible = false }
|
||||
.task(id: visible && scenePhase == .active) {
|
||||
guard visible && scenePhase == .active else { return }
|
||||
while !Task.isCancelled {
|
||||
await load()
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ detail: ThreadDetail) -> some View {
|
||||
let split = MessageSplit(detail.messages)
|
||||
let item = detail.item
|
||||
|
||||
ForEach(split.earlier) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
|
||||
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.bodyText)
|
||||
.lineSpacing(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id("latest")
|
||||
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
||||
|
||||
ForEach(split.after) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
|
||||
Text(statusLine(item))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(item.phase.color)
|
||||
|
||||
if let request = item.request {
|
||||
if request.isQuestion {
|
||||
questions(request)
|
||||
} else {
|
||||
approval(request)
|
||||
}
|
||||
}
|
||||
|
||||
if item.phase == .completed && item.request == nil {
|
||||
QuickReplies { send($0) }
|
||||
.disabled(sending)
|
||||
}
|
||||
|
||||
TextFieldLink(prompt: Text("Reply")) {
|
||||
Label("Reply", systemImage: "mic.fill")
|
||||
} onSubmit: { text in
|
||||
send(text)
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
||||
.disabled(sending)
|
||||
|
||||
if detail.canStop {
|
||||
Button(role: .destructive) { stop() } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red))
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
|
||||
private func approval(_ request: PendingRequest) -> some View {
|
||||
Card(edge: Theme.you) {
|
||||
if let prompt = request.prompt {
|
||||
Text(prompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(request.summary)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
if let reason = request.reason {
|
||||
Text(reason)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in
|
||||
Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: index == 0 ? Theme.you : Theme.control,
|
||||
foreground: index == 0 ? .black : .white))
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
|
||||
private func questions(_ request: PendingRequest) -> some View {
|
||||
ForEach(request.questions) { question in
|
||||
Card(edge: Theme.you) {
|
||||
Text(question.header)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.you)
|
||||
Text(question.question)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.padding(.top, 1)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(question.options, id: \.value) { choice in
|
||||
let chosen = answers[question.id] == choice.value
|
||||
Button(choice.label) { answer(request, question.id, choice.value) }
|
||||
.buttonStyle(ChoiceButtonStyle(
|
||||
fill: chosen ? Theme.you : Theme.control,
|
||||
foreground: chosen ? .black : .white))
|
||||
}
|
||||
if question.allowCustomAnswer {
|
||||
TextFieldLink(prompt: Text(question.question)) {
|
||||
Text("Other…")
|
||||
} onSubmit: { text in
|
||||
answer(request, question.id, text)
|
||||
}
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLine(_ item: FeedItem) -> String {
|
||||
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||
return switch item.phase {
|
||||
case .waitingForApproval: "Needs approval · \(place)"
|
||||
case .waitingForInput: "Has a question · \(place)"
|
||||
case .starting, .running: "Working · \(place)"
|
||||
case .failed: "Failed · \(place)"
|
||||
default: place
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
detail = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await action()
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
self.error = "Already answered."
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
await load()
|
||||
await model.refreshFeed()
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ text: String) {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
||||
}
|
||||
|
||||
private func respond(_ request: RespondRequest) {
|
||||
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
||||
}
|
||||
|
||||
/// Records one answer. Once every question has one, sends them together.
|
||||
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
|
||||
answers[questionId] = value
|
||||
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||
guard all else { return }
|
||||
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||
answers = [:]
|
||||
respond(.answers(requestId: request.id, answers: payload))
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a thread's messages around the agent's latest message, which the screen shows large.
|
||||
struct MessageSplit {
|
||||
var earlier: [Message]
|
||||
var latest: Message?
|
||||
var after: [Message]
|
||||
|
||||
init(_ messages: [Message]) {
|
||||
guard let index = messages.lastIndex(where: { $0.role == .assistant }) else {
|
||||
earlier = messages
|
||||
latest = nil
|
||||
after = []
|
||||
return
|
||||
}
|
||||
earlier = Array(messages[..<index])
|
||||
latest = messages[index]
|
||||
after = Array(messages[(index + 1)...])
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageRow: View {
|
||||
var message: Message
|
||||
|
||||
var body: some View {
|
||||
let mine = message.role == .user
|
||||
Text(message.text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||
.padding(.horizontal, mine ? 9 : 0)
|
||||
.padding(.vertical, mine ? 6 : 0)
|
||||
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-tap replies for a finished turn, like mock B's chips.
|
||||
private struct QuickReplies: View {
|
||||
var send: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
|
||||
Button(reply) { send(reply) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.card))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Finished") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "littleman", threadId: "t-next")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Approval") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "mac-mini", threadId: "t-ocr")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
|
||||
#Preview("Question") {
|
||||
NavigationStack {
|
||||
ThreadView(hostId: "server-gib", threadId: "t-db")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
}
|
||||
Reference in new issue
Block a user