PR #225 by @ArjenSchwarz · merging T-1800/bugfix-open-task-details-do-not-refresh-externally-added-comments → main · view on GitHub
97c46af recorded the same three failure points on the same simulator.dataMaintenance.confirmButton signature on iPhone 17; in the controlled iPhone 17 Pro reruns, both base and candidate fail earlier because dashboard.settingsButton is absent. The repeated base failure excludes a PR-diff connection.Ready to push/merge
Ready to push/merge. The candidate is exactly 97c46af; PR #225 is open, clean, non-draft, has a successful claude-review check, and has zero unresolved review threads. The three selected UI failures reproduce twice from the clean exact base ddfcc7a under the same Xcode 26.6 / iPhone 17 Pro iOS 26.5 state, and the PR diff contains no dashboard filter, milestone editor, or data-maintenance code. They are not a diff-connected regression.
Shown verbatim — the markdown the author wrote, unmodified.
## Summary - Replace TaskDetailView's one-time comment snapshot with a task-scoped reactive SwiftData query. - Use the query's current comments for both the detail list and Share export. - Add a regression covering task isolation, external-style insertion/deletion, and current share text. ## Root cause An open detail view only fetched comments on appearance or after its own local mutations, so MCP changes in the shared ModelContext left both display and export stale. ## Validation - Passed: focused ShareText regression, `make test-quick`, and `make lint`. - iOS compiled and ran the T-1800 regression. Full iOS and UI-only suites consistently have three unrelated existing UI failures: `TransitUITests.testClearAll()`, `TransitUITests.testEditViewPreservesTaskMilestone()`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath()`. See `specs/bugfixes/open-task-details-do-not-refresh-externally-added-comments/report.md` for the investigation and verification record.
d0caecc T-1800: Refresh open task detail comments 97c46af T-1800: Unify comment query descriptor Task details now watch the task’s comments instead of taking one snapshot when the screen opens. If an agent adds or removes a comment while the detail screen stays open, both the visible comments and the Share action use the current list.
TaskDetailView owns a task-scoped @Query, initialized with CommentService.descriptor(for:). CommentsSection receives that derived list as immutable display data and still delegates writes to CommentService. This removes duplicate reload paths and keeps UI, export, and service ordering aligned.
The shared descriptor queries Comment from the child side using $0.task?.id == taskID, compatible with the optional CloudKit relationship. Ordering by creation date and UUID is deterministic. The regression verifies task scoping, insertion, deletion, and Share output against the same descriptor.
Transit/Transit/Services/CommentService.swift
Why it matters. Prevents view and service comment queries from drifting in filtering or ordering.
What to look at. CommentService.descriptor(for:)
Transit/Transit/Views/TaskDetail/TaskDetailView.swift
Why it matters. Fixes stale open detail views and stale Share output after external comments mutate the shared context.
What to look at. TaskDetailView.init and @Query comments state
Transit/TransitTests/ShareTextTests.swift
Why it matters. Guards task isolation, insertion, deletion, and current Share text together.
What to look at. taskScopedCommentsReflectExternalInsertionDeletionAndKeepShareCurrent
The PR centralizes the child-side predicate and stable sort in CommentService, avoiding two equivalent-looking definitions that could later diverge.
On the same booted iPhone 17 Pro / iOS 26.5 simulator, exact base ddfcc7a failed all three selected tests in both clean runs. Candidate 97c46af failed at the same points. The candidate diff only changes comment and Share paths, so no changed source intersects the failures.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5c6a897..7a46f88 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +- T-1800: Open task details now observe a task-scoped SwiftData comment query instead of a one-time snapshot, so MCP and local insertions/deletions update both the visible comment list and Share export without reopening the view. The query filters through the CloudKit-compatible optional child relationship and uses creation date plus UUID for stable ordering.+ - T-1770: JSON mutation intents now preserve their established typed validation and domain-error payloads while mapping untyped SwiftData fetch/save failures to `INTERNAL_ERROR`. Shared task and milestone identifier resolution uses deterministic injected fetchers; milestone mutations also expose an injected saver. Regression coverage spans create, status update, milestone update, and deletion paths, asserting both the internal-error envelope and no persisted mutation on failure. - T-1803: `UpdateStatusIntent` now includes a missing requested `displayId` in its `TASK_NOT_FOUND` hint (`No task with displayId N`), while missing UUIDs retain the existing generic lookup hint and malformed identifiers, duplicate IDs, status validation, and atomic mutation behavior remain unchanged.
diff --git a/Transit/Transit/Services/CommentService.swift b/Transit/Transit/Services/CommentService.swiftindex 533491e..530a798 100644--- a/Transit/Transit/Services/CommentService.swift+++ b/Transit/Transit/Services/CommentService.swift@@ -18,6 +18,19 @@ final class CommentService: CommentFetching { private let modelContext: ModelContext + /// Shared task-scoped comment query for views and service consumers.+ /// Queries from the child side because the CloudKit-compatible task+ /// relationship is optional; UUID makes equal timestamps deterministic.+ static func descriptor(for taskID: UUID) -> FetchDescriptor<Comment> {+ FetchDescriptor<Comment>(+ predicate: #Predicate { $0.task?.id == taskID },+ sortBy: [+ SortDescriptor(\Comment.creationDate, order: .forward),+ SortDescriptor(\Comment.id, order: .forward)+ ]+ )+ }+ init(modelContext: ModelContext) { self.modelContext = modelContext }@@ -86,14 +99,7 @@ final class CommentService: CommentFetching { /// Fetches comments for a task, querying from the Comment side. /// Sorted by creationDate ascending, UUID as tiebreaker. func fetchComments(for taskID: UUID) throws -> [Comment] {- let descriptor = FetchDescriptor<Comment>(- predicate: #Predicate { $0.task?.id == taskID },- sortBy: [- SortDescriptor(\.creationDate, order: .forward),- SortDescriptor(\.id, order: .forward)- ]- )- return try modelContext.fetch(descriptor)+ try modelContext.fetch(Self.descriptor(for: taskID)) } /// Returns comment count for a task using fetchCount for efficiency.
diff --git a/Transit/Transit/Views/TaskDetail/CommentsSection.swift b/Transit/Transit/Views/TaskDetail/CommentsSection.swiftindex 4545f76..1fabb81 100644--- a/Transit/Transit/Views/TaskDetail/CommentsSection.swift+++ b/Transit/Transit/Views/TaskDetail/CommentsSection.swift@@ -2,7 +2,7 @@ import SwiftUI struct CommentsSection: View { let task: TransitTask- @Binding var comments: [Comment]+ let comments: [Comment] @Environment(CommentService.self) private var commentService @AppStorage("userDisplayName") private var userDisplayName = ""@@ -129,10 +129,6 @@ struct CommentsSection: View { // MARK: - Actions - private func loadComments() {- comments = (try? commentService.fetchComments(for: task.id)) ?? []- }- private func addComment() { guard canAddComment else { return } do {@@ -146,7 +142,6 @@ struct CommentsSection: View { } catch { errorMessage = "Failed to add comment." }- loadComments() } private func deleteComment(_ comment: Comment) {@@ -155,11 +150,10 @@ struct CommentsSection: View { } catch { errorMessage = "Failed to delete comment." }- loadComments() } /// Deletes comments at the given offsets, mapping to objects before any- /// mutation to avoid indexing into a stale array. Reloads once at the end.+ /// mutation to avoid indexing into a stale array. private func deleteComments(at offsets: IndexSet) { let toDelete = offsets.map { comments[$0] } do {@@ -167,6 +161,5 @@ struct CommentsSection: View { } catch { errorMessage = "Failed to delete comment." }- loadComments() } }
diff --git a/Transit/Transit/Views/TaskDetail/TaskDetailView.swift b/Transit/Transit/Views/TaskDetail/TaskDetailView.swiftindex b118601..a8b49be 100644--- a/Transit/Transit/Views/TaskDetail/TaskDetailView.swift+++ b/Transit/Transit/Views/TaskDetail/TaskDetailView.swift@@ -1,3 +1,4 @@+import SwiftData import SwiftUI struct TaskDetailView: View {@@ -6,12 +7,22 @@ struct TaskDetailView: View { var onEdit: (() -> Void)? @Environment(TaskService.self) private var taskService @Environment(\.dismiss) private var dismiss- @Environment(CommentService.self) private var commentService @Environment(\.resolvedTheme) private var resolvedTheme+ @Query private var comments: [Comment] @State private var showEdit = false- @State private var comments: [Comment] = [] @State private var errorMessage: String? + init(+ task: TransitTask,+ dismissAll: @escaping () -> Void,+ onEdit: (() -> Void)? = nil+ ) {+ self.task = task+ self.dismissAll = dismissAll+ self.onEdit = onEdit+ _comments = Query(CommentService.descriptor(for: task.id))+ }+ var body: some View { NavigationStack { #if os(macOS)@@ -38,7 +49,7 @@ struct TaskDetailView: View { iOSDetailSection iOSDescriptionSection MetadataSection(metadata: .constant(task.metadata), isEditing: false)- CommentsSection(task: task, comments: $comments)+ CommentsSection(task: task, comments: comments) iOSActionSection } .navigationTitle(task.displayID.formatted)@@ -47,7 +58,6 @@ struct TaskDetailView: View { .sheet(isPresented: $showEdit) { TaskEditView(task: task, dismissAll: dismissAll) }- .onAppear { loadComments() } } private var iOSDetailSection: some View {@@ -160,7 +170,7 @@ struct TaskDetailView: View { MetadataSection(metadata: .constant(task.metadata), isEditing: false) } - CommentsSection(task: task, comments: $comments)+ CommentsSection(task: task, comments: comments) LiquidGlassSection(title: "Actions") { actionButtons@@ -173,7 +183,6 @@ struct TaskDetailView: View { .background { BoardBackground(theme: resolvedTheme) } .navigationTitle(task.displayID.formatted) .toolbar { detailToolbar }- .onAppear { loadComments() } } #endif @@ -215,12 +224,6 @@ struct TaskDetailView: View { #endif } - private func loadComments() {- // Read-only fetch: showing an empty list on failure is acceptable,- // unlike write operations where silent errors would lose data.- comments = (try? commentService.fetchComments(for: task.id)) ?? []- }- @ViewBuilder private var actionButtons: some View { if task.status == .abandoned {
diff --git a/Transit/TransitTests/ShareTextTests.swift b/Transit/TransitTests/ShareTextTests.swiftindex 0550cbb..df4c2f2 100644--- a/Transit/TransitTests/ShareTextTests.swift+++ b/Transit/TransitTests/ShareTextTests.swift@@ -212,6 +212,58 @@ struct ShareTextTests { #expect(textAfter.contains("Just added")) } + // MARK: - Reactive task detail comments (T-1800 regression)++ @Test func taskScopedCommentsReflectExternalInsertionDeletionAndKeepShareCurrent() throws {+ let testContainer = try makeTestContainer()+ let context = testContainer.context+ let service = CommentService(modelContext: context)+ let project = makeProject(in: context, name: "Transit")+ let task = TransitTask(+ name: "Observed task",+ type: .bug,+ project: project,+ displayID: .permanent(1800)+ )+ context.insert(task)+ let unrelatedTask = TransitTask(+ name: "Unrelated task",+ type: .feature,+ project: project,+ displayID: .permanent(1801)+ )+ context.insert(unrelatedTask)+ try context.save()++ let descriptor = CommentService.descriptor(for: task.id)+ #expect(try context.fetch(descriptor).isEmpty)++ // Simulates an MCP agent mutating the shared app ModelContext while+ // TaskDetailView remains open.+ let externalComment = try service.addComment(+ to: task,+ content: "Added by an agent",+ authorName: "Agent",+ isAgent: true+ )+ _ = try service.addComment(+ to: unrelatedTask,+ content: "Do not include me",+ authorName: "Agent",+ isAgent: true+ )++ let insertedComments = try context.fetch(descriptor)+ #expect(insertedComments.map(\.id) == [externalComment.id])+ #expect(task.shareText(comments: insertedComments).contains("Added by an agent"))++ try service.deleteComment(externalComment)++ let deletedComments = try context.fetch(descriptor)+ #expect(deletedComments.isEmpty)+ #expect(!task.shareText(comments: deletedComments).contains("Added by an agent"))+ }+ // MARK: - Full format @Test func shareTextFullFormat() throws {
diff --git a/specs/bugfixes/open-task-details-do-not-refresh-externally-added-comments/report.md b/specs/bugfixes/open-task-details-do-not-refresh-externally-added-comments/report.mdnew file mode 100644index 0000000..a4a8f17--- /dev/null+++ b/specs/bugfixes/open-task-details-do-not-refresh-externally-added-comments/report.md@@ -0,0 +1,81 @@+# Bugfix Report: Open Task Details Do Not Refresh Externally Added Comments++**Date:** 2026-08-04+**Status:** Fixed++## Description of the Issue++An already-open task detail screen held comments in a one-time `@State` snapshot. Comments created or deleted by MCP in the shared app `ModelContext` were invisible until the detail screen was reopened. The Share action exported that same stale snapshot.++**Reproduction steps:**+1. Open a task detail screen.+2. Add or delete a comment through MCP while the screen remains open.+3. Observe that the visible comments and Share export do not change.++**Impact:** Agents and users could see and export obsolete task discussion data.++## Investigation Summary++- **Symptoms examined:** `TaskDetailView` only called `loadComments()` on appearance; `CommentsSection` only reloaded after its own local mutations.+- **Code inspected:** `TaskDetailView`, `CommentsSection`, `CommentService`, `TransitTask.shareText`, and the existing share-text tests.+- **Hypotheses tested:** The comment relation is CloudKit-compatible when queried from the child `Comment` side. `CommentService` already uses that query shape with chronological, UUID-tiebreaker ordering.++## Discovered Root Cause++The detail view used imperative snapshot state instead of a reactive SwiftData query. External mutations did not invoke either manual reload site, so neither the comment list nor `exportText` received current values.++**Defect type:** Stale state / missing observation.++**Why it occurred:** The original parent-owned binding fixed local Share consistency but did not observe mutations outside `CommentsSection`.++**Contributing factors:** The task-to-comments relationship is optional for CloudKit, so the query must be expressed on `Comment.task?.id` rather than from the optional to-many task relationship.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/CommentService.swift` - owns the reusable task-scoped child-side SwiftData descriptor with deterministic `creationDate`, then UUID ordering; service fetches and detail views share it.+- `Transit/Transit/Views/TaskDetail/TaskDetailView.swift` - installs that descriptor as `@Query` during view initialization and shares its current value with both rendering and Share export.+- `Transit/Transit/Views/TaskDetail/CommentsSection.swift` - consumes the parent query value and lets query observation replace manual reloads after local add/delete actions.++**Approach rationale:** `@Query` is SwiftUI's native reactive SwiftData mechanism and observes both MCP and UI changes in the shared context without timers, notifications, or duplicate fetching.++**Alternatives considered:**+- Reload on lifecycle events or timers - misses arbitrary external mutations and is wasteful.+- Pass mutable snapshot state through more callbacks - remains vulnerable to mutation paths that omit a callback.++## Regression Test++**Test file:** `Transit/TransitTests/ShareTextTests.swift`+**Test name:** `taskScopedCommentsReflectExternalInsertionDeletionAndKeepShareCurrent`++**What it verifies:** The exact descriptor installed by `TaskDetailView` excludes unrelated-task comments, reflects a locally saved agent insertion and deletion, and produces current Share text from each result.++**Run command:** `xcodebuild test -project Transit/Transit.xcodeproj -scheme Transit -destination 'platform=macOS' -only-testing:TransitTests/ShareTextTests`++## Affected Files++| File | Change |+|---|---|+| `CommentService.swift` | Shared task-scoped descriptor and comment fetch |+| `TaskDetailView.swift` | Replace snapshot with reactive `@Query` |+| `CommentsSection.swift` | Remove redundant manual refetching |+| `ShareTextTests.swift` | Regression coverage for the shared descriptor and current Share output |++## Verification++**Automated:**+- [x] Regression test passes (`TransitTests/ShareTextTests` on macOS)+- [x] Fast macOS unit suite passes (`make test-quick`)+- [x] Linters/validators pass (`make lint`)+- [ ] Full iOS suite has three unrelated existing UI failures: `TransitUITests.testClearAll()`, `TransitUITests.testEditViewPreservesTaskMilestone()`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath()` (1,246 passed / 3 failed). The same three failures recur in `make test-ui`; no T-1800/ShareText test failed.++**Manual verification:** Open a task detail screen, mutate comments through MCP, and confirm the list and Share sheet update without reopening.++## Prevention++- Detail data shared with an export action should derive from a reactive store query, not an independently refreshed snapshot.+- Reuse the exact descriptor in query-contract tests when a SwiftUI property wrapper cannot be rendered in a unit test host.++## Related++- T-1800
The repeated base failures should be tracked separately: Clear All waits for dashboard.clearAllFilters; the milestone test waits for standalone v1.0 (M-1); and Data Maintenance fails before navigation because dashboard.settingsButton is absent on the controlled iPhone 17 Pro state. The older candidate run also captured a distinct duplicate dataMaintenance.confirmButton alert signature.