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
+591 -94

No files matched your search

+14 -4
View File
@@ -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 {
+4 -1
View File
@@ -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
View File
@@ -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.
+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
// 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
+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
/// 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")
+1
View File
@@ -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
+2 -1
View File
@@ -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+(.*)/) {
+88 -20
View File
@@ -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
}
}
+28 -23
View File
@@ -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