Skip to content

feat(chat): offer the selection toolbar while a reply streams - #8990

Merged
bolichen97 merged 1 commit into
mainfrom
feat/selection-toolbar-while-streaming-7819
Sep 6, 2026
Merged

feat(chat): offer the selection toolbar while a reply streams#8990
bolichen97 merged 1 commit into
mainfrom
feat/selection-toolbar-while-streaming-7819

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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):

{!isStreaming && selectionActions.length > 0 && <SelectionToolbar containerRef={contentRef} actions={selectionActions} />}

SelectionToolbar itself has no streaming awareness at all -- a probe for
streaming|isGenerating|busy|inFlight in that file returns zero, against a
control of 40 hits for selection -- so the gate lived entirely at this one call
site. Four of the five production mount points never had it:
ArtifactPanel.tsx:381,435 gate on iframe/fullscreen and
MarkdownPanel.tsx:1897,1961 on 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 leaving
the disagreement implicit.

"Quote/Ask/Copy would act on a stale or empty range." The payload is
snapshotted, not live. selectedTextRef (SelectionToolbar.tsx:272) is written
in checkSelection at :307-310 and selectionRectRef at :313; handleAction
(:453-457) reads selectedTextRef.current and selectionRectRef.current. A
re-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) calls setInput,
setFlyingQuote and revealComposer -- it writes a blockquote into the composer
draft, where the reader edits and sends it themselves. handleAsk (:6591-6606)
opens the isolated /side conversation and hands the text over via a side-seed
event, deliberately not touching the main context. copyToClipboard never leaves
the client. So no action injects into the running turn, and
SelectionToolbar.tsx:530-532 states 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 checkSelection hide the toolbar. That
reasoning was wrong and the measurements below replace it.
What actually
happens, under real token arrival:

  • Settled prose keeps its text in one large text node (measured: 127 chars
    across 5 nodes, [123,1,1,1,1]). A selection there holds -- the toolbar appears
    and stays, and Quote returns byte-identical text after 2.6s of continuous
    token arrival.
  • The still-growing tail is rendered by the streaming glow as one text node per
    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.
  • When the browser does drop such a range, the reader loses the highlight and
    not the text or the toolbar: on desktop onSelectionChange returns early for
    non-touch (SelectionToolbar.tsx:424), so nothing re-checks the selection and
    the 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 (:426 file-change chips, :434 turn stats, :465 footer): those
are 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. They
drive the real desktop path -- a DOM range plus the mouseup the toolbar
debounces by 50ms -- rather than the externalSelection shortcut, so the gate
under test is the one a reader goes through.

  • offers Quote / Ask in Side Chat / Copy on a selection made mid-stream -- the
    capability itself.
  • hands Quote the text that was selected, not a live range -- pins the snapshot
    mechanism this PR's reasoning rests on.
  • leaves the end-of-turn summaries suppressed while streaming -- scope pin, so a
    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:

mutation reddened how it failed
restore !isStreaming on the toolbar the 3 new tests, 88 others pass TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Quote" / "Copy"
drop !isStreaming from the turn-stats gate the scope pin + the pre-existing turn stats footer > hidden while streaming expected document not to contain element, found <div

The scope pin appears in both but on different assertions, so each observable is
covered by its own mutation.

