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 / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
gib committed 2026-11-02 14:45:34 -05:00
1 parent 4dc4e57dac
commit edc4b88990
35 files changed
+1162 -144

No files matched your search

+8 -16
View File
@@ -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()
}
}
}
+39 -14
View File
@@ -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)
+72
View File
@@ -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
}
}
}
+53
View File
@@ -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()
}
}
}
+2 -10
View File
@@ -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 }
}
}
+4 -16
View File
@@ -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()
}
}
}
}
+236
View File
@@ -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)
}
+2 -15
View File
@@ -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 }
}
}
}
+2 -1
View File
@@ -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 {
+19 -18
View File
@@ -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 }
}
}