Comments, phase 2 — the pane, the composer, and the inline session

The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+476
View File
@@ -0,0 +1,476 @@
import Foundation
import Testing
@testable import Kanban
/// The comments pane's **two buffers** and the cadences that separate them (05-card-window.md The
/// comments column):
///
/// - the composer's draft, whose saves are slow "composer blur, window close, quit, and a lazy
/// interval (~30 s) **not** the body editor's 700 ms";
/// - an inline comment edit, which *is* the body editor's 700 ms, plus the one thing the body has
/// not got: a Cancel that reverts to session-start bytes.
///
/// `CardBodyEditSessionTests`' shape, and for its reason: both types are a buffer and a clock with a
/// closure for a destination, so this suite is about the rules rather than about files the fakes
/// below record what they were asked to write, and "writes nothing" is an assertion about a count.
/// The bytes those saves put on disk are `CommentWriteTests`'.
// MARK: - Fakes
/// A stand-in for `BoardStore.saveCommentDraft` / `postComment`.
@MainActor
private final class DraftSpy {
private(set) var written: [String] = []
private(set) var posts = 0
/// `nil` is a save that did not land a failure, the read-only lock, a vanished card.
var outcome: CommentDraftOutcome? = .updated
var postedID: ItemID? = ItemID(rawValue: CommentIdent.one)
var count: Int { written.count }
func save(_ text: String) -> CommentDraftOutcome? {
written.append(text)
return outcome
}
func post() -> ItemID? {
posts += 1
return postedID
}
}
/// A stand-in for `BoardStore.editComment`.
@MainActor
private final class EditSpy {
private(set) var written: [String] = []
var lands = true
var count: Int { written.count }
var last: String? { written.last }
func save(_ text: String) -> Bool {
written.append(text)
return lands
}
}
@MainActor
private func makeComposer(_ spy: DraftSpy, draft: CommentDraft? = nil) -> CommentDraftSession {
let session = CommentDraftSession()
// Fast enough that a test never waits on the real 30 s. `CardBodyEditSessionTests`' precedent
// a production default on the property, the suite dialling it down.
session.saveInterval = .milliseconds(40)
session.save = { [spy] text in spy.save(text) }
session.post = { [spy] in spy.post() }
session.adopt(draft: draft)
return session
}
@MainActor
private func makeEditor(_ spy: EditSpy, body: String = "original\n") -> CommentEditSession {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: body)
session.debounceInterval = .milliseconds(30)
session.save = { [spy] text in spy.save(text) }
return session
}
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while !condition() {
guard ContinuousClock.now - start < deadline else { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
// MARK: - The composer's cadence
@MainActor
@Suite("Comment composer ▸ the slow cadence")
struct CommentComposerCadenceTests {
@Test("The production interval is ~30 s, not the body's 700 ms")
func theDefaultIntervalIsTheDesignsNumber() {
// The one thing the seam must not do is quietly become the body's cadence "so a Pro user's
// typing never becomes a commit stream" (05 The comments column).
#expect(CommentDraftSession().saveInterval == .seconds(30))
#expect(CommentDraftSession().saveInterval != CardBodyEditSession().debounceInterval)
}
@Test("A keystroke does not save; the interval does")
func theIntervalSaves() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("half a thought")
#expect(spy.count == 0, "not on the keystroke itself")
await waitUntil { spy.count == 1 }
#expect(spy.written == ["half a thought"])
#expect(!session.isDirty)
}
@Test("It is an interval, not a debounce: steady typing still lands a save")
func typingDoesNotPostponeTheSave() async {
let spy = DraftSpy()
let session = makeComposer(spy)
// A debounce would be restarted by each of these and never fire while the user typed. An
// interval fires on its own schedule which is the whole difference, and the reason the
// design says "lazy interval" rather than "debounce".
let start = ContinuousClock.now
while spy.count == 0, ContinuousClock.now - start < .seconds(2) {
session.edited(session.text + "a")
try? await Task.sleep(for: .milliseconds(5))
}
#expect(spy.count == 1)
}
@Test("Blur saves — the first of the four cadence moments")
func blurSaves() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
#expect(session.blurred() == .updated)
#expect(spy.written == ["typed"])
#expect(!session.isDirty)
}
@Test("A flush pre-empts the armed interval, and the interval does not fire behind it")
func flushPreemptsTheInterval() async {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("typed")
_ = session.flush()
#expect(spy.count == 1)
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("An untouched composer writes nothing, ever")
func anUntouchedComposerWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
#expect(session.text == "restored\n", "restore-on-reopen is just the read")
#expect(session.flush() == nil)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A typed-then-reverted draft writes nothing, and leaves no timer standing")
func aRevertedDraftWritesNothing() async {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))
session.edited("restored\nand more")
session.edited("restored\n")
#expect(!session.isDirty)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.outcome = nil
session.edited("precious")
#expect(session.flush() == nil)
#expect(session.text == "precious")
#expect(session.isDirty, "the read-only lock, a failure and a vanished card all keep the text")
}
@Test("Emptying the composer saves empty text — the Writer owns the delete rule")
func emptyingSavesEmptyText() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "was here\n", attachments: []))
spy.outcome = .deleted
session.edited("")
#expect(session.flush() == .deleted)
#expect(spy.written == [""], "no second definition of 'no text and no attachments'")
#expect(!session.isDirty)
}
}
// MARK: - Dirty-buffer-wins, in the composer
@MainActor
@Suite("Comment composer ▸ dirty-buffer-wins")
struct CommentComposerAdoptTests {
@Test("A clean composer follows the file — a draft synced in arrives by itself")
func aCleanComposerFollowsDisk() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.adopt(draft: CommentDraft(body: "from another machine\n", attachments: ["shot.png"]))
#expect(session.text == "from another machine\n")
#expect(session.attachments == ["shot.png"])
#expect(!session.isDirty)
}
@Test("A dirty composer keeps its keystrokes under a reload")
func aDirtyComposerKeepsItsText() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mine, unsaved")
session.adopt(draft: CommentDraft(body: "theirs\n", attachments: []))
#expect(session.text == "mine, unsaved")
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
#expect(session.isDirty)
}
@Test("Chips follow the file even while the text is dirty — a drop is not a keystroke")
func chipsFollowDiskWhileDirty() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("mid-sentence")
// The user dropped a file into the composer: the import is a write of its own, and its
// reload must show the chip without disturbing the text above it.
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.text == "mid-sentence")
#expect(session.attachments == ["shot.png"])
}
@Test("No draft at all is an empty, clean composer")
func noDraftIsEmpty() {
let spy = DraftSpy()
let session = makeComposer(spy, draft: CommentDraft(body: "here\n", attachments: []))
session.adopt(draft: nil)
#expect(session.text == "")
#expect(!session.isDirty)
}
}
// MARK: - Posting
@MainActor
@Suite("Comment composer ▸ posting")
struct CommentComposerPostTests {
@Test("There is nothing to post exactly when there would be nothing to keep")
func canPostMirrorsTheEmptiedDraftRule() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(!session.canPost)
session.edited(" \n ")
#expect(!session.canPost, "whitespace is empty, the Writer's own gate")
session.edited("something")
#expect(session.canPost)
session.edited("")
session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
#expect(session.canPost, "a draft with no text but a file in it is still a draft")
}
@Test("⌘↩ flushes before it posts — the slow cadence is never visible as lost text")
func postFlushesFirst() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("about to post")
#expect(session.postNow() != nil)
#expect(spy.written == ["about to post"], "the post renames a folder; unwritten text is not in it")
#expect(spy.posts == 1)
}
@Test("After a post the composer is empty and clean — the draft is gone")
func postEmptiesTheComposer() {
let spy = DraftSpy()
let session = makeComposer(spy)
session.edited("posted")
session.adopt(draft: CommentDraft(body: "posted", attachments: ["shot.png"]))
_ = session.postNow()
#expect(session.text == "")
#expect(session.disk == "")
#expect(session.attachments.isEmpty)
#expect(!session.isDirty)
}
@Test("An empty composer posts nothing at all")
func anEmptyComposerPostsNothing() {
let spy = DraftSpy()
let session = makeComposer(spy)
#expect(session.postNow() == nil)
#expect(spy.posts == 0)
#expect(spy.count == 0)
}
@Test("A post that did not land leaves the composer holding its text")
func aRefusedPostKeepsTheDraft() {
let spy = DraftSpy()
let session = makeComposer(spy)
spy.postedID = nil
session.edited("still mine")
#expect(session.postNow() == nil)
#expect(session.text == "still mine")
}
}
// MARK: - The inline edit session
@MainActor
@Suite("Inline comment edit ▸ the body session in miniature")
struct CommentEditSessionTests {
@Test("The cadence is the body's ~700 ms, not the draft's")
func theCadenceIsTheBodys() {
let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: "")
#expect(session.debounceInterval == CardBodyEditSession().debounceInterval)
}
@Test("Typing saves once the keystrokes stop — crash safety without a commit point")
func typingSaves() async {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(spy.count == 0)
await waitUntil { spy.count == 1 }
#expect(spy.written == ["revised\n"])
#expect(!session.isDirty)
}
@Test("A burst is one save, of the last text")
func aBurstCoalesces() async {
let spy = EditSpy()
let session = makeEditor(spy)
for text in ["a", "ab", "abc"] { session.edited(text) }
await waitUntil { spy.count >= 1 }
#expect(spy.written == ["abc"])
}
@Test("An untouched session writes nothing, and ends writing nothing")
func anUntouchedSessionWritesNothing() async {
let spy = EditSpy()
let session = makeEditor(spy)
#expect(!session.commit())
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("Save is the commit point: it flushes and ends")
func commitFlushesAndEnds() {
let spy = EditSpy()
let session = makeEditor(spy)
session.edited("revised\n")
#expect(session.commit())
#expect(spy.written == ["revised\n"])
#expect(session.hasEnded)
#expect(!session.commit(), "a session ends once")
}
@Test("Cancel writes the session-start bytes back")
func cancelRevertsToSessionStart() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("half a rewrite\n")
#expect(session.flush())
#expect(spy.written == ["half a rewrite\n"])
#expect(session.cancel())
#expect(spy.written == ["half a rewrite\n", "original\n"])
#expect(session.text == "original\n")
#expect(session.hasEnded)
}
@Test("A cancel with nothing landed writes nothing — the file stays byte-identical")
func cancelAfterNoSaveWritesNothing() {
let spy = EditSpy()
let session = makeEditor(spy)
// Typed, but the debounce never fired: nothing of this session's is on disk, so there is
// nothing to put back and no `modified` stamp to spend.
session.edited("never landed\n")
#expect(!session.cancel())
#expect(spy.count == 0)
}
@Test("Cancel after several ticks reverts to the session's start, not to the last tick")
func cancelUndoesTheWholeSession() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("one\n")
_ = session.flush()
session.edited("two\n")
_ = session.flush()
session.edited("three\n")
_ = session.flush()
#expect(session.cancel())
#expect(spy.last == "original\n")
}
@Test("A window close flushes the session — it never reverts it")
func closeFlushesRatherThanReverts() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mid-sentence\n")
#expect(session.endOnClose())
#expect(spy.written == ["mid-sentence\n"], "dismissal never eats typed work where a save can land")
#expect(session.hasEnded)
}
@Test("A save that did not land keeps the buffer dirty, and the text")
func aFailedSaveKeepsTheText() {
let spy = EditSpy()
let session = makeEditor(spy)
spy.lands = false
session.edited("precious\n")
#expect(!session.flush())
#expect(session.text == "precious\n")
#expect(session.isDirty)
}
@Test("Dirty-buffer-wins: a foreign edit never lands under the cursor")
func dirtyBufferWins() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.edited("mine\n")
session.adopt(diskBody: "theirs\n")
#expect(session.text == "mine\n")
#expect(session.disk == "theirs\n")
// And a clean buffer follows disk.
let clean = makeEditor(spy, body: "original\n")
clean.adopt(diskBody: "theirs\n")
#expect(clean.text == "theirs\n")
#expect(!clean.isDirty)
}
@Test("Cancel after a foreign edit still writes the session's start bytes — last writer wins")
func cancelOverAForeignEdit() {
let spy = EditSpy()
let session = makeEditor(spy, body: "original\n")
session.adopt(diskBody: "an agent rewrote it\n")
#expect(session.cancel())
#expect(spy.last == "original\n", "the same no-merge-UI philosophy the body's dirty-buffer rule states")
}
}