fix(selection): judge containment by text at the bubble and doc pane (#7891) - #7993
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Restores consistent selection behavior — last paragraph/line now works like every other; screenshots confirm the Comment pill renders correctly in both themes. The shipped change is purely behavioral: triple-click on a spec document's last paragraph now raises the Comment pill, and copying a bubble's last line now expands paste chips instead of shipping the raw [UX-REVIEWED] 40b915c |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Third occurrence of a shipped-and-proven predicate correctly centralized instead of copied; scope, tests, and evidence all match the stated problem. [DESIGN-REVIEWED] 40b915c |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks complete. The helper has 3 real consumers, zero unfixed siblings of the predicate remain, and the capture/screenshot artifacts follow an established repo convention. Final review: First-Principles-Verdict: PASS Two reported last-line defects fixed at cause level — the wrong predicate is deleted everywhere it existed, replaced by one shared helper with three counted consumers. What this change shipsIntent: make multi-click selections of a container's last line work at the two surfaces still carrying #7875's bug — a FIX.
Depth check: grepped [FIRST-PRINCIPLES-REVIEWED] 40b915c |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
…7891) PR #7875 stopped the chat selection toolbar dismissing a multi-click (double/triple-click) selection of a message's LAST line: browsers normalize such a selection to a boundary point just past the container, hoisting range.commonAncestorContainer above it, so a bare container.contains(range.commonAncestorContainer) early return rejects a selection whose every character is inside the container. The First Principles review on that PR found the same gate surviving at two more surfaces, each inheriting the identical failure: - UserMessage's copy interceptor bailed, so copying a user bubble's last line shipped the literal paste-chip label ("[ Paste #1 - 4 lines ]") that the handler exists to replace. - The spec-builder document pane cleared the selection, so triple-clicking the last paragraph raised no Comment pill and the review affordance was unreachable for that paragraph. Rather than copy the predicate a third time, it moves to containedSelectionRange(range, container) in website/src/utils/, and all three call sites read from it. The helper returns the part of the range that lies inside the container -- the original range untouched on the fast path, a container-clamped clone when an accepted overhang has to be trimmed, null on rejection -- so each caller measures, stringifies, or clones from a range that cannot reach past the surface it owns. Both rejection tiers move with it in order: the O(1) endpoint check first, because one toolbar instance per message listens on document and the N-1 non-owning instances must not stringify text growing with transcript distance; then the text tier, which clamps a clone to each side and requires both overhangs to hold no text, keeping a selection that genuinely spans into a sibling rejected in either direction. SelectionToolbar's behavior is unchanged: its call site now reads the clamped measurement range from the helper instead of computing it inline, and its 11 existing tests pass untouched, including the cloneRange spy that pins the O(1) reject. Building the real-Chromium capture sharpened the document pane's mechanism beyond what the issue states, and the harness encodes what was learned. The pill is rendered only while a selection is held, and the double-click stage of a triple-click normally sets it -- so the pill's own overlay becomes the pane's last child and ABSORBS the boundary normalization, leaving the common ancestor inside the pane and the pre-fix code working. The defect needs the pill absent when the third click settles, which is what happens when the double-click selected a word below onSelectionSettled's 3-character floor. Probing the live selection capture-phase confirms both halves on the pre-fix component: clicking a long word reports paneContainsCac true, while clicking the two-letter word "to" reports paneContainsCac false with the end normalized to a <p> outside the pane, and no pill appears. So triple-clicking a last paragraph while the pointer sits over a short word silently produced nothing. Tests model the boundary-normalized geometry directly, as PR #7875 does, because happy-dom performs no native multi-click selection. Five unit tests pin the helper's contract, including the identity fast path and the O(1) tier that no single consumer exercises alone. Two tests per surface pin both sides of the invariant: the normalized last-line selection is accepted (chip expands; Comment pill appears) and a selection genuinely running into a text-bearing neighbour stays rejected. All four affected files verified red against main before the fix and green after. The committed capture script drives real Chromium and times out against the pre-fix component, so it is a live regression proof rather than a portrait. Closes #7891
4502ff2 to
40b915c
Compare
buluoray
left a comment
There was a problem hiding this comment.
Verdict: Approve — no blocking findings. Reviewed at head 40b915cf552ed7472204696b108a87a81cda1c43.
What I verified
- The change is not a string-occurrence match, despite the title.
website/src/utils/selectionContainment.tscontainedSelectionRange(range, container)operates on the liveRange's real boundary points, not on a text search of the region. So the "same string appears twice → matches the wrong occurrence" failure mode does not apply: nothing is located by content. The only text it inspects is the overhang (the part of the range outside the container), and only to check it is whitespace-only. - Boundary-spanning selections are still rejected. When
commonAncestorContaineris hoisted out of the container, the helper clamps clones to each side (before.setEnd(container,0),after.setStart(container, childNodes.length)) and rejects if(before.toString()+after.toString()).trim()is non-empty. A selection that genuinely runs into a sibling carries that sibling's text in the overhang and is rejected in either direction. Verified by tests inwebsite/src/test/selectionContainment.test.ts("rejects a selection whose overhang holds a sibling's text") and by the "keeps dismissing…"/"keeps ignoring…" tests inSpecBuilderDocView.test.tsxandUserMessagePasteCov80.test.tsx. - The single-occurrence (contained) path is byte-identical.
if (container.contains(range.commonAncestorContainer)) return rangereturns the original range (identity, asserted withtoBe(range)), so the three consumers keep their prior behavior on the common path:DocView.tsxstill doescontained.getBoundingClientRect(),UserMessage.tsxstill doescontained.cloneContents(),SelectionToolbar.tsxstill measures from the returned range. - The SelectionToolbar guard is an equivalent extraction, not a weakening. I diffed the removed inline block against the helper: identical tiers, identical clamping, identical
.toString().trim()overhang check and O(1)!startInside && !endInsideearly reject.selectionContainment.test.tspins the O(1) tier (cloneRangespy asserted un-called on a foreign selection). - New tests redden on revert. The DocView/UserMessage "raises the pill / still expands the chip when normalized past the pane/bubble" tests exercise the exact branch the fix adds; reverting to the bare
commonAncestorContainercheck makes them fail (pill absent / copy not intercepted). - Whitespace/zero-width handling is safe-by-direction. The overhang uses
String.prototype.trim(), which removes NBSP; a zero-width char is not trimmed and reads as non-empty, so it errs toward rejecting (never a false-accept of foreign content). Empty/short selections are gated upstream (text.length < 3in DocView,sel.isCollapsedin UserMessage). - Signals: all check-runs green at head (cancelled runs superseded by same-named success). All five review bots (GPT 5.6, Opus 4.8, Design, UX, First Principles) show PASS / no-blocking on the current full SHA. No AUTOSDE blocking rule is tripped (no inline
<svg>, no emoji-as-icon, no unlabeled icon button, noonClickdiv/span, nomax-w-[9xx+px], noinnerHTML/eval); the diff is logic + tests + an isolated capture harness + screenshots.
Findings
None blocking. No non-blocking findings worth raising as work.
What I could not verify
- I did not run the tests or the Playwright capture locally; I judged the fix and test coverage by reading source and confirming the assertions bracket the changed branch in both directions.
- I did not exhaustively audit
website/AGENTS.mdfor a spec-update obligation, but this is a bug fix to existing selection behavior (no new feature or documented surface), and no new i18n catalog strings are introduced.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
PR #7875 fixed the chat selection toolbar dismissing a multi-click
(double/triple-click) selection of a message's LAST line. The same predicate
survived at two more surfaces, each carrying the identical reported failure:
website/src/pages/chat/UserMessage.tsx-- multi-click the last line of a userbubble containing a paste chip, then copy: the chip-expansion handler bails and
the clipboard receives the literal
[ Paste #1 - 4 lines ]label that thehandler exists to replace.
website/src/apps/spec-builder/components/DocView.tsx-- triple-click thedocument's last paragraph and the Comment pill never appears, so the review
affordance is unreachable for that paragraph.
Why it matters
Both are silent failures on the last line only, which is the hardest shape for a
user to attribute: copy appears to work and delivers the wrong text, and the
Comment pill's absence reads as "this paragraph is not commentable" rather than
as a bug. The last paragraph of a spec document is disproportionately the one a
reviewer wants to comment on, and a paste chip is most often the last thing in a
message. Left as is, the predicate was also on track to be copied a fourth time.
What changed (motivation -> approach -> change)
Browsers normalize a multi-click selection of a container's first or last block
to a boundary point just OUTSIDE the container: a triple-click paragraph
selection ends "at the start of the next block", and for the last block that
position lives in the container's parent. That hoists
range.commonAncestorContainerabove the container, so a barecontainer.contains(range.commonAncestorContainer)early return rejects aselection whose every character is inside the container. Ancestry is the wrong
question; what the caller needs to know is whether any SELECTED TEXT lies
outside.
Rather than copy #7875's inline block a third time, the predicate moves to
containedSelectionRange(range, container)inwebsite/src/utils/selectionContainment.ts, and all three call sites read fromit. It returns the part of the range lying inside the container -- the original
range untouched on the fast path, a container-clamped clone when an accepted
overhang has to be trimmed,
nullon rejection -- so each caller measures,stringifies, or clones from a range that cannot reach past the surface it owns.
That single return value is what lets one helper serve three different consumers:
the toolbar positions from it,
UserMessageclones the clipboard fragment fromit, and
DocViewtakes the pill's rect from it.Both rejection tiers move with it, in order:
and each listens on
document, so the N-1 non-owning instances must not fallthrough to stringification, which would serialize text growing with transcript
distance on every event (select-all being the worst case). A
boundary-normalized multi-click always keeps at least one endpoint inside its
own container, so this tier never rejects the case the predicate exists for.
require both overhangs to hold no text. A selection genuinely spanning into a
sibling stays rejected in either direction.
SelectionToolbarbehavior is unchanged -- its call site reads the clampedmeasurement range from the helper instead of computing it inline, and its 11
existing tests pass untouched, including the
cloneRangespy that pins the O(1)reject.
The document pane's mechanism is narrower than the issue states
Building the real-Chromium capture turned up a condition the issue does not
mention, and it is worth recording because it decides whether any evidence of
this defect is real. The Comment pill renders only while a selection is held
(
{sel && !note && ...}), and the double-click stage of a triple-click normallysets it -- so the pill's own absolutely-positioned overlay becomes the pane's
last child and ABSORBS the boundary normalization: the end lands in that overlay,
which is still inside the pane, and the pre-fix code works.
The defect needs the pill ABSENT when the third click settles, which is exactly
what happens when the double-click selected a word below
onSelectionSettled's 3-character floor. Reading the live selectioncapture-phase (before the pane's own listener re-renders anything) on the
pre-fix component confirms both halves:
pane.contains(cac)pill)true<div class="absolute z-[5]">inside the paneto)false<p>outside the paneSo the user-facing bug is: triple-clicking a last paragraph while the pointer
happens to sit over a short word (
to,a,is,of) silently producesnothing, while the same gesture over a longer word works. That is why the
committed capture script deliberately targets a two-letter word -- targeting a
long one produces a passing shot against the pre-fix component, i.e. a portrait
rather than a proof.
Tests
happy-dom performs no native multi-click selection, so all new tests model the
boundary-normalized geometry directly, as PR #7875 does.
website/src/test/selectionContainment.test.ts(new, 5 tests) pins thehelper's contract: identity on the fast path (the common case allocates
nothing), end-normalized and start-normalized selections accepted AND clamped
to the container, a sibling-text overhang rejected, and the O(1) tier pinned by
asserting
Range.cloneRangeis never called for a selection holding neitherendpoint. The first and last of these are branches no single consumer
exercises alone.
website/src/test/UserMessagePasteCov80.test.tsx(+2) -- the chip stillexpands when the selection end is normalized past the bubble; a selection
running into a text-bearing neighbour is still ignored.
website/src/test/SpecBuilderDocView.test.tsx(+2) -- the Comment pill appearsfor a selection normalized past the pane; a selection running past the pane
into outside text is still dismissed.
Red-first, verified by reverting only the two component sources to
mainwiththe new tests in place: exactly the two acceptance tests failed
(
AssertionError: expected "vi.fn()" to be called with arguments), while bothnew rejection tests and all 14 pre-existing tests in those files passed. With
the fix: 32/32 across all four affected files, including
SelectToAsk.test.tsxuntouched.
Gates run locally after the rebase onto current
main:tsc -bclean,eslintclean on all seven source files,
i18n:checkexit 0 withI18N_BASE_REFset tothe merge base (0 added literals; this diff adds no user-facing strings),
lint:i18nat baseline,jscpd0 clones, and added lines carry no flaggedinclusive-language vocabulary.
Manual verification
website/scripts/capture-docview-multiclick.mjsdrives real Chromium againstwebsite/capture/docview-multiclick-last-paragraph.html: a genuine triple-click(so the browser makes its own boundary-normalized selection) on a two-letter word
in the last paragraph. It is a live regression proof, not a portrait -- against
the pre-fix component the pill wait fails with
locator.waitFor: Timeout 5000ms exceeded, and against the fix it captures.The
UserMessagehalf has no visual delta: the change is to the clipboardpayload, so it is covered by the tests above rather than a screenshot. Its
geometry is the same class the probe measured on #7875's own toolbar harness --
a container whose last block is followed by a sibling, where the end normalizes
to
<p> @0outside the container (containerContainsCac: false).Screenshots / video
Comment pill raised over a triple-click selection of the document's last
paragraph, which produced nothing before this change:
Light theme
The pill overlapping the line above is its normal placement (
top: sel.y - 34),not an artifact of the harness. The dimmed line below the pane is the harness
standing in for the comment tray: content after the pane is load-bearing, because
without a following block the normalized boundary has nowhere outside to land.
Related Issues
Closes #7891
Pattern harvest
Pattern: DOM containment tested via
range.commonAncestorContainerinstead ofby the selected text, which rejects any boundary-normalized multi-click of a
container's first or last block.
Rule candidate: review-prompt -- flag
contains(range.commonAncestorContainer)in any selection handler. Three independent sites in this repo carried the same
line, and the third was found only because a reviewer went looking after the
first was fixed; a grep-level rule would have surfaced all three at once. With
the predicate now centralized, a new occurrence of that expression under
website/srcis a signal the shared helper was bypassed.A second, subtler lesson from the capture: a screenshot or capture script can
PASS against the unfixed component and still look like proof. Here the fix's own
UI element absorbed the geometry the defect depends on, so the first capture
attempt was green pre-fix. Any capture offered as regression evidence should be
run against the pre-fix component and required to fail.