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:
@@ -1,5 +1,7 @@
|
||||
**August 2026**
|
||||
|
||||
Cards now show their comments, and you can add a new comment or press and hold an existing one to edit it.
|
||||
|
||||
Card text now renders full Markdown — headings, lists with checkboxes, quotes, tables, and code blocks — instead of plain paragraphs.
|
||||
|
||||
Tapping a card now shows a readable view of its title and text with formatting, instead of dropping straight into the editor.
|
||||
|
||||
@@ -55,6 +55,13 @@ final class BoardSession {
|
||||
/// Cleared by the next walk that succeeds.
|
||||
private(set) var lastError: BoardSessionError?
|
||||
|
||||
/// Bumped every time a walk lands a snapshot. Comment threads live *outside* the snapshot —
|
||||
/// the walk stays O(cards) and never opens comment content, the Mac's own arrangement — so a
|
||||
/// screen rendering a thread keys its re-read on this: it moves after the screen's own write
|
||||
/// (whose `perform` awaits the reload) and after a foreign change the metadata query relayed,
|
||||
/// which are exactly the two moments a thread on screen can have gone stale.
|
||||
private(set) var snapshotGeneration = 0
|
||||
|
||||
/// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure
|
||||
/// optimization — it cannot change what a walk answers, only how many files it opens — and it is
|
||||
/// what keeps the reload after every single write from re-parsing the whole board on a phone.
|
||||
@@ -189,6 +196,7 @@ final class BoardSession {
|
||||
parseMemo = result.memo
|
||||
lastError = nil
|
||||
phase = .ready
|
||||
snapshotGeneration += 1
|
||||
cancelRetry()
|
||||
|
||||
case let .failed(error):
|
||||
@@ -212,6 +220,66 @@ final class BoardSession {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comment thread
|
||||
|
||||
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
|
||||
/// its own thread: window-scoped, outside the board snapshot, coordinated like every other
|
||||
/// read on the phone.
|
||||
///
|
||||
/// Total, like the read it wraps: `CommentThread.load` never refuses, and a coordinator
|
||||
/// refusal answers `.empty` after a log line rather than surfacing — a thread that momentarily
|
||||
/// will not read renders as "no comments" for one pass and re-reads on the next
|
||||
/// `snapshotGeneration` bump, not an error state the screen has to draw. The defects the read
|
||||
/// reports are dropped here: the phone has no heal engine to hand them to, and healing from
|
||||
/// two apps at once would be two writers racing over one defect.
|
||||
func loadCommentThread(laneID: ItemID, cardID: ItemID) async -> CommentThread {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentThread, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.load(
|
||||
inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved),
|
||||
path: "\(laneID.rawValue)/\(cardID.rawValue)"
|
||||
)
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(thread):
|
||||
return thread
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment thread read failed: \(failure.description, privacy: .public)")
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the card's single draft — what seeds the composer. The draft is a folder inside the
|
||||
/// card, so it syncs like everything else: a comment started on the Mac is offered here to
|
||||
/// finish, exactly as designed ("the card's single draft", 01-storage-format.md § Enhanced
|
||||
/// schema). `nil` is "nothing to restore" — no draft, an unreadable one, or a coordinator
|
||||
/// refusal, none of which the composer can do anything about beyond starting empty.
|
||||
func loadCommentDraft(laneID: ItemID, cardID: ItemID) async -> CommentDraft? {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentDraft?, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.loadDraft(inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved))
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(draft):
|
||||
return draft
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment draft read failed: \(failure.description, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `<root>/<lane>/<card>` — the derivation every comment call anchors on, spelled once and
|
||||
/// `nonisolated` so the detached reads above can use it against the coordinator-resolved root.
|
||||
nonisolated static func cardFolder(laneID: ItemID, cardID: ItemID, inRoot root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Runs a closure of `BoardWriter` calls against this board and reloads.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The read screen's comment thread — the phone's rendering of `CommentThread`, oldest first,
|
||||
/// below the card's own content.
|
||||
///
|
||||
/// **The wording is the Mac's** (`CommentsHeader` / `CommentAuthorLine` in `CardCommentsLayout` —
|
||||
/// pure, but homed in `Kanban/UI`, which the phone deliberately does not compile, so the two lines
|
||||
/// are restated rather than imported): the header is "Comments · N" with the zero rendered as a
|
||||
/// count rather than an absence, and the author line is "author · timestamp · edited" with each
|
||||
/// absent segment dropped and the whole line dropped when there is neither a name nor a date to
|
||||
/// hang "edited" from. Absent means absent — no "Unattributed" placeholder, no avatar: the file
|
||||
/// deliberately carries no identity system, and the render must not invent one.
|
||||
///
|
||||
/// Read-only rows plus two doors: **Add Comment** under the thread opens the composer, and each
|
||||
/// row's context menu carries **Edit** — both sheets, both transactional, both the containing
|
||||
/// screen's to present. A posted comment's attachments render as read-only paperclip rows, the
|
||||
/// Mac's own rule for chips outside an authoring surface.
|
||||
struct CardCommentsSection: View {
|
||||
/// `nil` while the first read is in flight — the header and the Add button render, rows wait.
|
||||
let thread: CommentThread?
|
||||
let onAdd: () -> Void
|
||||
let onEdit: (Comment) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Divider()
|
||||
|
||||
Text("Comments · \(thread?.comments.count ?? 0)")
|
||||
.font(.subheadline.smallCaps().weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let thread {
|
||||
ForEach(thread.comments) { comment in
|
||||
CommentRow(comment: comment)
|
||||
.contextMenu {
|
||||
Button {
|
||||
onEdit(comment)
|
||||
} label: {
|
||||
Label("Edit Comment", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onAdd) {
|
||||
Label("Add Comment", systemImage: "plus.bubble")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One posted comment: author line, body through the same block renderer the card's own body
|
||||
/// uses (a comment's Markdown is the card-body subset, so the renderer is shared by construction),
|
||||
/// and the attachment listing.
|
||||
private struct CommentRow: View {
|
||||
let comment: Comment
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let line = authorLine {
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Only a non-empty body reaches the renderer: `CardBodyView`'s empty case says
|
||||
// "No description", which is a card's word, not a comment's — an attachment-only
|
||||
// comment renders its chips alone.
|
||||
if !comment.body.isEmpty {
|
||||
CardBodyView(body: comment.body)
|
||||
}
|
||||
|
||||
ForEach(comment.attachments, id: \.self) { name in
|
||||
Label(name, systemImage: "paperclip")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
/// `CommentAuthorLine.text(author:timestamp:isEdited:)`, restated. An empty `author` string is
|
||||
/// treated as absent for the Mac's reason: the Writer never writes `author: ""` (it omits the
|
||||
/// key), so one on disk is a hand edit, and a blank name beside a separator would be noise.
|
||||
private var authorLine: String? {
|
||||
var parts: [String] = []
|
||||
if let author = comment.author.value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!author.isEmpty {
|
||||
parts.append(author)
|
||||
}
|
||||
if let created = comment.created.value {
|
||||
parts.append(created.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
if comment.isEdited {
|
||||
parts.append("edited")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The comment composer and the inline comment edit, one transactional sheet — presented from
|
||||
/// `CardDetailScreen`'s comments section, `CardEditScreen`'s model in miniature: drafts commit on
|
||||
/// the trailing button or not at all, Cancel guards a dirty draft with a discard confirmation, a
|
||||
/// failed write keeps the sheet and its draft and raises an alert, and dismissal happens only on
|
||||
/// the perform's own `.success`. No scenePhase or onDisappear commit anywhere — backgrounding the
|
||||
/// app leaves the draft waiting.
|
||||
///
|
||||
/// ### Compose posts through the card's single draft
|
||||
///
|
||||
/// `Post` is `saveCommentDraft` then `postComment` in **one `session.perform` bracket** — the
|
||||
/// Mac composer's own two primitives, back to back. That makes the on-disk draft load-bearing
|
||||
/// rather than optional: the composer seeds from it when one exists (it syncs — a comment started
|
||||
/// on the Mac is offered here to finish, and its attachments ride into the post untouched),
|
||||
/// because a post that ignored it would silently clobber that other device's words with these.
|
||||
/// Cancel touches nothing on disk: the phone's transactional model, so the typed text is the
|
||||
/// sheet's and the draft folder stays exactly as the last device left it.
|
||||
///
|
||||
/// ### Edit seeds from the row that opened it
|
||||
///
|
||||
/// The one screen in this stack that carries a value rather than IDs alone — deliberately: a
|
||||
/// comment lives outside the board snapshot, so there is nothing to re-read from on each body
|
||||
/// evaluation, and the Mac's inline edit session seeds from open-time bytes the same way. The
|
||||
/// save targets the comment's *id* on disk regardless, so a body that changed elsewhere mid-edit
|
||||
/// is overwritten by an explicit Save — the same last-writer-wins any two editors of one file
|
||||
/// have — and a comment deleted elsewhere refuses the write and alerts rather than recreating it.
|
||||
struct CommentEditorScreen: View {
|
||||
enum Mode {
|
||||
/// A new comment through the card's single draft.
|
||||
case compose
|
||||
/// One posted comment's body.
|
||||
case edit(Comment)
|
||||
}
|
||||
|
||||
let boardRoot: URL
|
||||
let laneID: ItemID
|
||||
let cardID: ItemID
|
||||
let mode: Mode
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var bodyDraft = ""
|
||||
@State private var seededBody = ""
|
||||
@State private var draftAttachments: [String] = []
|
||||
@State private var isConfirmingDiscard = false
|
||||
@State private var isWriting = false
|
||||
@State private var writeError: BoardSessionError?
|
||||
@FocusState private var isBodyFocused: Bool
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
private var isEditing: Bool {
|
||||
if case .edit = mode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var isDirty: Bool { bodyDraft != seededBody }
|
||||
|
||||
/// Post validation, the emptied-draft rule read forwards: there is something to post exactly
|
||||
/// when there would be something to keep — text, or the draft's synced attachments. Edit has
|
||||
/// no such gate; an unchanged Save writes nothing (the writer's identical-bytes rule) and a
|
||||
/// deliberately emptied body is the user's to write.
|
||||
private var canCommit: Bool {
|
||||
switch mode {
|
||||
case .compose:
|
||||
!bodyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !draftAttachments.isEmpty
|
||||
case .edit:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TextEditor(text: $bodyDraft)
|
||||
.focused($isBodyFocused)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.navigationTitle(isEditing ? "Edit Comment" : "New Comment")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { requestDismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isEditing ? "Save" : "Post") { commit() }
|
||||
.disabled(!canCommit || isWriting)
|
||||
}
|
||||
}
|
||||
.task { await seed() }
|
||||
.confirmationDialog(
|
||||
"Discard Comment?",
|
||||
isPresented: $isConfirmingDiscard,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Discard", role: .destructive) { dismiss() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert(
|
||||
isEditing ? "Couldn't Save" : "Couldn't Post",
|
||||
isPresented: Binding(get: { writeError != nil }, set: { if !$0 { writeError = nil } }),
|
||||
presenting: writeError
|
||||
) { _ in
|
||||
Button("OK") { writeError = nil }
|
||||
} message: { error in
|
||||
Text(error.description)
|
||||
}
|
||||
}
|
||||
// A sheet — unlike the edit screen's fullScreenCover — has an interactive swipe-dismiss,
|
||||
// which would be a silent Cancel past the dirty guard. Disabling it while dirty routes
|
||||
// every exit through the two buttons; a clean sheet still swipes away freely.
|
||||
.interactiveDismissDisabled(isDirty)
|
||||
}
|
||||
|
||||
/// Seeds once, at presentation. Edit seeds synchronously from the row's value; compose reads
|
||||
/// the card's single draft off the main actor and applies it only if the user has not already
|
||||
/// started typing into the empty editor — the seed must never eat keystrokes.
|
||||
private func seed() async {
|
||||
switch mode {
|
||||
case let .edit(comment):
|
||||
bodyDraft = comment.body
|
||||
seededBody = comment.body
|
||||
case .compose:
|
||||
guard let draft = await session.loadCommentDraft(laneID: laneID, cardID: cardID) else { break }
|
||||
draftAttachments = draft.attachments
|
||||
if bodyDraft.isEmpty {
|
||||
bodyDraft = draft.body
|
||||
seededBody = draft.body
|
||||
}
|
||||
}
|
||||
isBodyFocused = true
|
||||
}
|
||||
|
||||
private func requestDismiss() {
|
||||
if isDirty {
|
||||
isConfirmingDiscard = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// One `session.perform` bracket either way; `perform` awaits its own reload, which bumps
|
||||
/// `snapshotGeneration`, which is what makes the thread under the dismissing sheet already
|
||||
/// show the new comment the instant the animation ends.
|
||||
private func commit() {
|
||||
let body = bodyDraft
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let cardTitle = session.snapshot?.lanes
|
||||
.first { $0.id == laneID }?.cards
|
||||
.first { $0.id == cardID }?.title.value
|
||||
|
||||
isWriting = true
|
||||
Task {
|
||||
let outcome: Result<Void, BoardSessionError>
|
||||
switch mode {
|
||||
case .compose:
|
||||
outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root)
|
||||
try BoardWriter.saveCommentDraft(inCard: folder, body: body, cardTitle: cardTitle)
|
||||
_ = try BoardWriter.postComment(inCard: folder, cardTitle: cardTitle)
|
||||
}
|
||||
case let .edit(comment):
|
||||
let commentID = comment.id
|
||||
outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root)
|
||||
try BoardWriter.editComment(
|
||||
at: CommentThread.commentFolder(commentID, inCard: folder),
|
||||
body: body,
|
||||
cardTitle: cardTitle
|
||||
)
|
||||
}
|
||||
}
|
||||
isWriting = false
|
||||
switch outcome {
|
||||
case .success:
|
||||
dismiss()
|
||||
case let .failure(error):
|
||||
writeError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user