Take the reader to the prose a decision points at #4
- KC
Two changes, one story: clicking a decision in the sidecar had nowhere to take you, and the decision itself had two places to be taken to.
---
# 1. Take the reader to the prose a decision points at
Clicking a sidecar card did nothing.
Everything needed to go somewhere was already resolved and already correct — a thread's `places`, a question's relations, both as Lexical node keys — and the only thing consuming any of it was the hover wash. A question even rendered a real `<button>` labelled "Show in plan", with a pointer cursor and a count of how many places it would reach, calling an `onRelationSelect` that no caller ever supplied. It typechecked, it rendered, it took a tab stop, and it was inert.
## The affordance is the quote
A button when the card has somewhere to send one, a blockquote when it has not — the rule `QuestionView` already applied to an answer, so both halves of the sidecar now offer the same thing in the same way. It matters most for an accepted comment: a `<Decision>` draws nothing in the prose, so the pane is the whole of where the decision can be seen and the quote is the only route to what it produced.
Not the whole card. A card holds a reply box, an Accept and a Dismiss, and making the whole of it a link would mean deciding on every click whether the reader meant the link or the control they actually hit.
## A hover asks; a click sends
Both leave the same wash, because they are the same fact — this is the prose that card refers to — and the difference is how long it is owed. A hover is over when the pointer moves; a click has put the reader somewhere they were not looking, so the mark is pinned for five seconds after the pointer has gone, or they arrive at a block with nothing to say which one it was.
There is one pin, in `marks.ts`, not one per store. A reader has one pointer, so going to a comment has to put out the question they went to before it — the same argument that already put the registry there. Pointing at another card in the meantime borrows the wash and gives it back.
## Clicking again walks
A decision can have produced several blocks, and the button already said how many. The step belongs to the pin rather than to the card: a lapsed pin means the reader has moved on, so the walk starts over rather than jumping to the third block a minute later with nothing on screen to explain it.
Whether a walk continues is asked of the pin rather than announced by it. Nothing needs redrawing when it lapses, since the lapse redraws itself, and a callback fired on the way to replacing a pin would reach a store in the middle of walking and wipe the step it had just taken.
## Also
- The question tabs called `onRelationSelect` too. Harmless for as long as it was `undefined`; wired up, it becomes the plan scrolling out from under somebody stepping through them to read. A tab switches tabs now. - `Decisions`' own `reveal` effect only ever matched `data-plan-sidecar-questionnaire`. `data-plan-sidecar-thread` had no reader at all. It matches either kind of card now. - The new button's accessible name includes the quote rather than replacing it.
---
# 2. Give a decision one place in the plan, not two
A question carried what it was about and what its answer produced as separate anchor sets, on the theory that they move independently. The agent, asked to tell them apart, anchored the first block of the result and called it the subject — in every record it has ever written:
``` q1 subject 72e814 result 72e814, 5b5cb2, d8f0c6 q2 subject 0fe395 result 0fe395, 5b5cb2 q3 subject 72e814 result 72e814, d8f0c6 ```
So a resolved card offered two adjacent, full-width, identically labelled "Show in plan" buttons that led to the same block — which is what made the first change land badly.
## The split cost more than the duplicate
A subject was never derived when a question was asked and never invalidated when the plan moved, so it sat permanently unreviewed: `counts()` reported nowhere, which rendered it inert, and `anchors_pending` carried an entry per question that no amount of anchoring could clear and that every `edit_plan` served back to the agent. It was untested at every layer, and `answer_changed` — the one reason that would have justified separate lifecycles — was never emitted.
## One `AnchorSet` per question
`anchor_plan` takes `{widget, question, blocks}` beside the `{thread, blocks}` it already took, which is the shape a comment thread has always used. A thread keeps its two, because there the halves have different authors and different shapes and neither can stand for the other — a thread's subject is usually the prose it asked to have rewritten.
On a resolved card the question and what was chosen are one button, since they are one decision. An unanswered question owes nothing, rather than owing a review from the moment it exists.
## The fold is not optional
Records written before this are read through `folded`, which keeps both halves. Without it the rebase on open meets a set with no `anchors` array — and that does **not** fail the open. It is caught and logged, so every decision in the plan silently loses its place, and because that guard is shared, one stale questionnaire takes the comment threads' anchors down with it.
Verified against a real room carrying the old shape: three questions, one placement each, nothing owed, where the agent previously owed three reviews it could never discharge.
---
## Testing
454 tests, up from 426. `bun run types`, `bun run ci` and `bun run build` clean.
The anchor path had **zero** coverage at any layer before this — `anchors.ts`, `Questions.relate/rebase/outstanding/invalidate` and `editor/anchors` were all untested, and `plan/anchors.test.ts` despite its name covers only the `Anchor` primitive. Three new files:
- `packages/editor/src/marks.test.ts` (extended) — a pin survives the pointer leaving; a hover borrows the wash and gives it back; one pin whichever half of the sidecar asked; the clock restarts on each ask so a walk can be walked. - `packages/editor/src/places.test.ts` (extended) — reveal marks one place, not all of them; clicking walks and wraps; the walk restarts once the mark has gone. - `apps/server/src/questions/anchors.test.ts` — the bookkeeping, and reading a record written before the fold. - `apps/server/src/questions.anchors.test.ts` — the same through `Service.open`, asserting the open logs no complaint, since with the guard in place the complaint is the only signal there is. - `packages/editor/src/anchors.test.ts` — resolution against a real editor.
Both migration tests were confirmed to fail with the fold removed.
`scroll.ts` has no test on purpose: there is no DOM in the test runtime, so it is kept as the whole of the part that needs a browser.
## Not verified here
The `anchor_plan` schema and the system prompt both changed, and `AGENT=off` cannot prove the model calls the tool with the new shape. `additionalProperties` is `false`, so a model still sending `relation` out of habit would have the call rejected. Needs one real agent turn against a token before merge.
Krzysztof-Cieslakmerged 722f12daac1d3 months ago on GitHub
Krzysztof-Cieslakhead-ref-deleted3 months ago on GitHub