feat(chat): offer the selection toolbar while a reply streams - #8990
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Minimal one-condition fix for a real dead-time problem; snapshot-at-selection semantics verified, scope honestly bounded, failure modes degrade to the prior behavior. [DESIGN-REVIEWED] 494d210 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified against the source: the snapshot mechanism is real ( First-Principles-Verdict: PASS A one-condition deletion at cause level — it removes the suppression itself, every rider is declared, and each follows a counted house convention. What this change shipsIntent: let a reader quote, side-chat, or copy text from a reply while it is still arriving, instead of waiting minutes for the turn to end. This is a FIX (of withheld capability) achieved by deletion.
The core item deletes a gate rather than adding machinery — the "settled-block" design triage proposed is avoided entirely, and the description's mechanism claims (snapshot refs, desktop-only WatchThe description itself flags the one unmeasured path: on touch, [FIRST-PRINCIPLES-REVIEWED] 494d210 |
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: |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of The screenshots are clean and match the blind read. I have everything needed to reconcile: the diff removes one UX-Verdict: CONCERNS Blind read is clean — every toolbar action correctly read and trusted — but selections in the streaming tail behave unpredictably, and the touch path is untested. Watch
Suggestions
[UX-REVIEWED] 494d210 |
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: |
The chat selection toolbar (Quote / Ask in Side Chat / Copy) was gated on `!isStreaming`, so for the minutes a reply takes to arrive the reader could not act on text that was already on screen. Nothing about the three actions needs the turn to be over. SelectionToolbar snapshots the selected text and rect at selection time and its click handler reads those snapshots, never a live range. Quote writes into the composer draft, Ask seeds the isolated side conversation, Copy reaches the clipboard -- none of them sends into the in-flight turn. Measured under real token arrival rather than assumed: settled prose keeps its text in one large node, so a selection there holds and Quote returns byte-identical text after seconds of streaming. The growing tail is rendered by the glow as one text node per character, so a selection there has no stable anchor -- a property of the glow that already ships, not of this gate. The three sibling gates (file chips, turn stats, footer) stay `!isStreaming`: those are end-of-turn summaries with no partial form to show. Refs #7819
bb53fc9 to
494d210
Compare
|
@- |
|
@- |
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review: approved.
Race safety of the streaming toolbar (the load-bearing question) — verified against SelectionToolbar.tsx, not just the description.
- Payload cannot go stale.
handleAction(:453-456) readsselectedTextRef.current/selectionRectRef.current, both written bycheckSelectionat :310 and :313 at selection time. No livewindow.getSelection()is consulted when a button fires, so a mid-stream re-render that destroys the anchoring text nodes cannot change what Quote / Ask / Copy receives, andif (!text) returnguards the empty case. No crash path, no stale send. - Desktop persistence is structural, not lucky.
onSelectionChangeearly-returns on!isTouchDevice()(:424), so per-token DOM churn in the tail never re-runscheckSelection— the toolbar and its snapshot survive the re-render rather than depending on the range holding. - Touch degrades gracefully. There the
selectionchangepath is live, so a collapsed tail range dismisses the toolbar after the 350ms debounce. That loses the affordance, not correctness, and it is documented in the call-site comment and the body rather than left implicit. - Failure paths are null-guarded: a
containedSelectionRangemiss or emptyselectionTextFromboth fall through tosetVisible(false).
Scope is tight. 24 production lines (one gate removed, the rest an explanatory comment) plus 77 test lines. The other 7 files are the repo's own evidence and capture conventions — temp-screenshots/, website/capture/, website/scripts/ all carry hundreds of siblings on main — and the three media files total ~308KB. The third test is the right one to have written: it pins that the sibling !isStreaming gates (file chips, turn stats, footer) stay suppressed, so a future edit cannot quietly drop all four.
On the UX CONCERNS (advisory). Both watch items are real and both are narrow: a tail selection's highlight can vanish while the toolbar stays clickable, and the touch path is untested. Neither is introduced by this gate — the per-character glow nodes already ship for every streaming message — and the suggested echo-the-captured-text fix belongs in SelectionToolbar as its own change, not bolted onto a one-line gate removal. Merging.
Non-blocking follow-up, pre-existing on main and untouched here: the comment above the listener effect says "checkSelection depends only on the stable containerRef, so this effect does not re-run after mount", but checkSelection is useCallback(..., [visible, pos]), so the effect does re-run when selection state changes. Harmless (it re-runs on selection events, not per token) but the comment is wrong and worth correcting separately.
CI: 64 check runs, 57 success / 7 skipped, 0 failures; Design PASS, First Principles PASS, GPT 5.6 and Opus 4.8 no blocking findings.
Problem / Motivation
Selecting text in an assistant reply and acting on it -- Quote, Ask in Side
Chat, Copy -- was unavailable for as long as the reply was still arriving.
A reply can take minutes, and for those minutes the reader could not act on text
that was already on screen: they had to wait for the turn to end, then go back
and find the passage again.
The suppression was one visible condition. At
website/src/pages/chat/AssistantMessage.tsx:424(main
d4c2cbf222cc81d4af96759b6ea468413b99cfe5):SelectionToolbaritself has no streaming awareness at all -- a probe forstreaming|isGenerating|busy|inFlightin that file returns zero, against acontrol of 40 hits for
selection-- so the gate lived entirely at this one callsite. Four of the five production mount points never had it:
ArtifactPanel.tsx:381,435gate on iframe/fullscreen andMarkdownPanel.tsx:1897,1961on fullscreen/editing.Why it matters
Long generations are the case where reading-while-waiting is worth the most, and
they were exactly the case where the affordance was withheld. The reporter's ask
is to use that time: highlight the parts worth following up on, start a side
chat, copy something out -- rather than wait for the turn to complete before
being able to begin.
What changed
Two blockers recorded in earlier triage do not hold, and that is what makes
this small. Both were reasonable readings of the call site; neither survives
reading
SelectionToolbar, so I am stating the measurements rather than leavingthe disagreement implicit.
"Quote/Ask/Copy would act on a stale or empty range." The payload is
snapshotted, not live.
selectedTextRef(SelectionToolbar.tsx:272) is writtenin
checkSelectionat :307-310 andselectionRectRefat :313;handleAction(:453-457) reads
selectedTextRef.currentandselectionRectRef.current. Are-render that destroys the text nodes a range was anchored in cannot change what
an action receives, because it was already copied out. Eager capture at selection
time is precisely the mitigation that triage proposed designing -- the component
already implements it.
"The quote is then sent back into the same in-flight turn, so it is a product
call." Nothing sends.
handleQuote(ChatPage.tsx:6571-6581) callssetInput,setFlyingQuoteandrevealComposer-- it writes a blockquote into the composerdraft, where the reader edits and sends it themselves.
handleAsk(:6591-6606)opens the isolated
/sideconversation and hands the text over via aside-seedevent, deliberately not touching the main context.
copyToClipboardnever leavesthe client. So no action injects into the running turn, and
SelectionToolbar.tsx:530-532states that split in the source.The remaining question was scope, and it is now measured rather than argued.
An earlier revision of this description said the tail behaviour was unverified and
reasoned that a collapse would make
checkSelectionhide the toolbar. Thatreasoning was wrong and the measurements below replace it. What actually
happens, under real token arrival:
across 5 nodes,
[123,1,1,1,1]). A selection there holds -- the toolbar appearsand stays, and Quote returns byte-identical text after 2.6s of continuous
token arrival.
character (measured: 28 chars across 28 nodes, every one length 1), recreated
as tokens land, so a selection there has no stable anchor. That is a property of
the glow that already ships for every streaming message, not something this gate
introduces.
not the text or the toolbar: on desktop
onSelectionChangereturns early fornon-touch (
SelectionToolbar.tsx:424), so nothing re-checks the selection andthe snapshot stays clickable. On touch that listener is live, so a collapse there
would dismiss the toolbar after its 350ms debounce -- untested here, and
worth knowing before relying on it.
So the effective behaviour is close to the "enable selection on settled blocks"
shape triage suggested, but reached without any settled-block machinery: the tail
is simply not a stable thing to select, for reasons upstream of this change.
The change itself is the one condition. The three sibling gates stay
!isStreaming(:426file-change chips,:434turn stats,:465footer): thoseare end-of-turn summaries with no partial form to show, and a pre-existing test
already pins the turn-stats one.
Tests
website/src/test/AssistantMessage.test.tsx, 3 added, 91 pass in the file. Theydrive the real desktop path -- a DOM range plus the
mouseupthe toolbardebounces by 50ms -- rather than the
externalSelectionshortcut, so the gateunder test is the one a reader goes through.
capability itself.
mechanism this PR's reasoning rests on.
later edit cannot drop all four gates and still look correct.
Mutation-verified by hand, and the two mutations redden different sets, which
is what rules out a harness artefact:
!isStreamingon the toolbarTestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Quote"/"Copy"!isStreamingfrom the turn-stats gateexpected document not to contain element, found <divThe scope pin appears in both but on different assertions, so each observable is
covered by its own mutation.
Also run:
eslinton both changed files at--max-warnings 0, clean.i18n:checkwithI18N_BASE_REFfed, all four diff-scoped checks PASS at zerotolerance (they silently skip without a base ref, so the ref was supplied).
tsc -breports 15 errors, all insrc/stories/*.stories.tsxandbyte-identical between this branch and its base in the same environment -- a local
harness lacking the storybook packages, none naming a file in this diff.
Manual verification
Two harnesses, both committed, both against a real browser and the real
AssistantMessage:website/scripts/capture-selection-toolbar-streaming.mjs-- the before/afterstills. Two-sided (
--expect present|absent), so the pair is a measurement: withthe gate restored it asserts that no toolbar appears, on this branch that one
does. Both assertions fired.
website/scripts/probe-selection-toolbar-token-stream.mjs-- drives the scene's?stream=1mode, which appends words on a real timer souseSmoothStreamandMarkdownRenderergenuinely re-parse under a held selection. It printsmeasurements rather than asserting an outcome, and asserts only its own control:
if the word count did not grow between reads, nothing re-parsed and the reading
is declared VOID.
Measured settled case:
words 12 -> 33(grew, so the subtree really re-parsed),toolbar shown on selection
true, toolbar still shown after a 2.6s holdtrue,Quote payload identical to the selection
true. Control: with streaming off aselection holds at full length across every 20ms sample for 300ms; with tokens
arriving the browser highlight is dropped after roughly 100ms while the toolbar
remains usable. Toolbar appearance was 8/8 on real mouseups in both modes.
The token source is irrelevant to the mechanism under question -- what collapses
a range is the DOM being rewritten, not where the bytes came from -- so this
closes the gap without a gateway. What it does not cover is the touch path,
named above.
Recording of a selection held through a real token stream is committed at
temp-screenshots/selection-toolbar-streaming/held-selection-through-token-stream.mp4.Screenshots / video
Same scene, same passage, same selection, animations frozen; the only variable is
the one condition.
Before -- gate in place, selection made mid-stream, no toolbar:
After -- same selection, Quote / Ask in Side Chat / Copy offered while the reply
is still streaming (note the dimmed tail and caret above the toolbar):
A comment below carries the same two stills plus the recording, pinned to the
current head, since these two URLs name the previous one.
Related Issues
Refs #7819
Deliberately not a closing keyword. The capability is delivered and measured for
settled prose, which is the bulk of a long reply; selecting inside the sentence
currently being written remains unreliable because the glow fragments it into
per-character nodes. Whether tail selection is in scope for #7819 is a
maintainer's call, and the per-character rendering is a nameable residue with an
owner outside this diff, so I would rather leave the item open than claim an
outcome it did not produce.
Known collision, declared rather than discovered at merge time: open PR #7336
(
feat/quote-annotation-pill) edits this same line to add asourceprop whilekeeping
!isStreaming, so whichever of the two lands second inherits a one-lineconflict there. It does not overlap this change's intent. #8947 touches neither
file -- it moves Quote/Ask into a shared seam for other hosts, which is a
different axis (which surfaces offer the actions, not when they are offered), and
it preserves the write-into-the-draft shape this PR's reasoning depends on.
Checklist