// 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) }