Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -348,6 +348,15 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public let echoes = EchoLedger()
|
||||
|
||||
/// **Board search's transient comment index** (04-interactions.md ▸ Search, re-ruled 2026-07-29)
|
||||
/// — the sweep that lets a query reach comment bodies without the snapshot ever carrying one.
|
||||
///
|
||||
/// A `let` beside `echoes` and `heals`, and *not* `@ObservationIgnored`: `searchFilter` reads
|
||||
/// `matchingCards`, so every surface that filters through the store re-renders when a sweep lands.
|
||||
/// That refinement arriving a moment after the keystroke is the design's own accepted behaviour —
|
||||
/// see `CommentSearchIndex` for the freshness rule and its interim.
|
||||
public let commentIndex = CommentSearchIndex()
|
||||
|
||||
/// The rows the board window's strip renders, in precedence order.
|
||||
///
|
||||
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
|
||||
@@ -756,7 +765,13 @@ public final class BoardStore: HealHost {
|
||||
// "ride whatever transaction is active rather than easing on its own", and a
|
||||
// re-grounding that landed outside this one would be exactly the independent ease
|
||||
// that rules out.
|
||||
transient.resolve(against: result.model)
|
||||
//
|
||||
// The comment index' current answer rides along, because the filter half of the
|
||||
// re-grounding is the *whole* filter (04 ▸ Search, re-ruled 2026-07-29) — a card
|
||||
// matching only through its comments must not be evicted from the selection by a
|
||||
// reload that asked a narrower question. The index is re-swept just below, outside
|
||||
// the transaction, and its landing re-runs this constraint through `onRefine`.
|
||||
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
|
||||
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
||||
// resolution leaves an emptied selection wherever the focused item used to be, and
|
||||
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
||||
@@ -772,6 +787,14 @@ public final class BoardStore: HealHost {
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
defects = result.defects
|
||||
// **The comment index' freshness signal, and its stated interim** (04-interactions.md ▸
|
||||
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
|
||||
// no changed-path channel — the watcher reports only *that* the tree changed
|
||||
// (02-architecture.md) — so what a landed reload can offer an index of window-scoped
|
||||
// content is its generation, and a query still active re-sweeps on it. Coarser than the
|
||||
// ruling asks for and bounded by the same debounce; a no-op with no query running, which
|
||||
// is the overwhelmingly common reload.
|
||||
refreshCommentIndex()
|
||||
reconcileLock(after: origin)
|
||||
// The registry write-through, for the same "not board structure" reason the lock
|
||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||
@@ -3977,13 +4000,42 @@ public final class BoardStore: HealHost {
|
||||
set {
|
||||
guard newValue != transient.searchQuery else { return }
|
||||
transient.searchQuery = newValue
|
||||
transient.constrainToSearch(in: snapshot)
|
||||
// **The comment index tracks the query, not the reload** (04 ▸ Search, re-ruled
|
||||
// 2026-07-29): the first keystroke of a query kicks the sweep, every later one re-filters
|
||||
// what it found, and clearing throws the whole thing away. Before the constraint, so a
|
||||
// keystroke that *widens* the comment matches does not first evict a selection the very
|
||||
// next line would have kept.
|
||||
refreshCommentIndex()
|
||||
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
}
|
||||
|
||||
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
|
||||
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
|
||||
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
|
||||
///
|
||||
/// **It carries the comment index' answer**, which is what makes "a card matches if any of its
|
||||
/// meaningful content matches" true for comments on every surface at once — the masonry, the
|
||||
/// order lists, the trash column, Select All — without any of them learning that comments exist.
|
||||
public var searchFilter: SearchFilter {
|
||||
SearchFilter(query: transient.searchQuery, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
|
||||
/// Points the comment index at the current query and snapshot — the one funnel, called from the
|
||||
/// query's setter and from a landed reload (`land`).
|
||||
///
|
||||
/// The targets are computed **lazily**, inside the index' own decision: an already-fresh index
|
||||
/// re-filters in memory and never asks, so an ordinary keystroke costs no board walk at all.
|
||||
private func refreshCommentIndex() {
|
||||
commentIndex.onRefine = { [weak self] in
|
||||
guard let self else { return }
|
||||
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
commentIndex.update(
|
||||
query: transient.searchQuery,
|
||||
generation: snapshotGeneration,
|
||||
targets: CommentSearchIndex.targets(in: snapshot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
|
||||
/// focus and an active search, one press clears the search and the full board returns"), and the
|
||||
|
||||
Reference in New Issue
Block a user