443 lines
18 KiB
Swift
443 lines
18 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, and the files a file approval would edit), then working (with
|
||
// the step the agent is on and how long it has run), then recent. A Recent row swipes left to settle
|
||
// or snooze, and the Recent header settles every completed thread. Past Recent's first 10, an "N
|
||
// more" row opens the full Recent list, and a violet row under it opens the snoozed threads. The last
|
||
// row opens All threads. At launch the feed saved last time shows at once, marked Updating until the
|
||
// first refresh answers.
|
||
|
||
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, cached: model.feedIsCached)
|
||
.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.snoozedTotal > 0 {
|
||
SnoozedRow(count: feed.snoozedTotal).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. A feed from the
|
||
/// cache says Updating beside the count until the first refresh answers.
|
||
struct FeedHeader: View {
|
||
var feed: Feed?
|
||
var error: String?
|
||
var cached = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
if let feed {
|
||
let waiting = feed.needsYou.count
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text("\(waiting)")
|
||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
|
||
Spacer(minLength: 4)
|
||
if cached {
|
||
Text("Updating")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(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 or to snooze it,
|
||
/// which opens the Snooze until picker.
|
||
struct RecentRow: View {
|
||
@Environment(AppModel.self) private var model
|
||
var item: FeedItem
|
||
|
||
var body: some View {
|
||
StatusCard(item: item)
|
||
.swipeActions(edge: .trailing) {
|
||
// The first button sits at the edge, and a full swipe runs it.
|
||
Button { Task { await model.settle([item]) } } label: {
|
||
Label("Settle", systemImage: "checkmark")
|
||
}
|
||
.tint(Theme.work)
|
||
Button { model.snoozing = item } label: {
|
||
Label("Snooze", systemImage: "moon.fill")
|
||
}
|
||
.tint(Theme.snooze)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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. A file approval
|
||
/// shows "Wants to edit" and the files in place of its prompt.
|
||
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:" : request.fileEdit != nil ? "Subagent \(name):" : "Subagent \(name) wants to run:")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Theme.secondaryText)
|
||
.lineLimit(2)
|
||
.padding(.top, 2)
|
||
}
|
||
if let edit = item.request?.fileEdit {
|
||
FileEditRows(edit: edit, limit: 3)
|
||
.padding(.top, 2)
|
||
} else 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)
|
||
}
|