Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
1 parent
4dc4e57dac
commit
edc4b88990
35 files changed
+1162
-144
No files matched your search
@@ -104,7 +104,7 @@ struct ProjectThreadsView: View {
|
||||
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) { self.error = message }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ struct SearchView: View {
|
||||
response = try await model.api.search(query)
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) { self.error = message }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,8 +276,9 @@ struct ThreadLine<Trailing: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
|
||||
/// `header` shows above the rows even while they load.
|
||||
/// The List each browse screen scrolls, with Loading, an error and Try again. Loads on appear.
|
||||
/// `header` shows above the rows even while they load. Once rows show, a failed reload only adds a
|
||||
/// dim line under them.
|
||||
private struct BrowseList<Header: View, Rows: View>: View {
|
||||
var scrolls: Bool
|
||||
var error: String?
|
||||
@@ -302,19 +303,10 @@ private struct BrowseList<Header: View, Rows: View>: View {
|
||||
header
|
||||
if loaded {
|
||||
rows
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.feedRow()
|
||||
}
|
||||
if let error {
|
||||
BrowseNote(text: error)
|
||||
if !loaded {
|
||||
Button("Try again") { Task { await reload() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
if !loaded || error != nil {
|
||||
LoadStatus(loaded: loaded, error: error, retry: reload)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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.
|
||||
// (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
|
||||
|
||||
@@ -13,7 +15,7 @@ struct FeedView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
FeedHeader(feed: model.feed, error: model.feedError)
|
||||
FeedHeader(feed: model.feed, error: model.feedError, cached: model.feedIsCached)
|
||||
.feedRow()
|
||||
|
||||
if let feed = model.feed {
|
||||
@@ -34,6 +36,9 @@ struct FeedView: View {
|
||||
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))
|
||||
@@ -87,18 +92,28 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The big count of threads waiting on you, plus offline hosts and refresh errors.
|
||||
private struct FeedHeader: View {
|
||||
/// 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
|
||||
Text("\(waiting)")
|
||||
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
|
||||
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)
|
||||
@@ -166,7 +181,8 @@ struct RecentHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Recent thread: its card, which opens the thread, and a left swipe to settle it.
|
||||
/// 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
|
||||
@@ -174,10 +190,15 @@ struct RecentRow: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +253,8 @@ struct AllThreadsRow: View {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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
|
||||
@@ -252,13 +274,16 @@ struct NeedsYouCard: View {
|
||||
.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:")
|
||||
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 request = item.request {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// What a file approval would edit, shown on the feed card and the thread screen in place of the raw
|
||||
// prompt. Claude sends no diff before approval, so most show the file name alone.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// "Wants to edit" over one file, or "Wants to edit 3 files" over a row for each: the name in
|
||||
/// monospace and, when the gateway counted them, the lines added and removed on the right. On the
|
||||
/// thread screen the title is amber and one file's first changed lines follow, removed in red and
|
||||
/// added in green. Past `limit` files, one line counts the rest.
|
||||
struct FileEditRows: View {
|
||||
var edit: FileEdit
|
||||
var limit = Int.max
|
||||
var inThread = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(edit.title)
|
||||
.font(.system(size: inThread ? 11 : 12, weight: inThread ? .semibold : .regular))
|
||||
.foregroundStyle(inThread ? Theme.you : Theme.secondaryText)
|
||||
ForEach(edit.files.prefix(limit)) { file in
|
||||
HStack(spacing: 6) {
|
||||
Text(code: file.name)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 4)
|
||||
if let counts = counts(file) {
|
||||
counts
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
if edit.files.count > limit {
|
||||
Text("\(edit.files.count - limit) more")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if inThread, !edit.preview.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(edit.preview) { line in
|
||||
Text(code: line.text)
|
||||
.foregroundStyle(color(line.change))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// "+9 −6" in monospace, green and red, leaving out a count the gateway did not send.
|
||||
private func counts(_ file: PendingFile) -> Text? {
|
||||
let parts = [
|
||||
file.counts.additions.map { Text(code: $0).foregroundStyle(Theme.add) },
|
||||
file.counts.deletions.map { Text(code: $0).foregroundStyle(Theme.bad) },
|
||||
].compactMap(\.self)
|
||||
guard let first = parts.first else { return nil }
|
||||
return parts.dropFirst().reduce(first) { Text("\($0)\(Text(code: " "))\($1)") }
|
||||
}
|
||||
|
||||
private func color(_ change: FileEdit.Line.Change) -> Color {
|
||||
switch change {
|
||||
case .added: Theme.add
|
||||
case .removed: Theme.bad
|
||||
case .context: Theme.secondaryText
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// How every screen that loads data shows it: a static "Loading" before the first answer, the error
|
||||
// and Try again when there is nothing to show, and once content is on screen, a small dim line under
|
||||
// it when a refresh fails. Content is never replaced by an error.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The placeholder before a screen's first answer, the same as the feed's. Static, with no spinner.
|
||||
struct LoadingText: View {
|
||||
var body: some View {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a screen shows under its content about loading. `loaded` is true once content is on screen.
|
||||
/// Before then: Loading, or the error with Try again when `retry` is set. After: only a failed
|
||||
/// refresh's error, small and dim.
|
||||
struct LoadStatus: View {
|
||||
var loaded: Bool
|
||||
var error: String?
|
||||
var retry: (() async -> Void)?
|
||||
|
||||
var body: some View {
|
||||
if let error {
|
||||
if loaded {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
.lineLimit(3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
if let retry {
|
||||
Button("Try again") { Task { await retry() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else if !loaded {
|
||||
LoadingText()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,16 +106,8 @@ struct NewThreadView: View {
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
LoadStatus(loaded: projects != nil, error: error, retry: load)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -189,7 +181,7 @@ struct NewThreadView: View {
|
||||
}
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) { self.error = message }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The full Recent list, opened from the feed's "N more" row. It loads `GET /v1/recent` when it
|
||||
// appears; nothing polls. Rows look and swipe like the feed's, and the header has Settle all.
|
||||
// appears; nothing polls. Rows look and swipe like the feed's, to settle or snooze, and the header
|
||||
// has Settle all.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -31,23 +32,10 @@ struct RecentView: View {
|
||||
ForEach(items) { item in
|
||||
RecentRow(item: item).feedRow()
|
||||
}
|
||||
} else if model.allRecentError == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.feedRow()
|
||||
}
|
||||
if let error = model.allRecentError {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
if model.allRecent == nil || model.allRecentError != nil {
|
||||
LoadStatus(loaded: model.allRecent != nil, error: model.allRecentError, retry: model.loadAllRecent)
|
||||
.feedRow()
|
||||
if model.allRecent == nil {
|
||||
Button("Try again") { Task { await model.loadAllRecent() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Snoozing a finished thread hides it from Recent until a time picked from four fixed choices, and
|
||||
// the gateway brings it back on its own. A Recent row's swipe and the thread screen's Snooze button
|
||||
// open the same picker. When any thread is snoozed, a dim violet row under Recent counts them and
|
||||
// opens the Snoozed list, where each card says when it wakes and swipes to Unsnooze.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One of the picker's times, worked out in the watch's time zone.
|
||||
struct SnoozeChoice: Hashable, Identifiable {
|
||||
var title: String
|
||||
var until: Date
|
||||
|
||||
var id: String { title }
|
||||
|
||||
/// In 1 hour, This evening at 6 PM (left out from 5 PM on), Tomorrow at 9 AM, and Next week,
|
||||
/// which is next Monday at 9 AM.
|
||||
static func choices(now: Date = .now, calendar: Calendar = .current) -> [SnoozeChoice] {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
func at(_ hour: Int, daysAfter days: Int) -> Date? {
|
||||
calendar.date(byAdding: .day, value: days, to: today)
|
||||
.flatMap { calendar.date(bySettingHour: hour, minute: 0, second: 0, of: $0) }
|
||||
}
|
||||
var choices = [SnoozeChoice(title: "In 1 hour", until: now.addingTimeInterval(3600))]
|
||||
if calendar.component(.hour, from: now) < 17, let evening = at(18, daysAfter: 0) {
|
||||
choices.append(SnoozeChoice(title: "This evening · 6 PM", until: evening))
|
||||
}
|
||||
if let tomorrow = at(9, daysAfter: 1) {
|
||||
choices.append(SnoozeChoice(title: "Tomorrow · 9 AM", until: tomorrow))
|
||||
}
|
||||
// Days until the next Monday, a full week when today is Monday. Weekday 2 is Monday.
|
||||
let days = (9 - calendar.component(.weekday, from: now)) % 7
|
||||
if let monday = at(9, daysAfter: days == 0 ? 7 : days) {
|
||||
choices.append(SnoozeChoice(title: "Next week", until: monday))
|
||||
}
|
||||
return choices
|
||||
}
|
||||
}
|
||||
|
||||
extension Format {
|
||||
/// When a snoozed thread comes back: "Wakes at 6 PM" today, "Wakes tomorrow 9 AM", "Wakes Monday
|
||||
/// 9 AM" within the week, and "Wakes Nov 30, 9 AM" past that.
|
||||
static func wakes(at date: Date, now: Date = .now, calendar: Calendar = .current) -> String {
|
||||
let style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), calendar: calendar, timeZone: calendar.timeZone)
|
||||
// "6:00 PM" becomes "6 PM". Some formatters put a narrow space before PM.
|
||||
let time = date.formatted(style.hour(.defaultDigits(amPM: .abbreviated)).minute())
|
||||
.replacingOccurrences(of: ":00", with: "")
|
||||
.replacingOccurrences(of: "\u{202F}", with: " ")
|
||||
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date)).day ?? 0
|
||||
switch days {
|
||||
case ...0: return "Wakes at \(time)"
|
||||
case 1: return "Wakes tomorrow \(time)"
|
||||
case 2 ..< 7:
|
||||
return "Wakes \(date.formatted(style.weekday(.wide))) \(time)"
|
||||
default:
|
||||
let day = date.formatted(style.month(.abbreviated).day())
|
||||
return "Wakes \(day), \(time)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Snooze until" and the four choices. Picking one snoozes the thread; on success the picker closes,
|
||||
/// and on failure it stays and says why.
|
||||
struct SnoozePicker: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
/// Fixed for snapshots. Live, the choices are worked out when the picker opens.
|
||||
var now: Date = .now
|
||||
/// Snapshots render the choices without their ScrollView, which ImageRenderer leaves blank.
|
||||
var scrolls = true
|
||||
@State private var sending = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
ScrollView { choices }
|
||||
.navigationTitle("Snooze until")
|
||||
} else {
|
||||
choices
|
||||
}
|
||||
}
|
||||
|
||||
private var choices: some View {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(SnoozeChoice.choices(now: now)) { choice in
|
||||
Button(choice.title) { snooze(until: choice.until) }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.card, foreground: .white))
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.disabled(sending)
|
||||
.opacity(sending ? 0.5 : 1)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private func snooze(until: Date) {
|
||||
sending = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.snooze(item, until: until)
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dim violet row under Recent, "☾ 2 snoozed", opening the Snoozed list.
|
||||
struct SnoozedRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var count: Int
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(.snoozed) } label: {
|
||||
Card(edge: Theme.snooze) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "moon.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
Text("\(count) snoozed")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(Theme.snooze)
|
||||
}
|
||||
.opacity(0.85)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(count) snoozed threads")
|
||||
}
|
||||
}
|
||||
|
||||
/// Every snoozed thread, the soonest to wake first. It loads `GET /v1/snoozed` when it appears;
|
||||
/// nothing polls.
|
||||
struct SnoozedView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if scrolls {
|
||||
List { rows }
|
||||
.listStyle(.plain)
|
||||
.refreshable { await model.loadSnoozed() }
|
||||
.task { await model.loadSnoozed() }
|
||||
} else {
|
||||
VStack(spacing: 6) { rows }
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Snoozed")
|
||||
}
|
||||
|
||||
@ViewBuilder private var rows: some View {
|
||||
if let items = model.snoozed {
|
||||
ForEach(items) { item in
|
||||
SnoozedCard(item: item).feedRow()
|
||||
}
|
||||
if items.isEmpty {
|
||||
Text("Nothing snoozed.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
if model.snoozed == nil || model.snoozedError != nil {
|
||||
LoadStatus(loaded: model.snoozed != nil, error: model.snoozedError, retry: model.loadSnoozed)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A snoozed thread: when it wakes in violet, its title, and where it runs. Opens the thread, and a
|
||||
/// left swipe unsnoozes it.
|
||||
private struct SnoozedCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var item: FeedItem
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(item.route) } label: {
|
||||
Card(edge: Theme.snooze) {
|
||||
if let until = item.snoozedUntil {
|
||||
Text(Format.wakes(at: until))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.snooze)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
Text("\(item.hostLabel) · \(item.projectTitle)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.done)
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button { Task { await model.unsnooze(item) } } label: {
|
||||
Label("Unsnooze", systemImage: "sun.max.fill")
|
||||
}
|
||||
.tint(Theme.snooze)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Snoozed") {
|
||||
NavigationStack {
|
||||
SnoozedView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Snooze until") {
|
||||
NavigationStack {
|
||||
SnoozePicker(item: DemoFixtures.previewRecent()[0])
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -486,21 +486,8 @@ private struct StatusLoader<Content: View>: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let status {
|
||||
content(status)
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 4)
|
||||
if status == nil {
|
||||
Button("Try again") { Task { await load() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
}
|
||||
}
|
||||
LoadStatus(loaded: status != nil, error: error, retry: load)
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
@@ -510,7 +497,7 @@ private struct StatusLoader<Content: View>: View {
|
||||
status = try await model.api.status()
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) { self.error = message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
|
||||
// and red means broken. Green and red also count lines added and removed.
|
||||
// red means broken, and violet means snoozed. Green and red also count lines added and removed.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -13,6 +13,7 @@ enum Theme {
|
||||
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
|
||||
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
|
||||
static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a
|
||||
static let snooze = Color(red: 0.608, green: 0.549, blue: 1.0) // #9b8cff
|
||||
}
|
||||
|
||||
extension Text {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
|
||||
// them, the pending approval or question with its options, the thread's subagents under a header
|
||||
// that collapses them, the files its latest run changed with their line counts, Reply by
|
||||
// dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop
|
||||
// while a run is active. A subagent's request shows first, labeled with the subagent. Message text
|
||||
// renders as markdown.
|
||||
// dictation or Scribble, Settle and Snooze once it finished or failed, an Ask before acting switch,
|
||||
// and Stop while a run is active. A subagent's request shows first, labeled with the subagent. A file
|
||||
// approval names the files and shows the first changed lines when the gateway has them. Message
|
||||
// text renders as markdown. The detail saved when the thread last loaded shows while it loads again.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -63,16 +64,8 @@ struct ThreadView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let detail {
|
||||
content(detail)
|
||||
} else if error == nil {
|
||||
Text("Loading")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
LoadStatus(loaded: detail != nil, error: error)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -95,7 +88,10 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(detail?.item.title ?? "Thread")
|
||||
.onAppear { visible = true }
|
||||
.onAppear {
|
||||
if detail == nil { detail = model.cachedThread(hostId: hostId, threadId: threadId) }
|
||||
visible = true
|
||||
}
|
||||
.onDisappear { visible = false }
|
||||
.task(id: visible && scenePhase == .active) {
|
||||
guard visible && scenePhase == .active else { return }
|
||||
@@ -183,10 +179,12 @@ struct ThreadView: View {
|
||||
.disabled(sending)
|
||||
|
||||
if settleable {
|
||||
Button { settle() } label: {
|
||||
Label("Settle", systemImage: "checkmark")
|
||||
HStack(spacing: 6) {
|
||||
Button("Settle") { settle() }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
||||
Button("Snooze") { model.snoozing = item }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.snooze))
|
||||
}
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
||||
.disabled(sending)
|
||||
}
|
||||
|
||||
@@ -272,7 +270,9 @@ struct ThreadView: View {
|
||||
private func approval(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
|
||||
Card(edge: Theme.you) {
|
||||
subagentLabel(via)
|
||||
if let prompt = request.prompt {
|
||||
if let edit = request.fileEdit {
|
||||
FileEditRows(edit: edit, inThread: true)
|
||||
} else if let prompt = request.prompt {
|
||||
Text(prompt)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
@@ -393,8 +393,9 @@ struct ThreadView: View {
|
||||
if let detail { history.keep(detail.messages) }
|
||||
detail = fresh
|
||||
error = nil
|
||||
model.saveThread(fresh)
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) { self.error = message }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user