Also run: eslint on both changed files at --max-warnings 0, clean.
i18n:check with I18N_BASE_REF fed, all four diff-scoped checks PASS at zero
tolerance (they silently skip without a base ref, so the ref was supplied).
tsc -b reports 15 errors, all in src/stories/*.stories.tsx and
byte-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/after
    stills. Two-sided (--expect present|absent), so the pair is a measurement: with
    the 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=1 mode, which appends words on a real timer so useSmoothStream and
    MarkdownRenderer genuinely re-parse under a held selection. It prints
    measurements 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 hold true,
Quote payload identical to the selection true. Control: with streaming off a
selection 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:

no toolbar while streaming

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):

toolbar while streaming

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 a source prop while
keeping !isStreaming, so whichever of the two lands second inherits a one-line
conflict 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

  • At most two commits, with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) -- N/A, no documented behaviour changes
  • No secrets, credentials, or internal references in the diff

@chenmingwei23
chenmingwei23 requested a review from a team September 6, 2026 10:30
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 6, 2026 10:30
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 494d21050b01de0c45867c9726006c0bda5fcb3b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 494d21050b01de0c45867c9726006c0bda5fcb3b — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified against the source: the snapshot mechanism is real (selectedTextRef written in checkSelection at SelectionToolbar.tsx:310, read in handleAction at :453-456), the five production mounts check out with only AssistantMessage.tsx ever carrying the streaming gate, and every rider (capture scene, scripts, committed screenshots/video) follows a massive existing repo convention. Final review:

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 ships

Intent: 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.

  1. Selection toolbar now appears on a selection made mid-stream — justified
  2. End-of-turn summaries (file chips, turn stats, footer) stay hidden while streaming — justified scope pin
  3. Three tests driving the real mouseup path, one pinning the sibling gates — justified
  4. New capture scene (website/capture/selection-toolbar-streaming.{html,tsx}) — declared, matches 219-file convention
  5. Two Playwright verification scripts — declared, matches 452-file convention in website/scripts/
  6. Two PNGs + one MP4 under temp-screenshots/ — declared, matches 1053-file convention (incl. existing mp4s)

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 selectionchange early return, 4-of-5 mounts never gated) all verify against SelectionToolbar.tsx and the mount sites I counted (5 production mounts: AssistantMessage.tsx:447, ArtifactPanel.tsx:381,435, MarkdownPanel.tsx:1897,1961). No new prop, flag, config key, or exported symbol — zero permanent public surface added.

Watch

The description itself flags the one unmeasured path: on touch, selectionchange is live, so a tail-collapse dismisses the toolbar after the 350ms debounce — "untested here". Degradation is dismissal (the pre-change behavior), not corruption, so it does not gate this change; the correctness lane owns whether it needs a test.

[FIRST-PRINCIPLES-REVIEWED] 494d210

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 494d21050b01de0c45867c9726006c0bda5fcb3b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 494d210

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 494d21050b01de0c45867c9726006c0bda5fcb3b: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

UX-level review of 494d21050b01de0c45867c9726006c0bda5fcb3b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The screenshots are clean and match the blind read. I have everything needed to reconcile: the diff removes one !isStreaming gate so the existing Quote / Ask in Side Chat / Copy toolbar mounts mid-stream; the blind reader correctly identified all three actions and their outcomes and would use them; both states (toolbar mid-stream, before-gate) are in committed screenshots, and a recording of the held selection is committed.

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

  • Tail selections have no stable anchor (per-character glow nodes, per the PR's own measurements): on desktop the purple highlight silently vanishes ~100ms after selection while the toolbar stays up, so Quote acts on a snapshot the user can no longer see — they can't verify what they're about to quote. Narrow (only the sentence being written) but confusing every time it happens; smallest fix: dismiss the toolbar when the snapshot's highlight is gone, or echo the captured text in it.
  • Same root cause on touch is unverified: the live selectionchange path would dismiss the toolbar after its 350ms debounce when the tail re-renders — the author flags this as untested. A mobile user selecting mid-stream may watch the toolbar flicker away. Verify the touch path (or note it as a known limit) before relying on it.

Suggestions

  • In SelectionToolbar, render a truncated echo of selectedTextRef.current when the browser selection has collapsed, so Quote's payload is visible after the highlight drops.

[UX-REVIEWED] 494d210

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 494d21050b01de0c45867c9726006c0bda5fcb3b — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 494d210

Verdict parsed from the review's SHA-scoped output markers for commit 494d21050b01de0c45867c9726006c0bda5fcb3b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 494d21050b01de0c45867c9726006c0bda5fcb3b: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 6, 2026
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
@chenmingwei23
chenmingwei23 force-pushed the feat/selection-toolbar-while-streaming-7819 branch from bb53fc9 to 494d210 Compare September 6, 2026 11:07
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 6, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

@-

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 6, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

@-

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tech Lead review: approved.

Race safety of the streaming toolbar (the load-bearing question) — verified against SelectionToolbar.tsx, not just the description.

  1. Payload cannot go stale. handleAction (:453-456) reads selectedTextRef.current / selectionRectRef.current, both written by checkSelection at :310 and :313 at selection time. No live window.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, and if (!text) return guards the empty case. No crash path, no stale send.
  2. Desktop persistence is structural, not lucky. onSelectionChange early-returns on !isTouchDevice() (:424), so per-token DOM churn in the tail never re-runs checkSelection — the toolbar and its snapshot survive the re-render rather than depending on the range holding.
  3. Touch degrades gracefully. There the selectionchange path 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.
  4. Failure paths are null-guarded: a containedSelectionRange miss or empty selectionTextFrom both fall through to setVisible(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.

@bolichen97
bolichen97 merged commit 46037bd into main Sep 6, 2026
65 checks passed
@bolichen97
bolichen97 deleted the feat/selection-toolbar-while-streaming-7819 branch September 6, 2026 15:25
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants