Watch: review fixes for action errors that vanished, feed refreshes dropped or overwritten around a change, a fresh thread screen per route, errors cleared on sign out, notification actions only while paired, cache writes off the main thread and capped at 30 threads, Ask before acting and Load earlier races, two-letter search, C# headings, and strictly encoded query values
This commit is contained in:
1 parent
99b300855e
commit
25be69fa30
14 files changed
+591
-94
No files matched your search
@@ -172,11 +172,25 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
/// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS
|
||||
/// text field does, and submitting opens the results.
|
||||
/// text field does, and submitting opens the results. A search under two letters stays here with a
|
||||
/// hint, since the gateway needs two.
|
||||
private struct SearchField: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var tooShort = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
field
|
||||
if tooShort {
|
||||
Text("Search for at least two letters.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var field: some View {
|
||||
TextFieldLink(prompt: Text("Search threads")) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
@@ -195,7 +209,8 @@ private struct SearchField: View {
|
||||
} onSubmit: { text in
|
||||
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return }
|
||||
model.path.append(.search(query: query))
|
||||
tooShort = query.count < Route.minimumSearch
|
||||
if !tooShort { model.path.append(.search(query: query)) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Search threads")
|
||||
|
||||
@@ -324,6 +324,7 @@ struct NeedsYouCard: View {
|
||||
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
|
||||
sending = true
|
||||
error = nil
|
||||
model.beginChange()
|
||||
Task {
|
||||
do {
|
||||
let target = item.requestThread
|
||||
|
||||
@@ -30,7 +30,8 @@ enum Markdown {
|
||||
|
||||
/// One line with its block syntax rewritten as inline markdown or plain text.
|
||||
static func block(_ line: String) -> String {
|
||||
if let heading = line.wholeMatch(of: /\s{0,3}#{1,6}\s+(.+?)[\s#]*/) {
|
||||
// A closing run of #s goes only after a space, so "C#" keeps its #.
|
||||
if let heading = line.wholeMatch(of: /\s{0,3}#{1,6}\s+(.+?)(?:\s+#+)?\s*/) {
|
||||
return "**\(heading.1)**"
|
||||
}
|
||||
if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// 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 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
|
||||
// and Stop while a run is active. A failed action says why above Reply, and a reply that did not
|
||||
// send shows its text with Send again. 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.
|
||||
|
||||
@@ -16,7 +17,13 @@ struct ThreadView: View {
|
||||
let threadId: String
|
||||
|
||||
@State private var detail: ThreadDetail?
|
||||
/// Why the last load failed. The next good load clears it.
|
||||
@State private var error: String?
|
||||
/// Why the last reply, answer, Settle, Stop or Load earlier failed. Loads leave it alone, so it
|
||||
/// stays until the next action.
|
||||
@State private var actionError: String?
|
||||
/// A reply that failed to send, offered again with Send again.
|
||||
@State private var unsent: String?
|
||||
@State private var sending = false
|
||||
@State private var visible = false
|
||||
@State private var answers: [String: RespondRequest.Answer] = [:]
|
||||
@@ -27,6 +34,8 @@ struct ThreadView: View {
|
||||
@State private var modeError: String?
|
||||
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
|
||||
@State private var pendingAskFirst: Bool?
|
||||
/// Bumped when an Ask before acting change succeeds. A load sent before it keeps the switch as is.
|
||||
@State private var askFirstGeneration = 0
|
||||
/// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
|
||||
@State private var history = MessageHistory()
|
||||
@State private var loadingEarlier = false
|
||||
@@ -169,6 +178,10 @@ struct ThreadView: View {
|
||||
.disabled(sending)
|
||||
}
|
||||
|
||||
if let actionError {
|
||||
actionFailure(actionError)
|
||||
}
|
||||
|
||||
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
|
||||
TextFieldLink(prompt: Text("Reply")) {
|
||||
Label("Reply", systemImage: "mic.fill")
|
||||
@@ -206,6 +219,26 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an action failed, and for a reply that did not send, its text and Send again.
|
||||
private func actionFailure(_ message: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.bad)
|
||||
if let unsent {
|
||||
Text("\u{201C}\(unsent)\u{201D}")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(3)
|
||||
Button("Send again") { send(unsent) }
|
||||
.buttonStyle(ChoiceButtonStyle())
|
||||
.disabled(sending)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
|
||||
/// A small grey title over a list, such as Changed.
|
||||
private func sectionLabel(_ title: String) -> some View {
|
||||
Text(title)
|
||||
@@ -387,9 +420,15 @@ struct ThreadView: View {
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
let askGeneration = askFirstGeneration
|
||||
do {
|
||||
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||
if let pendingAskFirst { fresh.askFirst = pendingAskFirst }
|
||||
if let pendingAskFirst {
|
||||
fresh.askFirst = pendingAskFirst
|
||||
} else if askGeneration != askFirstGeneration, let current = detail?.askFirst {
|
||||
// Sent before the switch last changed, so its value may be the old one.
|
||||
fresh.askFirst = current
|
||||
}
|
||||
if let detail { history.keep(detail.messages) }
|
||||
detail = fresh
|
||||
error = nil
|
||||
@@ -399,29 +438,39 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the page before `cursor` and puts it above the messages already shown.
|
||||
/// Fetches the page before `cursor` and puts it above the messages already shown. The window
|
||||
/// on screen is kept from now on, so a refresh landing before the page loses nothing.
|
||||
private func loadEarlier(_ cursor: String) {
|
||||
loadingEarlier = true
|
||||
actionError = nil
|
||||
if let detail { history.startKeeping(detail.messages) }
|
||||
Task {
|
||||
do {
|
||||
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
actionError = model.message(for: error)
|
||||
}
|
||||
loadingEarlier = false
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||
/// Runs an action, then reloads the thread and the feed. A failure shows in `actionError`, which
|
||||
/// the reload leaves alone. `text` is the reply being sent, offered again if it fails.
|
||||
private func perform(sending text: String? = nil, _ action: @escaping @MainActor () async throws -> Void) {
|
||||
sending = true
|
||||
error = nil
|
||||
actionError = nil
|
||||
unsent = nil
|
||||
model.beginChange()
|
||||
Task {
|
||||
do {
|
||||
try await action()
|
||||
} catch let failure as GatewayError where failure.isStale {
|
||||
self.error = "Already answered."
|
||||
actionError = "Already answered."
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
if let message = model.message(for: error) {
|
||||
actionError = message
|
||||
unsent = text
|
||||
}
|
||||
}
|
||||
await load()
|
||||
await model.refreshFeed()
|
||||
@@ -432,7 +481,7 @@ struct ThreadView: View {
|
||||
private func send(_ text: String) {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
||||
perform(sending: text) { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
||||
}
|
||||
|
||||
/// Answers the pending request on the thread that asked, which is the subagent when `via` is set.
|
||||
@@ -471,6 +520,7 @@ struct ThreadView: View {
|
||||
Task {
|
||||
do {
|
||||
try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on)
|
||||
askFirstGeneration += 1
|
||||
} catch {
|
||||
detail?.askFirst = !on
|
||||
modeError = model.message(for: error)
|
||||
@@ -482,14 +532,16 @@ struct ThreadView: View {
|
||||
/// Settles the thread and goes back to the feed. On failure the screen stays and shows why.
|
||||
private func settle() {
|
||||
sending = true
|
||||
error = nil
|
||||
actionError = nil
|
||||
unsent = nil
|
||||
model.beginChange()
|
||||
Task {
|
||||
do {
|
||||
try await model.api.settle(hostId: hostId, threadId: threadId)
|
||||
dismiss()
|
||||
await model.refreshFeed()
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
actionError = model.message(for: error)
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
@@ -516,13 +568,16 @@ struct MessageSplit {
|
||||
}
|
||||
|
||||
/// Older pages fetched by Load earlier, oldest first, kept in front of the latest window the thread
|
||||
/// route returns. Once a page is in, a refresh's outgoing window is kept too, so no gap opens when
|
||||
/// new messages push the window forward. A message shows once, by id, the latest window's copy first.
|
||||
/// route returns. Once Load earlier is asked for, a refresh's outgoing window is kept too, so no gap
|
||||
/// opens when new messages push the window forward. A message shows once, by id, and the copy
|
||||
/// received last replaces an earlier one, so a message caught mid-stream gets its full text.
|
||||
struct MessageHistory {
|
||||
private(set) var older: [Message] = []
|
||||
/// The cursor for the page before `older`, once a page has loaded.
|
||||
private var nextCursor: String?
|
||||
private var loaded = false
|
||||
/// True once Load earlier has been asked for. From then on, every outgoing window is kept.
|
||||
private var keeping = false
|
||||
|
||||
/// Where Load earlier fetches from next, or nil when the thread starts in view.
|
||||
func cursor(_ detail: ThreadDetail) -> String? {
|
||||
@@ -536,20 +591,33 @@ struct MessageHistory {
|
||||
}
|
||||
|
||||
mutating func prepend(_ page: HistoryResponse) {
|
||||
older = Self.unique(page.messages + older)
|
||||
older = Self.merge(page.messages, into: older, atFront: true)
|
||||
nextCursor = page.earlierCursor
|
||||
loaded = true
|
||||
keeping = true
|
||||
}
|
||||
|
||||
/// Keeps the window a refresh replaces, once older pages are in.
|
||||
/// Starts keeping outgoing windows, with `window`, as a Load earlier request goes out.
|
||||
mutating func startKeeping(_ window: [Message]) {
|
||||
keeping = true
|
||||
keep(window)
|
||||
}
|
||||
|
||||
/// Keeps the window a refresh replaces, once Load earlier has been asked for.
|
||||
mutating func keep(_ window: [Message]) {
|
||||
guard loaded else { return }
|
||||
older = Self.unique(older + window)
|
||||
guard keeping else { return }
|
||||
older = Self.merge(window, into: older, atFront: false)
|
||||
}
|
||||
|
||||
private static func unique(_ messages: [Message]) -> [Message] {
|
||||
var seen = Set<String>()
|
||||
return messages.filter { seen.insert($0.id).inserted }
|
||||
/// `base` with `incoming` merged in. A message already in `base` takes the incoming copy in
|
||||
/// place; the rest go in front or behind, in their own order.
|
||||
private static func merge(_ incoming: [Message], into base: [Message], atFront: Bool) -> [Message] {
|
||||
let copies = Dictionary(incoming.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
let baseIds = Set(base.map(\.id))
|
||||
var added = Set<String>()
|
||||
let new = incoming.filter { !baseIds.contains($0.id) && added.insert($0.id).inserted }
|
||||
let updated = base.map { copies[$0.id] ?? $0 }
|
||||
return atFront ? new + updated : updated + new
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user