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:
gib committed 2026-11-02 15:29:15 -05:00
1 parent 99b300855e
commit 25be69fa30
14 files changed
+556 -59

No files matched your search

+18 -6
View File
@@ -92,7 +92,9 @@ row leaves at once and comes back with the error if its call fails. Settle all,
and the full list, is one `POST /v1/recent/settle` for every completed thread in Recent, including and the full list, is one `POST /v1/recent/settle` for every completed thread in Recent, including
those past the first 10. Failed threads stay. The completed rows leave at once, and the header says those past the first 10. Failed threads stay. The completed rows leave at once, and the header says
so when the call fails or some threads did not settle. A finished notification has Settle too, so when the call fails or some threads did not settle. A finished notification has Settle too,
beside Reply and Open. It settles in the background without opening the app. beside Reply and Open. It settles in the background without opening the app. A background action
only runs while the watch is paired. After unpairing, or in demo mode, it posts a notification
saying the watch is not paired.
## Snooze ## Snooze
@@ -120,10 +122,17 @@ content, a refresh keeps it on screen, and a failed refresh only adds a small di
The error and Try again replace the placeholder only when there is nothing to show. When a screen The error and Try again replace the placeholder only when there is nothing to show. When a screen
goes away mid-request, the cancelled request shows no error. goes away mid-request, the cancelled request shows no error.
On every refresh the app saves the last good feed as JSON in its caches folder, and it saves each A feed refresh asked for while another is out, as after Settle, Snooze or Allow, waits for it and
thread's detail when the thread loads. Launch shows the saved feed at once, with a dim "Updating" by the then runs once more. A feed answer to a request sent before a change, or before signing out, is
count until the first refresh answers, and a thread opens on its saved detail while it loads. dropped, so a poll cannot put back a row the change removed. On the thread screen, a failed reply,
Unpairing clears both. Demo mode never touches them. answer, Settle or Stop says why above Reply until the next action, and a reply that did not send
shows its text with Send again.
The app saves the last good feed as JSON in its caches folder, and each thread's detail when the
thread loads, off the main thread and only when it changed. It keeps the 30 threads saved most
recently. Launch shows the saved feed at once, with a dim "Updating" by the count until the first
refresh answers, and a thread opens on its saved detail while it loads. Unpairing clears both. Demo
mode never touches them.
## Activity, All threads and history ## Activity, All threads and history
@@ -137,12 +146,15 @@ The feed's last row opens All threads: hosts from `GET /v1/projects` (offline on
host's projects by title, then the project's threads from host's projects by title, then the project's threads from
`GET /v1/projects/:hostId/:projectId/threads`, settled ones included. Each thread is one line with `GET /v1/projects/:hostId/:projectId/threads`, settled ones included. Each thread is one line with
a status dot and its age, and opens the usual thread screen. Each screen loads when it appears. a status dot and its age, and opens the usual thread screen. Each screen loads when it appears.
The search field at the top of All threads needs at least two letters, as the gateway does, and
says so under the field when given one.
A thread screen lists the thread's subagents under its messages, above Reply, each with a status A thread screen lists the thread's subagents under its messages, above Reply, each with a status
word. A row opens that subagent's thread. When the thread has messages before the latest 20, Load word. A row opens that subagent's thread. When the thread has messages before the latest 20, Load
earlier at the top fetches the page before them (`GET /v1/threads/:hostId/:threadId/history`), and earlier at the top fetches the page before them (`GET /v1/threads/:hostId/:threadId/history`), and
the next tap uses the cursor that page returned. The 10 s refresh replaces only the latest window. the next tap uses the cursor that page returned. The 10 s refresh replaces only the latest window.
Loaded pages stay, and a message shows once by id. Message text renders as markdown: bold, italics, Loaded pages stay, and from the first tap the window a refresh replaces is kept too. A message shows
once by id, as its newest copy, so one caught mid-stream gets its full text. Message text renders as markdown: bold, italics,
inline code and links inline, headings in bold, list items as bullets, and fenced code in monospace. inline code and links inline, headings in bold, list items as bullets, and fenced code in monospace.
Against a gateway older than these fields, the cards show the last message, and Load earlier and Against a gateway older than these fields, the cards show the last message, and Load earlier and
+14 -4
View File
@@ -197,6 +197,19 @@ struct GatewayClient: GatewayAPI {
error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription) error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription)
} }
/// `path` under `baseURL`, then `query`. `appending(component:)` percent-encodes each segment,
/// slashes included. Query values are encoded strictly, so a `+`, `&` or `=` in a search or an
/// opaque history cursor reaches the gateway as sent.
static func url(_ baseURL: URL, _ path: [String], query: [URLQueryItem] = []) -> URL {
let url = path.reduce(baseURL) { $0.appending(component: $1) }
guard !query.isEmpty, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
components.percentEncodedQueryItems = query.map {
URLQueryItem(name: $0.name, value: $0.value?.addingPercentEncoding(withAllowedCharacters: unreserved))
}
return components.url ?? url
}
private func call<Response: Decodable>( private func call<Response: Decodable>(
_ method: String, _ path: [String], query: [URLQueryItem] = [] _ method: String, _ path: [String], query: [URLQueryItem] = []
) async throws -> Response { ) async throws -> Response {
@@ -221,10 +234,7 @@ struct GatewayClient: GatewayAPI {
private func perform<Body: Encodable>( private func perform<Body: Encodable>(
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body? _ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
) async throws -> Data { ) async throws -> Data {
// appending(component:) percent-encodes each segment, slashes included. var request = URLRequest(url: Self.url(baseURL, path, query: query), timeoutInterval: 20)
var url = path.reduce(baseURL) { $0.appending(component: $1) }
if !query.isEmpty { url.append(queryItems: query) }
var request = URLRequest(url: url, timeoutInterval: 20)
request.httpMethod = method request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token { if let token {
+4 -1
View File
@@ -21,6 +21,8 @@ enum Route: Hashable, Sendable {
case search(query: String) case search(query: String)
static let scheme = "wristcode" static let scheme = "wristcode"
/// The fewest characters the gateway searches for.
static let minimumSearch = 2
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`, /// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`,
/// `wristcode://status`, /// `wristcode://status`,
@@ -92,7 +94,8 @@ enum Route: Hashable, Sendable {
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }: case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
self = .projectThreads(hostId: parts[0], projectId: parts[1]) self = .projectThreads(hostId: parts[0], projectId: parts[1])
case "search": case "search":
guard let query = components.queryItems?.first(where: { $0.name == "q" })?.value, !query.isEmpty else { return nil } guard let query = components.queryItems?.first(where: { $0.name == "q" })?.value, query.count >= Self.minimumSearch
else { return nil }
self = .search(query: query) self = .search(query: query)
default: default:
return nil return nil
+98 -16
View File
@@ -51,18 +51,31 @@ final class AppModel {
var snoozing: FeedItem? var snoozing: FeedItem?
/// Where the feed and thread details are saved. Only used while paired, never in demo mode. /// Where the feed and thread details are saved. Only used while paired, never in demo mode.
private let cache: FeedCache? private let cache: FeedCache?
/// Answers every call in place of the mode's gateway, for tests.
private let gateway: (any GatewayAPI)?
private var apnsToken: String? private var apnsToken: String?
private var refreshing = false /// The feed refresh in flight. A refresh asked for meanwhile waits on it, and it runs once more.
private var refreshTask: Task<Void, Never>?
/// Bumped when a change to threads starts, when a refresh is asked for during another, and on
/// reset. A feed answer to a request sent before the bump is dropped, and the refresh runs again.
private var feedGeneration = 0
/// What was last written to disk, so an unchanged feed or thread is not written again.
private var savedFeed: Feed?
private var savedThread: ThreadDetail?
/// The latest disk write. Writes run off the main actor, one after another.
private(set) var cacheWrite: Task<Void, Never>?
/// The counts the complication was last told about. /// The counts the complication was last told about.
private var sharedSummary: Summary? private var sharedSummary: Summary?
/// With a `cache` and no `feed`, a paired model starts on the saved feed. /// With a `cache` and no `feed`, a paired model starts on the saved feed.
init( init(
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil, mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil,
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil,
gateway: (any GatewayAPI)? = nil
) { ) {
self.mode = mode self.mode = mode
self.cache = cache self.cache = cache
self.gateway = gateway
self.allRecent = allRecent self.allRecent = allRecent
self.snoozed = snoozed self.snoozed = snoozed
self.projects = projects self.projects = projects
@@ -115,7 +128,8 @@ final class AppModel {
private var pairedCache: FeedCache? { isPaired ? cache : nil } private var pairedCache: FeedCache? { isPaired ? cache : nil }
var api: any GatewayAPI { var api: any GatewayAPI {
switch mode { if let gateway { return gateway }
return switch mode {
case let .paired(credentials): case let .paired(credentials):
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token) GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
case .demo: case .demo:
@@ -146,20 +160,32 @@ final class AppModel {
/// gateway URL is kept. /// gateway URL is kept.
func signOut() { func signOut() {
if isPaired { DeviceStore.clearToken() } if isPaired { DeviceStore.clearToken() }
cache?.clear() // After any write still queued, so none puts a file back.
if let cache { write { cache.clear() } }
mode = .unpaired mode = .unpaired
resetContent() resetContent()
} }
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out. /// Forgets what the screens loaded, for a new pairing, demo mode or signing out. A refresh still
/// in flight drops its answer.
private func resetContent() { private func resetContent() {
feedGeneration += 1
feed = nil feed = nil
feedError = nil feedError = nil
feedIsCached = false feedIsCached = false
settling = []
settleErrors = [:]
settlingAll = false
settleAllError = nil
allRecent = nil allRecent = nil
allRecentError = nil
snoozed = nil snoozed = nil
snoozedError = nil
snoozing = nil snoozing = nil
projects = nil projects = nil
projectsError = nil
savedFeed = nil
savedThread = nil
path = [] path = []
} }
@@ -189,23 +215,56 @@ final class AppModel {
// MARK: - Feed // MARK: - Feed
/// Loads the feed. Asked for while a refresh is in flight, it waits for that one, which then
/// runs again, since its answer may predate the caller's change.
func refreshFeed() async { func refreshFeed() async {
guard mode != .unpaired, !refreshing else { return } guard mode != .unpaired else { return }
refreshing = true if let refreshTask {
defer { refreshing = false } feedGeneration += 1
await refreshTask.value
return
}
let task = Task { await runRefresh() }
refreshTask = task
await task.value
}
/// Call as a change to threads goes out, such as a settle or an answer. A feed answer to a
/// request sent before it is dropped, so a poll cannot put back what the change removed.
func beginChange() {
feedGeneration += 1
}
/// Fetches the feed until an answer arrives with no change, reset or new request since it was
/// sent, then applies it. An answer from before signing out or switching mode is never applied.
private func runRefresh() async {
defer { refreshTask = nil }
while mode != .unpaired {
let generation = feedGeneration
let result: Result<Feed, any Error>
do { do {
let feed = try await api.feed() result = .success(try await api.feed())
} catch {
result = .failure(error)
}
guard generation == feedGeneration else { continue }
switch result {
case let .success(feed):
self.feed = feed self.feed = feed
feedError = nil feedError = nil
feedIsCached = false feedIsCached = false
let listed = Set((feed.needsYou + feed.working + feed.recent + (allRecent ?? [])).map(\.id))
settleErrors = settleErrors.filter { listed.contains($0.key) }
share(Summary(feed)) share(Summary(feed))
pairedCache?.save(feed) saveFeed(feed)
} catch { case let .failure(error):
// A cancelled refresh changes nothing; the next one answers. // A cancelled refresh changes nothing; the next one answers.
guard let message = message(for: error) else { return } guard let message = message(for: error) else { return }
feedError = message feedError = message
feedIsCached = false feedIsCached = false
} }
return
}
} }
/// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode. /// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode.
@@ -213,15 +272,34 @@ final class AppModel {
pairedCache?.thread(hostId: hostId, threadId: threadId) pairedCache?.thread(hostId: hostId, threadId: threadId)
} }
/// Saves a thread's detail for the next time it opens. /// Saves a thread's detail for the next time it opens, unless it is what was saved last.
func saveThread(_ detail: ThreadDetail) { func saveThread(_ detail: ThreadDetail) {
pairedCache?.save(detail) guard let cache = pairedCache, detail != savedThread else { return }
savedThread = detail
write { cache.save(detail) }
}
/// Saves the feed for the next launch, unless it is what was saved last.
private func saveFeed(_ feed: Feed) {
guard let cache = pairedCache, feed != savedFeed else { return }
savedFeed = feed
write { cache.save(feed) }
}
/// Runs a disk write off the main actor, after the writes before it.
private func write(_ body: @escaping @Sendable () -> Void) {
let previous = cacheWrite
cacheWrite = Task.detached(priority: .utility) {
await previous?.value
body()
}
} }
/// Settles items concurrently. Each one leaves the feed at once and comes back with its error /// Settles items concurrently. Each one leaves the feed at once and comes back with its error
/// if its call fails. Refreshes once every call has answered. /// if its call fails. Refreshes once every call has answered.
func settle(_ items: [FeedItem]) async { func settle(_ items: [FeedItem]) async {
let ids = Set(items.map(\.id)) let ids = Set(items.map(\.id))
beginChange()
settling.formUnion(ids) settling.formUnion(ids)
for id in ids { settleErrors[id] = nil } for id in ids { settleErrors[id] = nil }
let api = api let api = api
@@ -270,6 +348,7 @@ final class AppModel {
/// Settles every completed thread in Recent with one call, including those past the feed's first /// Settles every completed thread in Recent with one call, including those past the feed's first
/// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh. /// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh.
func settleAllRecent() async { func settleAllRecent() async {
beginChange()
settlingAll = true settlingAll = true
settleAllError = nil settleAllError = nil
do { do {
@@ -291,6 +370,7 @@ final class AppModel {
/// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker /// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker
/// closes, and its screen does too if it is on top. Then the feed refreshes. /// closes, and its screen does too if it is on top. Then the feed refreshes.
func snooze(_ item: FeedItem, until: Date) async throws { func snooze(_ item: FeedItem, until: Date) async throws {
beginChange()
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until) try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
snoozing = nil snoozing = nil
removeFromRecent(1) { $0.id == item.id } removeFromRecent(1) { $0.id == item.id }
@@ -311,17 +391,19 @@ final class AppModel {
} }
/// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and /// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and
/// the list refresh. On failure the list shows why. /// the list refresh. On failure the list shows why, once the reload has put the thread back.
func unsnooze(_ item: FeedItem) async { func unsnooze(_ item: FeedItem) async {
beginChange()
snoozed?.removeAll { $0.id == item.id } snoozed?.removeAll { $0.id == item.id }
var failure: String?
do { do {
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId) try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
snoozedError = nil
} catch { } catch {
snoozedError = message(for: error) failure = message(for: error)
} }
await refreshFeed() await refreshFeed()
await loadSnoozed() await loadSnoozed()
if let failure { snoozedError = failure }
} }
/// Drops settled threads from the feed and the full list until the next refresh confirms it. /// Drops settled threads from the feed and the full list until the next refresh confirms it.
+19 -3
View File
@@ -1,11 +1,13 @@
// The last good feed, and the last detail of each thread opened, saved as JSON in the app's caches // The last good feed, and the last detail of the 30 threads saved most recently, saved as JSON in
// folder. Launch shows them at once while the first refresh runs. Signing out clears them, and demo // the app's caches folder. Launch shows them at once while the first refresh runs. Signing out clears
// mode never reads or writes them. // them, and demo mode never reads or writes them.
import Foundation import Foundation
struct FeedCache: Sendable { struct FeedCache: Sendable {
let directory: URL let directory: URL
/// How many thread details are kept. Saving one more deletes the oldest.
var threadLimit = 30
static let standard = FeedCache(directory: .cachesDirectory.appending(path: "WristCode", directoryHint: .isDirectory)) static let standard = FeedCache(directory: .cachesDirectory.appending(path: "WristCode", directoryHint: .isDirectory))
@@ -23,6 +25,20 @@ struct FeedCache: Sendable {
func save(_ detail: ThreadDetail) { func save(_ detail: ThreadDetail) {
write(detail, Self.threadFile(detail.item.hostId, detail.item.threadId)) write(detail, Self.threadFile(detail.item.hostId, detail.item.threadId))
pruneThreads()
}
/// Deletes the thread details saved longest ago, past `threadLimit`.
private func pruneThreads() {
let fileManager = FileManager.default
let key = URLResourceKey.contentModificationDateKey
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [key]) else { return }
let threads = files.filter { $0.lastPathComponent.hasPrefix("thread-") }
guard threads.count > threadLimit else { return }
let modified = { (url: URL) in (try? url.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast }
for url in threads.sorted(by: { modified($0) > modified($1) }).dropFirst(threadLimit) {
try? fileManager.removeItem(at: url)
}
} }
/// Deletes everything saved, as when the watch unpairs. /// Deletes everything saved, as when the watch unpairs.
@@ -177,17 +177,15 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
await model.refreshFeed() await model.refreshFeed()
return return
case .respond, .answer: case .respond, .answer:
guard model.mode != .unpaired, let response = command.response else { return } guard let response = command.response else { return }
await attempt("Couldn't answer") { await attempt("Couldn't answer") {
try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request) try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request)
} }
case let .reply(hostId, threadId, text): case let .reply(hostId, threadId, text):
guard model.mode != .unpaired else { return }
await attempt("Couldn't send your reply") { await attempt("Couldn't send your reply") {
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
} }
case let .settle(hostId, threadId): case let .settle(hostId, threadId):
guard model.mode != .unpaired else { return }
await attempt("Couldn't settle") { await attempt("Couldn't settle") {
try await model.api.settle(hostId: hostId, threadId: threadId) try await model.api.settle(hostId: hostId, threadId: threadId)
} }
@@ -197,22 +195,32 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
} }
/// Runs a background action. If it fails, says so with a local notification, since nothing is /// Runs a background action. If it fails, says so with a local notification, since nothing is
/// on screen. /// on screen. Only a paired watch acts: after unpairing, or in demo mode, the action would go
/// nowhere or to the demo gateway, so the notification says the watch is not paired instead.
private func attempt(_ title: String, _ action: () async throws -> Void) async { private func attempt(_ title: String, _ action: () async throws -> Void) async {
guard AppModel.shared.isPaired else {
await notify(title, "This watch isn't paired. Open WristCode to pair it.")
return
}
AppModel.shared.beginChange()
do { do {
try await action() try await action()
} catch let error as GatewayError where error.isStale { } catch let error as GatewayError where error.isStale {
return return
} catch { } catch {
guard let message = AppModel.shared.message(for: error) else { return } guard let message = AppModel.shared.message(for: error) else { return }
await notify(title, message)
}
}
private func notify(_ title: String, _ body: String) async {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
content.body = message content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request) try? await UNUserNotificationCenter.current().add(request)
} }
} }
}
/// The long look for `input` pushes. It shows the push's title and question, and sets the push's /// The long look for `input` pushes. It shows the push's title and question, and sets the push's
/// own actions, which is the one place watchOS takes per-push action titles. /// own actions, which is the one place watchOS takes per-push action titles.
+17 -2
View File
@@ -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 /// 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 { private struct SearchField: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@State private var tooShort = false
var body: some View { 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")) { TextFieldLink(prompt: Text("Search threads")) {
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
@@ -195,7 +209,8 @@ private struct SearchField: View {
} onSubmit: { text in } onSubmit: { text in
let query = text.trimmingCharacters(in: .whitespacesAndNewlines) let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return } 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) .buttonStyle(.plain)
.accessibilityLabel("Search threads") .accessibilityLabel("Search threads")
+1
View File
@@ -324,6 +324,7 @@ struct NeedsYouCard: View {
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) { private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
sending = true sending = true
error = nil error = nil
model.beginChange()
Task { Task {
do { do {
let target = item.requestThread let target = item.requestThread
+2 -1
View File
@@ -30,7 +30,8 @@ enum Markdown {
/// One line with its block syntax rewritten as inline markdown or plain text. /// One line with its block syntax rewritten as inline markdown or plain text.
static func block(_ line: String) -> String { 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)**" return "**\(heading.1)**"
} }
if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) { if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) {
+88 -20
View File
@@ -2,7 +2,8 @@
// them, the pending approval or question with its options, the thread's subagents under a header // 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 // 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, // 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 // 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. // 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 let threadId: String
@State private var detail: ThreadDetail? @State private var detail: ThreadDetail?
/// Why the last load failed. The next good load clears it.
@State private var error: String? @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 sending = false
@State private var visible = false @State private var visible = false
@State private var answers: [String: RespondRequest.Answer] = [:] @State private var answers: [String: RespondRequest.Answer] = [:]
@@ -27,6 +34,8 @@ struct ThreadView: View {
@State private var modeError: String? @State private var modeError: String?
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps. /// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
@State private var pendingAskFirst: Bool? @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. /// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
@State private var history = MessageHistory() @State private var history = MessageHistory()
@State private var loadingEarlier = false @State private var loadingEarlier = false
@@ -169,6 +178,10 @@ struct ThreadView: View {
.disabled(sending) .disabled(sending)
} }
if let actionError {
actionFailure(actionError)
}
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
TextFieldLink(prompt: Text("Reply")) { TextFieldLink(prompt: Text("Reply")) {
Label("Reply", systemImage: "mic.fill") 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. /// A small grey title over a list, such as Changed.
private func sectionLabel(_ title: String) -> some View { private func sectionLabel(_ title: String) -> some View {
Text(title) Text(title)
@@ -387,9 +420,15 @@ struct ThreadView: View {
// MARK: - Actions // MARK: - Actions
private func load() async { private func load() async {
let askGeneration = askFirstGeneration
do { do {
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId) 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) } if let detail { history.keep(detail.messages) }
detail = fresh detail = fresh
error = nil 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) { private func loadEarlier(_ cursor: String) {
loadingEarlier = true loadingEarlier = true
actionError = nil
if let detail { history.startKeeping(detail.messages) }
Task { Task {
do { do {
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor)) history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
} catch { } catch {
self.error = model.message(for: error) actionError = model.message(for: error)
} }
loadingEarlier = false 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 sending = true
error = nil actionError = nil
unsent = nil
model.beginChange()
Task { Task {
do { do {
try await action() try await action()
} catch let failure as GatewayError where failure.isStale { } catch let failure as GatewayError where failure.isStale {
self.error = "Already answered." actionError = "Already answered."
} catch { } catch {
self.error = model.message(for: error) if let message = model.message(for: error) {
actionError = message
unsent = text
}
} }
await load() await load()
await model.refreshFeed() await model.refreshFeed()
@@ -432,7 +481,7 @@ struct ThreadView: View {
private func send(_ text: String) { private func send(_ text: String) {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } 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. /// 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 { Task {
do { do {
try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on) try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on)
askFirstGeneration += 1
} catch { } catch {
detail?.askFirst = !on detail?.askFirst = !on
modeError = model.message(for: error) 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. /// Settles the thread and goes back to the feed. On failure the screen stays and shows why.
private func settle() { private func settle() {
sending = true sending = true
error = nil actionError = nil
unsent = nil
model.beginChange()
Task { Task {
do { do {
try await model.api.settle(hostId: hostId, threadId: threadId) try await model.api.settle(hostId: hostId, threadId: threadId)
dismiss() dismiss()
await model.refreshFeed() await model.refreshFeed()
} catch { } catch {
self.error = model.message(for: error) actionError = model.message(for: error)
} }
sending = false 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 /// 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 /// route returns. Once Load earlier is asked for, a refresh's outgoing window is kept too, so no gap
/// new messages push the window forward. A message shows once, by id, the latest window's copy first. /// 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 { struct MessageHistory {
private(set) var older: [Message] = [] private(set) var older: [Message] = []
/// The cursor for the page before `older`, once a page has loaded. /// The cursor for the page before `older`, once a page has loaded.
private var nextCursor: String? private var nextCursor: String?
private var loaded = false 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. /// Where Load earlier fetches from next, or nil when the thread starts in view.
func cursor(_ detail: ThreadDetail) -> String? { func cursor(_ detail: ThreadDetail) -> String? {
@@ -536,20 +591,33 @@ struct MessageHistory {
} }
mutating func prepend(_ page: HistoryResponse) { mutating func prepend(_ page: HistoryResponse) {
older = Self.unique(page.messages + older) older = Self.merge(page.messages, into: older, atFront: true)
nextCursor = page.earlierCursor nextCursor = page.earlierCursor
loaded = true 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]) { mutating func keep(_ window: [Message]) {
guard loaded else { return } guard keeping else { return }
older = Self.unique(older + window) older = Self.merge(window, into: older, atFront: false)
} }
private static func unique(_ messages: [Message]) -> [Message] { /// `base` with `incoming` merged in. A message already in `base` takes the incoming copy in
var seen = Set<String>() /// place; the rest go in front or behind, in their own order.
return messages.filter { seen.insert($0.id).inserted } 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
} }
} }
+5
View File
@@ -31,6 +31,9 @@ struct RootView: View {
NavigationStack(path: $model.path) { NavigationStack(path: $model.path) {
FeedView() FeedView()
.navigationDestination(for: Route.self) { route in .navigationDestination(for: Route.self) { route in
// A new identity per route, so swapping one thread for another at the
// same place in the stack starts the screen's state and polling afresh.
Group {
switch route { switch route {
case let .thread(hostId, threadId): case let .thread(hostId, threadId):
ThreadView(hostId: hostId, threadId: threadId) ThreadView(hostId: hostId, threadId: threadId)
@@ -56,6 +59,8 @@ struct RootView: View {
SearchView(query: query) SearchView(query: query)
} }
} }
.id(route)
}
} }
.sheet(item: $model.snoozing) { item in .sheet(item: $model.snoozing) { item in
NavigationStack { NavigationStack {
+229
View File
@@ -0,0 +1,229 @@
// How AppModel keeps the feed honest around changes: a refresh asked for during another runs again,
// an answer sent before a change or a reset is dropped, action errors outlive the reloads after
// them, and the disk cache skips unchanged writes. These write the complication's shared state, so
// they run in the serialized SharedStateTests suite and put that state back after.
import Foundation
import Testing
@testable import WristCode
/// A gateway whose feed answers wait for the test to hand them over, unless `holdFeed` is off, and
/// whose other calls fail with `failure` when it is set.
private actor FakeGateway: GatewayAPI {
struct Unused: Error {}
var holdFeed = true
var feedAnswer = Feed(needsYou: [], working: [], recent: [], hosts: [])
var failure: GatewayError?
var snoozedItems: [FeedItem] = []
private(set) var feedCalls = 0
private var waiting: [CheckedContinuation<Feed, any Error>] = []
var waitingFeeds: Int { waiting.count }
func set(holdFeed: Bool? = nil, feed: Feed? = nil, failure: GatewayError?? = nil, snoozed: [FeedItem]? = nil) {
if let holdFeed { self.holdFeed = holdFeed }
if let feed { feedAnswer = feed }
if let failure { self.failure = failure }
if let snoozed { snoozedItems = snoozed }
}
/// Answers the oldest feed request still waiting.
func answerFeed(_ feed: Feed) {
waiting.removeFirst().resume(returning: feed)
}
private func check() throws {
if let failure { throw failure }
}
func feed() async throws -> Feed {
feedCalls += 1
guard holdFeed else { return feedAnswer }
return try await withCheckedThrowingContinuation { waiting.append($0) }
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: snoozedItems)
}
func settle(hostId: String, threadId: String) async throws { try check() }
func unsnooze(hostId: String, threadId: String) async throws { try check() }
func settleRecent() async throws -> SettleRecentResponse { try check(); throw Unused() }
func recent() async throws -> RecentResponse { try check(); throw Unused() }
func projects() async throws -> ProjectsResponse { try check(); throw Unused() }
func summary() async throws -> Summary { throw Unused() }
func thread(hostId: String, threadId: String) async throws -> ThreadDetail { throw Unused() }
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse { throw Unused() }
func sendMessage(hostId: String, threadId: String, text: String) async throws { throw Unused() }
func respond(hostId: String, threadId: String, request: RespondRequest) async throws { throw Unused() }
func stop(hostId: String, threadId: String) async throws { throw Unused() }
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws { throw Unused() }
func snooze(hostId: String, threadId: String, until: Date) async throws { throw Unused() }
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse { throw Unused() }
func search(_ query: String) async throws -> SearchResponse { throw Unused() }
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { throw Unused() }
func registerPush(_ registration: PushRegistration) async throws { throw Unused() }
func status() async throws -> StatusResponse { throw Unused() }
func reconnect(hostId: String) async throws { throw Unused() }
func testPush() async throws -> PushResult { throw Unused() }
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs { throw Unused() }
func unpair() async throws { throw Unused() }
}
/// Waits, a turn at a time, until `condition` holds.
@MainActor private func until(_ condition: () async -> Bool) async throws {
for _ in 0 ..< 1000 {
if await condition() { return }
await Task.yield()
}
Issue.record("Timed out")
throw CancellationError()
}
/// Lets tasks already queued on the main actor run up to their first wait.
@MainActor private func settleMainActor() async {
for _ in 0 ..< 5 { await Task.yield() }
}
/// Puts the complication's shared state back after `body`.
@MainActor private func keepingSharedState(_ body: () async throws -> Void) async rethrows {
let demo = SharedState.demo
let summary = SharedState.lastSummary
defer {
SharedState.demo = demo
SharedState.lastSummary = summary
}
try await body()
}
private let fixtures = DemoFixtures.previewFeed(now: Date(timeIntervalSince1970: 1_800_000_000))
/// A feed with one recent thread, and the same feed after it settled.
private let withThread = Feed(needsYou: [], working: [], recent: [fixtures.recent[0]], hosts: fixtures.hosts)
private let settled = Feed(needsYou: [], working: [], recent: [], hosts: fixtures.hosts)
extension SharedStateTests {
@MainActor @Test func refreshAskedForDuringAnotherRunsAgain() async throws {
try await keepingSharedState {
let gateway = FakeGateway()
let model = AppModel(mode: .demo, gateway: gateway)
let poll = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
// A change lands while the poll is out. Its refresh waits instead of being dropped.
let afterChange = Task { await model.refreshFeed() }
await settleMainActor()
await gateway.answerFeed(withThread)
try await until { await gateway.waitingFeeds == 1 }
#expect(model.feed == nil)
await gateway.answerFeed(settled)
await poll.value
await afterChange.value
#expect(model.feed == settled)
#expect(await gateway.feedCalls == 2)
}
}
@MainActor @Test func feedAnswersFromBeforeAChangeOrResetAreDropped() async throws {
try await keepingSharedState {
let gateway = FakeGateway()
let model = AppModel(mode: .demo, gateway: gateway)
let poll = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
model.beginChange()
await gateway.answerFeed(withThread)
try await until { await gateway.waitingFeeds == 1 }
await gateway.answerFeed(settled)
await poll.value
#expect(model.feed == settled)
// Signing out while a refresh is out: its answer never lands.
let late = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
model.signOut()
await gateway.answerFeed(withThread)
await late.value
#expect(model.feed == nil)
#expect(SharedState.lastSummary == nil)
#expect(await gateway.feedCalls == 3)
}
}
@MainActor @Test func failedUnsnoozeShowsItsErrorAfterTheReload() async throws {
try await keepingSharedState {
let item = fixtures.recent[0]
let gateway = FakeGateway()
await gateway.set(holdFeed: false, failure: .transport("Gateway down"), snoozed: [item])
let model = AppModel(mode: .demo, snoozed: [item], gateway: gateway)
await model.unsnooze(item)
#expect(model.snoozedError == "Gateway down")
#expect(model.snoozed == [item])
}
}
@MainActor @Test func settleErrorsLeaveWithTheirThreadsAndOnSignOut() async throws {
try await keepingSharedState {
let item = fixtures.recent[0]
let gateway = FakeGateway()
await gateway.set(holdFeed: false, feed: withThread, failure: .transport("Gateway down"))
let model = AppModel(mode: .demo, gateway: gateway)
await model.settle([item])
#expect(model.settleErrors[item.id] == "Gateway down")
// The thread left the feed elsewhere, so its error goes too.
await gateway.set(feed: settled)
await model.refreshFeed()
#expect(model.settleErrors.isEmpty)
await gateway.set(feed: withThread)
await model.settle([item])
await model.settleAllRecent()
await model.loadAllRecent()
await model.loadProjects()
await model.unsnooze(item)
#expect(!model.settleErrors.isEmpty)
#expect(model.settleAllError != nil)
#expect(model.allRecentError != nil)
#expect(model.projectsError != nil)
#expect(model.snoozedError != nil)
model.signOut()
#expect(model.settleErrors.isEmpty)
#expect(model.settling.isEmpty)
#expect(!model.settlingAll)
#expect(model.settleAllError == nil)
#expect(model.allRecentError == nil)
#expect(model.projectsError == nil)
#expect(model.snoozedError == nil)
}
}
@MainActor @Test func unchangedFeedIsNotWrittenAgain() async throws {
try await keepingSharedState {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let cache = FeedCache(directory: directory)
let file = directory.appending(path: "feed.json")
let gateway = FakeGateway()
await gateway.set(holdFeed: false, feed: withThread)
let credentials = Credentials(gatewayURL: URL(string: "https://gateway.test")!, token: "t")
let model = AppModel(mode: .paired(credentials), cache: cache, gateway: gateway)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(cache.feed()?.recent.map(\.id) == withThread.recent.map(\.id))
try FileManager.default.removeItem(at: file)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(!FileManager.default.fileExists(atPath: file.path()))
await gateway.set(feed: settled)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(cache.feed()?.recent.map(\.id) == [])
}
}
}
+47
View File
@@ -474,8 +474,50 @@ private let feedItemJSON = """
#expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"]) #expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"])
} }
@Test func olderMessagesTakeTheNewerCopy() {
func m(_ id: String, _ text: String) -> Message { Message(id: id, role: .assistant, text: text, createdAt: .now) }
var history = MessageHistory()
// Load earlier goes out with 3 caught mid-stream. A refresh lands before the page does.
history.startKeeping([m("3", "Runn"), m("4", "Done")])
history.keep([m("3", "Running the suite"), m("4", "Done"), m("5", "Next")])
#expect(history.messages(before: [m("6", "Later")]).map(\.text) == ["Running the suite", "Done", "Next", "Later"])
// The page repeats 3 and its copy, fetched last, wins in place.
history.prepend(HistoryResponse(messages: [m("2", "Two"), m("3", "Running the suite, all 48")], earlierCursor: nil))
#expect(history.messages(before: [m("6", "Later")]).map(\.text)
== ["Two", "Running the suite, all 48", "Done", "Next", "Later"])
}
@Test func queryValuesAreEncodedStrictly() {
let url = GatewayClient.url(
URL(string: "https://gateway.test")!, ["v1", "threads", "h/1", "t"],
query: [URLQueryItem(name: "cursor", value: "habc+/d=&e f")])
#expect(url.absoluteString == "https://gateway.test/v1/threads/h%2F1/t?cursor=habc%2B%2Fd%3D%26e%20f")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "habc+/d=&e f")
}
@Test func threadCacheKeepsTheNewest() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
var cache = FeedCache(directory: directory)
cache.threadLimit = 3
var detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false }"#)
for id in ["a", "b", "c", "d", "e"] {
detail.item.threadId = id
cache.save(detail)
try await Task.sleep(for: .milliseconds(20))
}
cache.save(Feed(needsYou: [], working: [], recent: [], hosts: []))
let kept = ["a", "b", "c", "d", "e"].filter { cache.thread(hostId: detail.item.hostId, threadId: $0) != nil }
#expect(kept == ["c", "d", "e"])
#expect(cache.feed() != nil)
}
@Test func markdownReadsCleanly() { @Test func markdownReadsCleanly() {
#expect(Markdown.block("## Done") == "**Done**") #expect(Markdown.block("## Done") == "**Done**")
#expect(Markdown.block("## Porting to C#") == "**Porting to C#**")
#expect(Markdown.block("## Done ##") == "**Done**")
#expect(Markdown.block("# Notes # ") == "**Notes**")
#expect(Markdown.block(" - item") == " • item") #expect(Markdown.block(" - item") == " • item")
#expect(Markdown.block("> quoted") == "quoted") #expect(Markdown.block("> quoted") == "quoted")
#expect(Markdown.block("**bold** stays") == "**bold** stays") #expect(Markdown.block("**bold** stays") == "**bold** stays")
@@ -599,6 +641,9 @@ private let feedItemJSON = """
#expect(Route(url: route.url) == route) #expect(Route(url: route.url) == route)
} }
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil) #expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
// The gateway searches two characters or more.
#expect(Route(url: URL(string: "wristcode://search?q=a")!) == nil)
#expect(Route(url: URL(string: "wristcode://search?q=ab")!) == .search(query: "ab"))
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil) #expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
} }
@@ -1008,6 +1053,8 @@ private let feedItemJSON = """
#expect(model.feedIsCached) #expect(model.feedIsCached)
model.signOut() model.signOut()
// The clear runs off the main actor, after any write still queued.
await model.cacheWrite?.value
#expect(cache.feed() == nil) #expect(cache.feed() == nil)
#expect(model.feed == nil) #expect(model.feed == nil)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 90 KiB