Files

444 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
model.beginChange()
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)
}