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
+18
-6
@@ -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
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
thread's detail when the thread loads. 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.
|
||||
A feed refresh asked for while another is out, as after Settle, Snooze or Allow, waits for it and
|
||||
then runs once more. A feed answer to a request sent before a change, or before signing out, is
|
||||
dropped, so a poll cannot put back a row the change removed. On the thread screen, a failed reply,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
Against a gateway older than these fields, the cards show the last message, and Load earlier and
|
||||
|
||||
@@ -197,6 +197,19 @@ struct GatewayClient: GatewayAPI {
|
||||
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>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = []
|
||||
) async throws -> Response {
|
||||
@@ -221,10 +234,7 @@ struct GatewayClient: GatewayAPI {
|
||||
private func perform<Body: Encodable>(
|
||||
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||
) async throws -> Data {
|
||||
// appending(component:) percent-encodes each segment, slashes included.
|
||||
var url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
if !query.isEmpty { url.append(queryItems: query) }
|
||||
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||
var request = URLRequest(url: Self.url(baseURL, path, query: query), timeoutInterval: 20)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let token {
|
||||
|
||||
@@ -21,6 +21,8 @@ enum Route: Hashable, Sendable {
|
||||
case search(query: String)
|
||||
|
||||
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://status`,
|
||||
@@ -92,7 +94,8 @@ enum Route: Hashable, Sendable {
|
||||
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
|
||||
self = .projectThreads(hostId: parts[0], projectId: parts[1])
|
||||
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)
|
||||
default:
|
||||
return nil
|
||||
|
||||
+107
-25
@@ -51,18 +51,31 @@ final class AppModel {
|
||||
var snoozing: FeedItem?
|
||||
/// Where the feed and thread details are saved. Only used while paired, never in demo mode.
|
||||
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 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.
|
||||
private var sharedSummary: Summary?
|
||||
|
||||
/// With a `cache` and no `feed`, a paired model starts on the saved feed.
|
||||
init(
|
||||
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.cache = cache
|
||||
self.gateway = gateway
|
||||
self.allRecent = allRecent
|
||||
self.snoozed = snoozed
|
||||
self.projects = projects
|
||||
@@ -115,7 +128,8 @@ final class AppModel {
|
||||
private var pairedCache: FeedCache? { isPaired ? cache : nil }
|
||||
|
||||
var api: any GatewayAPI {
|
||||
switch mode {
|
||||
if let gateway { return gateway }
|
||||
return switch mode {
|
||||
case let .paired(credentials):
|
||||
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
||||
case .demo:
|
||||
@@ -146,20 +160,32 @@ final class AppModel {
|
||||
/// gateway URL is kept.
|
||||
func signOut() {
|
||||
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
|
||||
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() {
|
||||
feedGeneration += 1
|
||||
feed = nil
|
||||
feedError = nil
|
||||
feedIsCached = false
|
||||
settling = []
|
||||
settleErrors = [:]
|
||||
settlingAll = false
|
||||
settleAllError = nil
|
||||
allRecent = nil
|
||||
allRecentError = nil
|
||||
snoozed = nil
|
||||
snoozedError = nil
|
||||
snoozing = nil
|
||||
projects = nil
|
||||
projectsError = nil
|
||||
savedFeed = nil
|
||||
savedThread = nil
|
||||
path = []
|
||||
}
|
||||
|
||||
@@ -189,22 +215,55 @@ final class AppModel {
|
||||
|
||||
// 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 {
|
||||
guard mode != .unpaired, !refreshing else { return }
|
||||
refreshing = true
|
||||
defer { refreshing = false }
|
||||
do {
|
||||
let feed = try await api.feed()
|
||||
self.feed = feed
|
||||
feedError = nil
|
||||
feedIsCached = false
|
||||
share(Summary(feed))
|
||||
pairedCache?.save(feed)
|
||||
} catch {
|
||||
// A cancelled refresh changes nothing; the next one answers.
|
||||
guard let message = message(for: error) else { return }
|
||||
feedError = message
|
||||
feedIsCached = false
|
||||
guard mode != .unpaired else { return }
|
||||
if let refreshTask {
|
||||
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 {
|
||||
result = .success(try await api.feed())
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
guard generation == feedGeneration else { continue }
|
||||
switch result {
|
||||
case let .success(feed):
|
||||
self.feed = feed
|
||||
feedError = nil
|
||||
feedIsCached = false
|
||||
let listed = Set((feed.needsYou + feed.working + feed.recent + (allRecent ?? [])).map(\.id))
|
||||
settleErrors = settleErrors.filter { listed.contains($0.key) }
|
||||
share(Summary(feed))
|
||||
saveFeed(feed)
|
||||
case let .failure(error):
|
||||
// A cancelled refresh changes nothing; the next one answers.
|
||||
guard let message = message(for: error) else { return }
|
||||
feedError = message
|
||||
feedIsCached = false
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,15 +272,34 @@ final class AppModel {
|
||||
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) {
|
||||
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
|
||||
/// if its call fails. Refreshes once every call has answered.
|
||||
func settle(_ items: [FeedItem]) async {
|
||||
let ids = Set(items.map(\.id))
|
||||
beginChange()
|
||||
settling.formUnion(ids)
|
||||
for id in ids { settleErrors[id] = nil }
|
||||
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
|
||||
/// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh.
|
||||
func settleAllRecent() async {
|
||||
beginChange()
|
||||
settlingAll = true
|
||||
settleAllError = nil
|
||||
do {
|
||||
@@ -291,6 +370,7 @@ final class AppModel {
|
||||
/// 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.
|
||||
func snooze(_ item: FeedItem, until: Date) async throws {
|
||||
beginChange()
|
||||
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
|
||||
snoozing = nil
|
||||
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
|
||||
/// 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 {
|
||||
beginChange()
|
||||
snoozed?.removeAll { $0.id == item.id }
|
||||
var failure: String?
|
||||
do {
|
||||
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
|
||||
snoozedError = nil
|
||||
} catch {
|
||||
snoozedError = message(for: error)
|
||||
failure = message(for: error)
|
||||
}
|
||||
await refreshFeed()
|
||||
await loadSnoozed()
|
||||
if let failure { snoozedError = failure }
|
||||
}
|
||||
|
||||
/// Drops settled threads from the feed and the full list until the next refresh confirms it.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// The last good feed, and the last detail of each thread opened, saved as JSON in the app's caches
|
||||
// folder. Launch shows them at once while the first refresh runs. Signing out clears them, and demo
|
||||
// mode never reads or writes them.
|
||||
// The last good feed, and the last detail of the 30 threads saved most recently, saved as JSON in
|
||||
// the app's caches folder. Launch shows them at once while the first refresh runs. Signing out clears
|
||||
// them, and demo mode never reads or writes them.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedCache: Sendable {
|
||||
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))
|
||||
|
||||
@@ -23,6 +25,20 @@ struct FeedCache: Sendable {
|
||||
|
||||
func save(_ detail: ThreadDetail) {
|
||||
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.
|
||||
|
||||
@@ -177,17 +177,15 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
await model.refreshFeed()
|
||||
return
|
||||
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") {
|
||||
try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request)
|
||||
}
|
||||
case let .reply(hostId, threadId, text):
|
||||
guard model.mode != .unpaired else { return }
|
||||
await attempt("Couldn't send your reply") {
|
||||
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
|
||||
}
|
||||
case let .settle(hostId, threadId):
|
||||
guard model.mode != .unpaired else { return }
|
||||
await attempt("Couldn't settle") {
|
||||
try await model.api.settle(hostId: hostId, threadId: threadId)
|
||||
}
|
||||
@@ -197,21 +195,31 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
guard AppModel.shared.isPaired else {
|
||||
await notify(title, "This watch isn't paired. Open WristCode to pair it.")
|
||||
return
|
||||
}
|
||||
AppModel.shared.beginChange()
|
||||
do {
|
||||
try await action()
|
||||
} catch let error as GatewayError where error.isStale {
|
||||
return
|
||||
} catch {
|
||||
guard let message = AppModel.shared.message(for: error) else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = message
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
try? await UNUserNotificationCenter.current().add(request)
|
||||
await notify(title, message)
|
||||
}
|
||||
}
|
||||
|
||||
private func notify(_ title: String, _ body: String) async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,30 +31,35 @@ struct RootView: View {
|
||||
NavigationStack(path: $model.path) {
|
||||
FeedView()
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
switch route {
|
||||
case let .thread(hostId, threadId):
|
||||
ThreadView(hostId: hostId, threadId: threadId)
|
||||
case .newThread:
|
||||
NewThreadView()
|
||||
case .recent:
|
||||
RecentView()
|
||||
case .snoozed:
|
||||
SnoozedView()
|
||||
case .status:
|
||||
StatusView()
|
||||
case let .host(id):
|
||||
HostStatusView(hostId: id)
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
case .allThreads:
|
||||
HostsView()
|
||||
case let .hostProjects(hostId):
|
||||
ProjectsView(hostId: hostId)
|
||||
case let .projectThreads(hostId, projectId):
|
||||
ProjectThreadsView(hostId: hostId, projectId: projectId)
|
||||
case let .search(query):
|
||||
SearchView(query: query)
|
||||
// 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 {
|
||||
case let .thread(hostId, threadId):
|
||||
ThreadView(hostId: hostId, threadId: threadId)
|
||||
case .newThread:
|
||||
NewThreadView()
|
||||
case .recent:
|
||||
RecentView()
|
||||
case .snoozed:
|
||||
SnoozedView()
|
||||
case .status:
|
||||
StatusView()
|
||||
case let .host(id):
|
||||
HostStatusView(hostId: id)
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
case .allThreads:
|
||||
HostsView()
|
||||
case let .hostProjects(hostId):
|
||||
ProjectsView(hostId: hostId)
|
||||
case let .projectThreads(hostId, projectId):
|
||||
ProjectThreadsView(hostId: hostId, projectId: projectId)
|
||||
case let .search(query):
|
||||
SearchView(query: query)
|
||||
}
|
||||
}
|
||||
.id(route)
|
||||
}
|
||||
}
|
||||
.sheet(item: $model.snoozing) { item in
|
||||
|
||||
@@ -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) == [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,8 +474,50 @@ private let feedItemJSON = """
|
||||
#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() {
|
||||
#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("> quoted") == "quoted")
|
||||
#expect(Markdown.block("**bold** stays") == "**bold** stays")
|
||||
@@ -599,6 +641,9 @@ private let feedItemJSON = """
|
||||
#expect(Route(url: route.url) == route)
|
||||
}
|
||||
#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)
|
||||
}
|
||||
|
||||
@@ -1008,6 +1053,8 @@ private let feedItemJSON = """
|
||||
#expect(model.feedIsCached)
|
||||
|
||||
model.signOut()
|
||||
// The clear runs off the main actor, after any write still queued.
|
||||
await model.cacheWrite?.value
|
||||
#expect(cache.feed() == nil)
|
||||
#expect(model.feed == nil)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 90 KiB |
Reference in new issue
Block a user