// 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 the step the agent is on and how long it // has run), then recent. A Recent row swipes left to settle, and the Recent header settles every // completed thread. Past Recent's first 10, an "N more" row opens the full Recent list. The last // row opens All threads. 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 { let recent = model.visibleRecent(feed.recent) let more = model.settlingAll ? 0 : feed.moreRecent ForEach(feed.needsYou) { item in NeedsYouCard(item: item).feedRow() } ForEach(feed.working) { item in StatusCard(item: item).feedRow() } if !recent.isEmpty || more > 0 { RecentHeader(recent: recent, more: more).feedRow() } ForEach(recent) { item in RecentRow(item: item).feedRow() } if more > 0 { MoreRecentRow(count: more).feedRow() } if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 { Text("No threads right now.") .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) .feedRow() } AllThreadsRow().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: .topBarLeading) { NavigationLink(value: Route.status) { Image(systemName: "gearshape.fill") .foregroundStyle(.black) } .accessibilityLabel("Status and settings") } 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)) } } } } extension View { /// A clear List row with the feed's card insets. 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 } } /// A title over the finished and failed threads, with Settle all when any completed or when /// threads past `recent` may have. Settle all is one call for every completed thread in Recent. It /// leaves failed threads, which settle one at a time by swipe. struct RecentHeader: View { @Environment(AppModel.self) private var model var title = "Recent" var recent: [FeedItem] /// Threads in Recent that `recent` leaves out. var more = 0 var body: some View { VStack(alignment: .leading, spacing: 2) { HStack { Text(title) .foregroundStyle(Theme.secondaryText) Spacer() if more > 0 || recent.contains(where: { $0.phase == .completed }) { Button("Settle all") { Task { await model.settleAllRecent() } } .buttonStyle(.plain) .foregroundStyle(Theme.work) .disabled(model.settlingAll) } } .font(.system(size: 12, weight: .semibold)) if let error = model.settleAllError { Text(error) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) } } .padding(.horizontal, 8) .padding(.top, 4) } } /// A Recent thread: its card, which opens the thread, and a left swipe to settle it. struct RecentRow: View { @Environment(AppModel.self) private var model var item: FeedItem var body: some View { StatusCard(item: item) .swipeActions(edge: .trailing) { Button { Task { await model.settle([item]) } } label: { Label("Settle", systemImage: "checkmark") } .tint(Theme.work) } } } /// The last Recent row when the feed leaves threads out: "N more", dimmer than a card, opening the /// full Recent list. struct MoreRecentRow: View { @Environment(AppModel.self) private var model var count: Int var body: some View { Button { model.path.append(.recent) } label: { Card(edge: Theme.done.opacity(0.5)) { HStack { Text("\(count) more") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.done) Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.done) } } .opacity(0.8) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("\(count) more recent threads") } } /// The feed's last row, a plain card opening All threads. struct AllThreadsRow: View { @Environment(AppModel.self) private var model var body: some View { Button { model.path.append(.allThreads) } label: { Card(edge: Theme.done) { HStack { Text("All threads") .font(.system(size: 13, weight: .semibold)) Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } } /// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline. /// When a subagent asked, one line names it, and the answer goes to the subagent. 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 via = item.via, let request = item.request { let name = Text(via.title).bold().foregroundStyle(.white) Text(request.isQuestion ? "Subagent \(name) asks:" : "Subagent \(name) wants to run:") .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .lineLimit(2) .padding(.top, 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 { let target = item.requestThread try await model.api.respond( hostId: target.hostId, threadId: target.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. A working thread shows the /// step it is on, or its latest message when the gateway names no step. A recent thread shows what /// its latest run changed, such as "+42 −8 · 3 files", and why it failed to settle. 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 activity = item.activity { ActivityLine(activity: activity) .padding(.top, 3) } else if item.phase.isWorking, let line = item.lastMessage { Text(Markdown.render(line)) .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .lineLimit(2) .padding(.top, 3) } else if !item.phase.isWorking, let changes = item.changes { Text(changes: changes) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .lineLimit(1) .padding(.top, 3) } if let error = model.settleErrors[item.id] { Text(error) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .padding(.top, 4) } } .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)" } } /// What a working thread is doing: a teal symbol for the kind of step, its text, and how long it has /// run. The elapsed time is worked out when the card renders, so the feed's 10 s refresh moves it on. struct ActivityLine: View { var activity: Activity var body: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Image(systemName: activity.kind.symbol) .font(.system(size: 10, weight: .bold)) .foregroundStyle(Theme.work) .frame(width: 14) label .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .lineLimit(2) Spacer(minLength: 4) if let startedAt = activity.startedAt { Text(Format.elapsed(since: startedAt)) .font(.system(size: 11, weight: .semibold)) .monospacedDigit() .foregroundStyle(Theme.work) } } } private var label: Text { let mono = Text(code: activity.text).font(.system(size: 11)) return switch activity.kind { case .command: mono case .edit: Text("Editing \(mono)") default: Text(activity.text) } } } #Preview("Feed") { NavigationStack { FeedView() } .environment(AppModel.preview()) .fontDesign(.rounded) }