The phone reads the thread — comments arrive on the card's read view, posting and editing behind transactional sheets

The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 18:18:56 -04:00
parent 2516c4ba5d
commit 78c32776d4
9 changed files with 521 additions and 27 deletions
@@ -12,6 +12,13 @@ import SwiftUI
/// the whole point of the transactional model: a screen that only ever renders `session.snapshot`
/// cannot itself go stale relative to disk, and there is no auto-save timing to reason about
/// because there is no save at all on this side of the Edit button.
///
/// **Comments are the one thing here the snapshot does not carry.** The thread lives outside the
/// board walk (the Mac's arrangement, kept), so this screen reads it itself re-read whenever
/// `session.snapshotGeneration` moves, which covers both a comment sheet's own write (whose
/// `perform` awaits the reload) and a foreign change landing. The read-only posture survives
/// intact: the thread renders read-only, and the two comment writes live behind their own
/// transactional sheets (`CommentEditorScreen`), exactly as card edits live behind Edit.
struct CardDetailScreen: View {
let boardRoot: URL
let laneID: ItemID
@@ -21,6 +28,9 @@ struct CardDetailScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var isPresentingEdit = false
@State private var thread: CommentThread?
@State private var isComposingComment = false
@State private var editingComment: Comment?
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
@@ -40,9 +50,21 @@ struct CardDetailScreen: View {
}
}
.task { session.open() }
// The thread's read, keyed on the walk counter: runs at appearance (generation 0 or
// whatever the session has reached), then again every time a walk lands which is
// every landing point a thread on this screen can have gone stale at.
.task(id: session.snapshotGeneration) {
thread = await session.loadCommentThread(laneID: laneID, cardID: cardID)
}
.fullScreenCover(isPresented: $isPresentingEdit) {
CardEditScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
}
.sheet(isPresented: $isComposingComment) {
CommentEditorScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID, mode: .compose)
}
.sheet(item: $editingComment) { comment in
CommentEditorScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID, mode: .edit(comment))
}
}
@ViewBuilder
@@ -59,6 +81,11 @@ struct CardDetailScreen: View {
titleBlock(for: card)
bodyBlock(for: card)
detailsFooter(for: card)
CardCommentsSection(
thread: thread,
onAdd: { isComposingComment = true },
onEdit: { editingComment = $0 }
)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)