Files
wristcode/watch/WristCode/Views/FeedView.swift
T
gibandClaude Opus 5.5 9731fd621f Watch: demo screenshots, wrapping reply chips, feed toolbar and inset fixes, README
Screenshots of Pair, Feed, Thread (finished, approval, question) and New
thread, taken in the Series 12 46mm simulator in demo mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:15 -05:00

236 lines
8.9 KiB
Swift

// 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")
.fontWeight(.bold)
.foregroundStyle(.black)
}
.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: 4, bottom: 3, trailing: 4))
}
}
/// 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)
}