mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📱 fix: Show Quote Popup for Block Selections and on Touch Devices (#14777)
* 📱 fix: Show Quote Popup for Block Selections and on Touch Devices The "Add to chat" popup never appeared for two whole classes of selection. Block-granularity gestures (triple-click, double-click then word-drag) park the selection's far boundary at the start of the next block. For a message's closing block that boundary sits outside `.message-render` — on the composer wrapper or the following message row — while selecting no text there, so the anchor/focus equality check suppressed the popup. Triple-clicking any earlier paragraph worked, which is what made this look like an edge case. The range is now clamped to the message before the check, and selections that really do carry visible text from another message are still refused. Touch platforms could not reach the feature at all. A long-press, and every drag of the native selection handles, emits no mouse event whatsoever — only `selectionchange` — while the popup was shown exclusively from mouseup, dblclick and keyup. Showing now also hangs off a settle-debounced `selectionchange`, gated so an in-progress mouse drag still cannot flicker it. Accepting was broken independently: the tap is also the gesture that dismisses the selection, unmounting the button before `click` could land, so touch commits on `pointerdown` instead. The desktop mousedown path is deliberately unchanged, since preventDefault on `pointerdown` can suppress the compatibility mousedown that click depends on. Two UX consequences of the same code: scrolling re-anchors the popup rather than dismissing it on the first event (the chat auto-scrolls constantly while streaming, and a mobile URL bar collapsing fires resize), and touch selections place the button below the text, clear of the OS Copy/Share callout, with a 44px tap target. Covered by six e2e tests — three desktop, three on an emulated Pixel 5 with a real touchscreen — each verified to fail against the pre-fix build. * 🩹 fix: Address Review Findings and Repair the Scroll Specs The two failing e2e shards were a defect in the specs, not the component. `scrollMessages` reached for `.scrollbar-gutter-stable` with a document-wide query, but the nav and side panels carry that class too, so it could grab a sidebar list that never scrolls — 0px moved, and only in CI, where the nav renders differently. The scroller is now reached from the message itself, the way `MessageNav` does it. The specs also centre the selection first and nudge by a quarter of the visible height, so the gesture cannot scroll the selection clean out of view and then blame the popup for going with it. Review findings, all in `QuoteButton`: Visibility was tested against the window, but the list scrolls inside a bounded container, so text can sit clipped under the header or the composer while its un-clipped rect is still inside the window — leaving the popup floating over unrelated UI. It is now clipped to the nearest scrollable ancestor. Touch committed on the press, so starting a scroll on the button, or touching it and thinking better of it, still added the quote. The excerpt is captured on the press and committed on the release, and only when that release lands on the button, restoring the cancellation every button is expected to have. Commit on press existed because the tap dismisses the selection before `click` fires; capturing the text up front keeps that safe, and an in-flight press is no longer allowed to unmount its own target. A visible popup also described the previous selection for up to the settle window, so a tap while dragging a native selection handle queued the stale excerpt. It is dropped as soon as a differing selection starts settling. Finally, `viaTouch` survived from the last press into keyboard-driven selections on hybrid devices, which could flip the popup into the touch layout; keydown clears it. The cancel path is covered by a new touch spec, verified to fail against a commit-on-press build. * 🧵 fix: Reconcile Cancelled Presses, Widen Clipping, Steady the Scroll Specs Second review round, with one finding taken on trust and flagged rather than claimed as proven. A cancelled touch press could leave the popup backed by a selection that no longer existed. A press deliberately keeps the button alive through a collapsing selection so the release has a target to be judged against, but a cancel then dropped the press without ever honouring the collapse it had masked, so a later tap could add a dead excerpt. Ending a press without committing now rechecks the live selection and dismisses if it went away. Visibility now intersects every clipping ancestor of the message rather than stopping at the nearest. This one is precautionary, not a proven fix: the review that prompted it describes scroll containers *inside* a message (a wide table, a code block) shadowing the outer chat scroller, but the walk starts from the message element, so those are descendants and were never in the chain. Behaviour is unchanged in the current layout — a spec covering a table-cell selection passes identically with and without it — and it is kept only because intersecting the whole chain stays correct if the list is ever nested inside a further-clipped panel. The comment says exactly this. The scroll specs were the real instability. They now move the selection between two positions that are both on screen instead of nudging by a pixel count: blind nudges kept pushing it under the composer, where the popup correctly hides, and the chat's own auto-scroll made the landing spot unpredictable. They also target the opening paragraph, since the closing one is the last content in the conversation and cannot be carried upward from a list already at maximum scroll. The reply fixture gained a table so a selection inside a nested scroll container is exercised, and a spec covers the cancelled press. 15/15 pass locally. * 🪟 fix: Judge Quote-Popup Visibility From the Selection, on Both Axes Third review round. All three findings held up, and each now has a spec that fails without its fix. Clipping is now measured from the selection rather than from the message, and on both axes. A wide table or a long code line scrolls inside its own container — and `overflow-x: auto` makes the computed `overflow-y` auto, so it clips vertically too — which means scrolling it sideways carries the selected text out of view while the message never moves. Walking up from the message could not see those containers at all, and a vertical-only test could not see that motion. This supersedes the previous round's precautionary widening, which was kept without evidence; the evidence is now a spec that scrolls a table past its own selection. Publishing a settled selection also checks visibility. Nothing is tracked during the 300ms settle interval, so a scroll inside that window never reached the re-anchoring path, and the reading was published off-screen and then clamped into view — stranding the popup over unrelated UI. The cancelled-press spec now reproduces the ordering it describes. Collapsing the selection and cancelling in one synchronous block let the asynchronous `selectionchange` arrive after the press had ended, which is the ordinary path and passes either way; it now waits for delivery in between, so the collapse lands while the press is still masking it. Two other specs needed the same scrutiny: `toBeHidden` is satisfied by an element that does not exist yet, so the settle spec sits out the interval before asserting, and it scrolls just past the container edge rather than to the end of the conversation, because a violent scroll re-renders the messages and drops the selection for unrelated reasons. The reply fixture's table is now wide enough to overflow sideways. 17/17 pass, and each new spec was re-run against a build with its own fix reverted to confirm it fails there.
This commit is contained in:
parent
df6e15a0de
commit
155f71f81a
3 changed files with 1064 additions and 100 deletions
|
|
@ -4,6 +4,7 @@ import { TextQuote } from 'lucide-react';
|
|||
import { useSetRecoilState } from 'recoil';
|
||||
import { mainTextareaId } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
/** Only selections fully inside a rendered chat message get the popup. */
|
||||
|
|
@ -17,13 +18,35 @@ const MAX_QUOTE_COUNT = 10;
|
|||
const POPUP_OFFSET = 8;
|
||||
/** Keep the popup this far (px) from the viewport edges. */
|
||||
const EDGE_MARGIN = 16;
|
||||
/** Quiet period before a mouse-less selection (touch long-press, native handle
|
||||
* drag, keyboard extend) is treated as final. Every change restarts it, so the
|
||||
* popup lands once the selection stops moving instead of chasing a handle. */
|
||||
const SELECTION_SETTLE_MS = 300;
|
||||
|
||||
type Anchor = {
|
||||
/** Viewport-relative bounds of the selection (used to place the button, and
|
||||
* to tell whether it is still visible — hence both axes, since a selection
|
||||
* can also be scrolled sideways out of a wide table or code block). */
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
type SelectionState = {
|
||||
text: string;
|
||||
/** Viewport-relative anchor of the selection (used to place the button). */
|
||||
top: number;
|
||||
bottom: number;
|
||||
centerX: number;
|
||||
anchor: Anchor;
|
||||
/** Touch selections carry an OS callout above them, so the popup goes below. */
|
||||
viaTouch: boolean;
|
||||
};
|
||||
|
||||
type Reading = {
|
||||
text: string;
|
||||
anchor: Anchor;
|
||||
/** Retained so scrolling can re-measure without re-walking the selection. */
|
||||
range: Range;
|
||||
/** Everything that clips the selection while the page scrolls. */
|
||||
clippers: HTMLElement[];
|
||||
};
|
||||
|
||||
const resolveMessageElement = (node: Node | null): HTMLElement | null => {
|
||||
|
|
@ -31,18 +54,108 @@ const resolveMessageElement = (node: Node | null): HTMLElement | null => {
|
|||
return (element?.closest(MESSAGE_SELECTOR) as HTMLElement | null) ?? null;
|
||||
};
|
||||
|
||||
const readSelection = (): SelectionState | null => {
|
||||
const anchorFromRect = (rect: DOMRect): Anchor | null => {
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
return null;
|
||||
}
|
||||
return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
|
||||
};
|
||||
|
||||
const anchorCenterX = (anchor: Anchor): number => (anchor.left + anchor.right) / 2;
|
||||
|
||||
const sameAnchor = (a: Anchor, b: Anchor): boolean =>
|
||||
a.top === b.top && a.bottom === b.bottom && a.left === b.left && a.right === b.right;
|
||||
|
||||
const stripWhitespace = (value: string): string => value.replace(/\s+/g, '');
|
||||
|
||||
const CLIPPING_OVERFLOWS = new Set(['auto', 'scroll', 'hidden']);
|
||||
|
||||
/**
|
||||
* Every ancestor that clips the element, innermost first, so visibility can be
|
||||
* judged against all of them at once.
|
||||
*
|
||||
* Walking from the *selection* rather than from the message matters: a wide
|
||||
* table or a long code line scrolls inside its own container (and `overflow-x:
|
||||
* auto` makes the computed `overflow-y` auto too, so it is a clipper on both
|
||||
* axes), and scrolling that container sideways carries the selected text out of
|
||||
* view while the message itself has not moved at all.
|
||||
*/
|
||||
const findClippingAncestors = (element: HTMLElement): HTMLElement[] => {
|
||||
const clippers: HTMLElement[] = [];
|
||||
let current = element.parentElement;
|
||||
while (current && current !== document.body) {
|
||||
const { overflowX, overflowY } = getComputedStyle(current);
|
||||
if (CLIPPING_OVERFLOWS.has(overflowX) || CLIPPING_OVERFLOWS.has(overflowY)) {
|
||||
clippers.push(current);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return clippers;
|
||||
};
|
||||
|
||||
const sameRange = (a: Range, b: Range): boolean => {
|
||||
try {
|
||||
return (
|
||||
a.compareBoundaryPoints(Range.START_TO_START, b) === 0 &&
|
||||
a.compareBoundaryPoints(Range.END_TO_END, b) === 0
|
||||
);
|
||||
} catch {
|
||||
/** Detached or cross-document ranges cannot be compared; treat as changed. */
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Block-granularity gestures (triple-click, double-click then drag) park the
|
||||
* selection's far boundary at the start of the *next* block. For the last block
|
||||
* of a message that boundary sits outside `.message-render` — on the composer
|
||||
* wrapper or the following message — even though no text out there is selected,
|
||||
* which used to suppress the popup for any triple-clicked closing paragraph.
|
||||
*
|
||||
* Clamping the range to the message keeps those gestures eligible; comparing
|
||||
* visible text (whitespace-insensitive, since the overhang contributes only
|
||||
* collapsed whitespace) still rejects selections that truly span messages.
|
||||
*/
|
||||
const clampToMessage = (range: Range, message: HTMLElement): Range | null => {
|
||||
const bounds = document.createRange();
|
||||
bounds.selectNodeContents(message);
|
||||
|
||||
const clamped = range.cloneRange();
|
||||
if (clamped.compareBoundaryPoints(Range.START_TO_START, bounds) < 0) {
|
||||
clamped.setStart(bounds.startContainer, bounds.startOffset);
|
||||
}
|
||||
if (clamped.compareBoundaryPoints(Range.END_TO_END, bounds) > 0) {
|
||||
clamped.setEnd(bounds.endContainer, bounds.endOffset);
|
||||
}
|
||||
|
||||
return stripWhitespace(clamped.toString()) === stripWhitespace(range.toString()) ? clamped : null;
|
||||
};
|
||||
|
||||
const readSelection = (): Reading | null => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchorMessage = resolveMessageElement(selection.anchorNode);
|
||||
const focusMessage = resolveMessageElement(selection.focusNode);
|
||||
if (!anchorMessage || anchorMessage !== focusMessage) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const startMessage = resolveMessageElement(range.startContainer);
|
||||
const endMessage = resolveMessageElement(range.endContainer);
|
||||
const message = startMessage ?? endMessage;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let measured = range;
|
||||
if (startMessage !== endMessage) {
|
||||
const clamped = clampToMessage(range, message);
|
||||
if (!clamped) {
|
||||
return null;
|
||||
}
|
||||
measured = clamped;
|
||||
}
|
||||
|
||||
/** `Selection.toString()` reflects rendered text, so it stays the quote source
|
||||
* even when the measured range was clamped to the message. */
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\u00a0/g, ' ')
|
||||
|
|
@ -51,73 +164,268 @@ const readSelection = (): SelectionState | null => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const anchor = anchorFromRect(measured.getBoundingClientRect());
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectionElement =
|
||||
measured.startContainer instanceof Element
|
||||
? (measured.startContainer as HTMLElement)
|
||||
: (measured.startContainer.parentElement ?? message);
|
||||
|
||||
return {
|
||||
text: text.slice(0, MAX_QUOTE_LENGTH),
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
centerX: rect.left + rect.width / 2,
|
||||
anchor,
|
||||
range: measured,
|
||||
clippers: findClippingAncestors(selectionElement),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the selection is still on screen, judged against the window
|
||||
* intersected with every ancestor that clips it, on both axes. Text slipping
|
||||
* under the chat header or sideways out of a table is invisible even though its
|
||||
* un-clipped rect is still inside the window, and checking the window alone
|
||||
* would leave the popup floating over unrelated UI.
|
||||
*/
|
||||
const isAnchorVisible = (anchor: Anchor, clippers: HTMLElement[]): boolean => {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
let left = 0;
|
||||
let right = window.innerWidth;
|
||||
for (let index = 0; index < clippers.length; index++) {
|
||||
const bounds = clippers[index].getBoundingClientRect();
|
||||
top = Math.max(top, bounds.top);
|
||||
bottom = Math.min(bottom, bounds.bottom);
|
||||
left = Math.max(left, bounds.left);
|
||||
right = Math.min(right, bounds.right);
|
||||
if (top > bottom || left > right) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
anchor.bottom >= top && anchor.top <= bottom && anchor.right >= left && anchor.left <= right
|
||||
);
|
||||
};
|
||||
|
||||
/** Place the popup on the preferred side, falling back to the other side and
|
||||
* finally clamping into the viewport. */
|
||||
const resolveTop = (anchor: Anchor, height: number, preferBelow: boolean): number => {
|
||||
const above = anchor.top - POPUP_OFFSET - height;
|
||||
const below = anchor.bottom + POPUP_OFFSET;
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
|
||||
const fits = (value: number) => value >= EDGE_MARGIN && value <= maxTop;
|
||||
|
||||
const [preferred, fallback] = preferBelow ? [below, above] : [above, below];
|
||||
if (fits(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
if (fits(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(Math.max(preferred, EDGE_MARGIN), maxTop);
|
||||
};
|
||||
|
||||
/**
|
||||
* ChatGPT-style floating "Add to chat" button. Watches for text selections
|
||||
* inside chat messages and, on click, appends the selected excerpt to the
|
||||
* conversation's pending-quotes queue so it shows as a removable chip above
|
||||
* the composer and rides along with the next submission.
|
||||
*
|
||||
* Pointer and touch platforms surface selections through different events:
|
||||
* a mouse drag ends in `mouseup`, but a long-press or a drag of the native
|
||||
* selection handles emits no mouse event at all, only `selectionchange`. Both
|
||||
* are handled — mouse paths show immediately, mouse-less ones after the
|
||||
* selection settles — so the popup is reachable on phones as well as desktops.
|
||||
*
|
||||
* Rendered through a portal so the `fixed` positioning stays viewport-relative
|
||||
* regardless of any transformed ancestor in the composer tree. The on-screen
|
||||
* position is computed from the button's measured size (no CSS transform), so it
|
||||
* is clamped accurately to the viewport — flipping below the selection when
|
||||
* there is no room above and keeping its full width within the side margins.
|
||||
* is clamped accurately to the viewport, and it tracks the selection while the
|
||||
* page scrolls rather than dismissing on the first scroll event.
|
||||
*/
|
||||
function QuoteButton({ conversationId }: { conversationId: string }) {
|
||||
const localize = useLocalize();
|
||||
const [selection, setSelection] = useState<SelectionState | null>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const rangeRef = useRef<Range | null>(null);
|
||||
const clippersRef = useRef<HTMLElement[]>([]);
|
||||
/** Excerpt captured when a touch press begins, committed only if that press
|
||||
* completes on the button. */
|
||||
const pressedTextRef = useRef<string | null>(null);
|
||||
/** Set by the listener effect so the press handlers can dismiss the popup. */
|
||||
const hideRef = useRef<() => void>(() => undefined);
|
||||
const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId));
|
||||
|
||||
useEffect(() => {
|
||||
const updateSelection = () => {
|
||||
setSelection(readSelection());
|
||||
/** Recompute placement from scratch for the new selection. */
|
||||
setPos(null);
|
||||
};
|
||||
const clearSelection = () => setSelection(null);
|
||||
/** Hide the popup the instant the selection collapses or empties, including
|
||||
* paths that fire no mouse/key event — e.g. a streaming markdown re-render
|
||||
* replacing the selected text node, which would otherwise leave the button
|
||||
* stranded over a now-collapsed caret. Only hides here; showing stays gated
|
||||
* on mouseup/dblclick/keyup so an in-progress drag never flickers it. */
|
||||
const handleSelectionChange = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
setSelection(null);
|
||||
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let frame = 0;
|
||||
/** Suppresses showing mid-drag, which would flicker the popup across the
|
||||
* growing selection; the closing `mouseup` shows it. */
|
||||
let mouseDragging = false;
|
||||
let viaTouch = false;
|
||||
|
||||
const clearSettleTimer = () => {
|
||||
if (settleTimer !== undefined) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', updateSelection);
|
||||
/** Drop what is on screen, keeping any pending settle intact. */
|
||||
const dropVisible = () => {
|
||||
/** Never yank the button out from under an in-flight touch press — its
|
||||
* release still has to land on a live target to count. */
|
||||
if (pressedTextRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
rangeRef.current = null;
|
||||
clippersRef.current = [];
|
||||
setSelection(null);
|
||||
setPos(null);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearSettleTimer();
|
||||
dropVisible();
|
||||
};
|
||||
hideRef.current = hide;
|
||||
|
||||
const show = (touch = viaTouch) => {
|
||||
clearSettleTimer();
|
||||
const reading = readSelection();
|
||||
/** Also gate on visibility here, not just while re-anchoring: nothing is
|
||||
* tracked during the settle window, so a selection scrolled out of the
|
||||
* chat in those 300ms would otherwise be published off-screen and
|
||||
* clamped into view, stranding the popup over unrelated UI. */
|
||||
if (!reading || !isAnchorVisible(reading.anchor, reading.clippers)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
rangeRef.current = reading.range;
|
||||
clippersRef.current = reading.clippers;
|
||||
/** Reuse the previous state object when nothing moved so a redundant
|
||||
* settle pass costs no render. */
|
||||
setSelection((prev) =>
|
||||
prev &&
|
||||
prev.text === reading.text &&
|
||||
prev.viaTouch === touch &&
|
||||
sameAnchor(prev.anchor, reading.anchor)
|
||||
? prev
|
||||
: { text: reading.text, anchor: reading.anchor, viaTouch: touch },
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
viaTouch = event.pointerType !== 'mouse';
|
||||
mouseDragging = !viaTouch;
|
||||
};
|
||||
|
||||
const endPointer = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
mouseDragging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
mouseDragging = false;
|
||||
show();
|
||||
};
|
||||
|
||||
/** Chromium commits a double-click word selection on `dblclick`, after
|
||||
* `mouseup` has already read a still-collapsed range, so listen here too. */
|
||||
document.addEventListener('dblclick', updateSelection);
|
||||
document.addEventListener('keyup', updateSelection);
|
||||
const handleDoubleClick = () => show();
|
||||
/** Keyboard selections carry no OS callout, so they never prefer below.
|
||||
* Clearing the flag on the way down also stops a settle pass scheduled by
|
||||
* the resulting `selectionchange` from reviving the touch layout on a
|
||||
* hybrid device whose last press happened to be a finger. */
|
||||
const handleKeyDown = () => {
|
||||
viaTouch = false;
|
||||
};
|
||||
const handleKeyUp = () => show(false);
|
||||
|
||||
/**
|
||||
* The only signal touch platforms give: long-press selection and native
|
||||
* handle drags fire no mouse events. Also hides the instant a selection
|
||||
* collapses or empties through paths that fire no input event — e.g. a
|
||||
* streaming markdown re-render replacing the selected text node, which
|
||||
* would otherwise strand the button over a now-collapsed caret.
|
||||
*/
|
||||
const handleSelectionChange = () => {
|
||||
const current = window.getSelection();
|
||||
if (!current || current.rangeCount === 0 || current.isCollapsed) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (mouseDragging) {
|
||||
return;
|
||||
}
|
||||
/** The visible popup still describes the previous selection. Drop it now,
|
||||
* or a tap landing during the settle window — easy to do while dragging a
|
||||
* native selection handle — would queue the stale excerpt. */
|
||||
if (rangeRef.current && !sameRange(rangeRef.current, current.getRangeAt(0))) {
|
||||
dropVisible();
|
||||
}
|
||||
clearSettleTimer();
|
||||
const touch = viaTouch;
|
||||
settleTimer = setTimeout(() => show(touch), SELECTION_SETTLE_MS);
|
||||
};
|
||||
|
||||
/** Follow the selection instead of dismissing on the first scroll: chat
|
||||
* auto-scrolls while streaming, and on mobile the URL bar collapsing fires
|
||||
* resize, both of which used to drop a selection the user just made. */
|
||||
const reanchor = () => {
|
||||
frame = 0;
|
||||
const range = rangeRef.current;
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const anchor = anchorFromRect(range.getBoundingClientRect());
|
||||
if (!anchor || !isAnchorVisible(anchor, clippersRef.current)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
setSelection((prev) =>
|
||||
prev && !sameAnchor(prev.anchor, anchor) ? { ...prev, anchor } : prev,
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleReanchor = () => {
|
||||
if (rangeRef.current === null || frame !== 0) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(reanchor);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('pointerup', endPointer, true);
|
||||
document.addEventListener('pointercancel', endPointer, true);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('dblclick', handleDoubleClick);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
document.addEventListener('scroll', clearSelection, true);
|
||||
window.addEventListener('resize', clearSelection);
|
||||
document.addEventListener('scroll', scheduleReanchor, true);
|
||||
window.addEventListener('resize', scheduleReanchor);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', updateSelection);
|
||||
document.removeEventListener('dblclick', updateSelection);
|
||||
document.removeEventListener('keyup', updateSelection);
|
||||
clearSettleTimer();
|
||||
if (frame !== 0) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
rangeRef.current = null;
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('pointerup', endPointer, true);
|
||||
document.removeEventListener('pointercancel', endPointer, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('dblclick', handleDoubleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
document.removeEventListener('scroll', clearSelection, true);
|
||||
window.removeEventListener('resize', clearSelection);
|
||||
document.removeEventListener('scroll', scheduleReanchor, true);
|
||||
window.removeEventListener('resize', scheduleReanchor);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -129,30 +437,51 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
|
|||
}
|
||||
const { width, height } = buttonRef.current.getBoundingClientRect();
|
||||
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN);
|
||||
const left = Math.min(Math.max(selection.centerX - width / 2, EDGE_MARGIN), maxLeft);
|
||||
const left = Math.min(
|
||||
Math.max(anchorCenterX(selection.anchor) - width / 2, EDGE_MARGIN),
|
||||
maxLeft,
|
||||
);
|
||||
const top = resolveTop(selection.anchor, height, selection.viaTouch);
|
||||
|
||||
const aboveTop = selection.top - POPUP_OFFSET - height;
|
||||
const belowTop = selection.bottom + POPUP_OFFSET;
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
|
||||
const top = aboveTop >= EDGE_MARGIN ? aboveTop : Math.min(belowTop, maxTop);
|
||||
|
||||
setPos({ top: Math.max(top, EDGE_MARGIN), left });
|
||||
setPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
|
||||
}, [selection]);
|
||||
|
||||
const commitQuote = useCallback(
|
||||
(text: string) => {
|
||||
setQuotes((prev) =>
|
||||
prev.includes(text) || prev.length >= MAX_QUOTE_COUNT ? prev : [...prev, text],
|
||||
);
|
||||
rangeRef.current = null;
|
||||
clippersRef.current = [];
|
||||
pressedTextRef.current = null;
|
||||
setSelection(null);
|
||||
setPos(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
document.getElementById(mainTextareaId)?.focus();
|
||||
},
|
||||
[setQuotes],
|
||||
);
|
||||
|
||||
const addQuote = useCallback(() => {
|
||||
if (!selection) {
|
||||
return;
|
||||
if (selection) {
|
||||
commitQuote(selection.text);
|
||||
}
|
||||
setQuotes((prev) =>
|
||||
prev.includes(selection.text) || prev.length >= MAX_QUOTE_COUNT
|
||||
? prev
|
||||
: [...prev, selection.text],
|
||||
);
|
||||
setSelection(null);
|
||||
setPos(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
document.getElementById(mainTextareaId)?.focus();
|
||||
}, [selection, setQuotes]);
|
||||
}, [selection, commitQuote]);
|
||||
|
||||
/**
|
||||
* End a touch press that did not commit. While a press is in flight the
|
||||
* listener effect deliberately ignores a collapsing selection so the button
|
||||
* survives to judge the release — which means a cancel leaves the popup
|
||||
* backed by a range that may no longer be selected. Re-check once the press
|
||||
* is over, and dismiss if the selection went away with it.
|
||||
*/
|
||||
const abandonPress = useCallback(() => {
|
||||
pressedTextRef.current = null;
|
||||
const live = window.getSelection();
|
||||
if (!live || live.rangeCount === 0 || live.isCollapsed) {
|
||||
hideRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!selection) {
|
||||
return null;
|
||||
|
|
@ -162,8 +491,49 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
|
|||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
/** Keep the selection alive while the click lands. */
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/** A tap is also the gesture that dismisses a selection, so `click` can
|
||||
* never be relied on here: the button is already unmounted by the time it
|
||||
* would fire. The excerpt is captured on the press and committed on the
|
||||
* release instead, which keeps a button's normal escape hatches — drag
|
||||
* off the button, or have the gesture stolen by a scroll, and nothing is
|
||||
* added. Pointer capture guarantees the release lands here to be judged. */
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
pressedTextRef.current = selection.text;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/** Capture is an optimisation — without it the release is simply
|
||||
* judged wherever it lands. */
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
return;
|
||||
}
|
||||
const text = pressedTextRef.current;
|
||||
if (text === null) {
|
||||
return;
|
||||
}
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const releasedOnButton =
|
||||
event.clientX >= bounds.left &&
|
||||
event.clientX <= bounds.right &&
|
||||
event.clientY >= bounds.top &&
|
||||
event.clientY <= bounds.bottom;
|
||||
if (!releasedOnButton) {
|
||||
abandonPress();
|
||||
return;
|
||||
}
|
||||
pressedTextRef.current = null;
|
||||
commitQuote(text);
|
||||
}}
|
||||
onPointerCancel={abandonPress}
|
||||
/** Keep the selection alive while a mouse click lands. */
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={addQuote}
|
||||
aria-label={localize('com_ui_add_to_chat')}
|
||||
data-testid="add-to-chat-button"
|
||||
|
|
@ -173,7 +543,11 @@ function QuoteButton({ conversationId }: { conversationId: string }) {
|
|||
/** Hidden until measured so it never flashes at an unclamped position. */
|
||||
visibility: pos == null ? 'hidden' : 'visible',
|
||||
}}
|
||||
className="fixed z-50 inline-flex items-center gap-1.5 rounded-full border border-border-light bg-surface-secondary px-3 py-1.5 text-sm font-medium text-text-primary shadow-lg transition-colors hover:bg-surface-tertiary"
|
||||
className={cn(
|
||||
'fixed z-50 inline-flex items-center gap-1.5 rounded-full border border-border-light bg-surface-secondary text-sm font-medium text-text-primary shadow-lg transition-colors hover:bg-surface-tertiary',
|
||||
/** Comfortable tap target when the selection came from a finger. */
|
||||
selection.viaTouch ? 'min-h-11 px-4 py-2.5' : 'px-3 py-1.5',
|
||||
)}
|
||||
>
|
||||
<TextQuote className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_add_to_chat')}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const ASK_USER_QUESTION_MARKER = 'E2E_ASK_USER_QUESTION:';
|
|||
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
|
||||
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
|
||||
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
|
||||
/** Two prose paragraphs, so a spec can select the message's *closing* block. */
|
||||
const PARAGRAPHS_REPLY_MARKER = 'E2E_PARAGRAPHS_REPLY';
|
||||
const MERMAID_ARTIFACT_REPLY_MARKER = 'E2E_MERMAID_ARTIFACT_REPLY';
|
||||
const LARGE_MERMAID_ARTIFACT_REPLY_MARKER = 'E2E_LARGE_MERMAID_ARTIFACT_REPLY';
|
||||
const HTML_ARTIFACT_REPLY_MARKER = 'E2E_HTML_ARTIFACT_REPLY';
|
||||
|
|
@ -432,6 +434,45 @@ function replyResponses(text) {
|
|||
};
|
||||
}
|
||||
|
||||
if (text.includes(PARAGRAPHS_REPLY_MARKER)) {
|
||||
/** The quoted cell sits in the first column, so scrolling the table to its
|
||||
* right edge carries it out of view. */
|
||||
const wideColumns = [{ header: 'E2E first column header', cell: 'E2E table cell text' }];
|
||||
for (let index = 1; index < 8; index++) {
|
||||
wideColumns.push({
|
||||
header: `E2E column ${index} with a deliberately wide header`,
|
||||
cell: `E2E filler cell ${index} padding the row out`,
|
||||
});
|
||||
}
|
||||
const filler = [];
|
||||
for (let index = 0; index < 4; index++) {
|
||||
filler.push(
|
||||
`E2E filler paragraph ${index} keeps this reply tall enough to overflow a phone viewport so scrolling is exercised for real.`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return {
|
||||
responses: [
|
||||
[
|
||||
'E2E opening paragraph of the reply, ahead of the closing one.',
|
||||
'',
|
||||
/** Renders inside `.markdown-table-wrapper`, a nested scroll container:
|
||||
* its `overflow-x: auto` also makes the computed `overflow-y` auto, so
|
||||
* a selection here is clipped by the table AND by the message list.
|
||||
* Wide enough to actually overflow sideways, which is what lets a
|
||||
* spec scroll the selected cell out of view without moving the
|
||||
* message at all. */
|
||||
`| ${wideColumns.map((column) => column.header).join(' | ')} |`,
|
||||
`| ${wideColumns.map(() => '---').join(' | ')} |`,
|
||||
`| ${wideColumns.map((column) => column.cell).join(' | ')} |`,
|
||||
'',
|
||||
...filler,
|
||||
'E2E closing paragraph, the last block this message renders.',
|
||||
].join('\n'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const errorName = getMarkerValue(text, FORCED_ERROR_MARKER);
|
||||
if (errorName) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, devices } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
MOCK_ENDPOINTS,
|
||||
|
|
@ -15,49 +15,57 @@ import {
|
|||
* `.message-render` that contains it, then dispatch `mouseup` so the
|
||||
* `QuoteButton` listener fires — the deterministic equivalent of a user
|
||||
* drag-selecting that text to summon the "Add to chat" popup.
|
||||
*
|
||||
* Pass `emitMouseUp: false` to model a touch selection instead: phones deliver
|
||||
* a long-press (and every native handle drag) as a bare `selectionchange` with
|
||||
* no mouse event anywhere in the sequence, which is the whole reason the popup
|
||||
* needs a mouse-less path.
|
||||
*/
|
||||
async function selectMessageText(page: Page, needle: string) {
|
||||
await page.evaluate((text) => {
|
||||
const renders = Array.from(document.querySelectorAll('.message-render'));
|
||||
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
|
||||
if (!host) {
|
||||
throw new Error(`No message contains: ${text}`);
|
||||
}
|
||||
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const value = node.nodeValue ?? '';
|
||||
const index = value.indexOf(text);
|
||||
if (index !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + text.length);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error('Selection API unavailable');
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
return;
|
||||
async function selectMessageText(page: Page, needle: string, emitMouseUp = true) {
|
||||
await page.evaluate(
|
||||
({ text, emitMouseUp: withMouse }) => {
|
||||
const renders = Array.from(document.querySelectorAll('.message-render'));
|
||||
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
|
||||
if (!host) {
|
||||
throw new Error(`No message contains: ${text}`);
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
throw new Error(`No text node contains: ${text}`);
|
||||
}, needle);
|
||||
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const value = node.nodeValue ?? '';
|
||||
const index = value.indexOf(text);
|
||||
if (index !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + text.length);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error('Selection API unavailable');
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
if (withMouse) {
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
throw new Error(`No text node contains: ${text}`);
|
||||
},
|
||||
{ text: needle, emitMouseUp },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click the first word of `needle` inside the most recent message
|
||||
* containing it, using native mouse events at that word's measured coordinates.
|
||||
* Unlike `selectMessageText` (a programmatic Range), this exercises the
|
||||
* browser's own double-click word selection — the path the `dblclick` listener
|
||||
* guards. Measuring the `needle` text node itself (not the first text node in
|
||||
* `.message-render`, which may be a `select-none` screen-reader/model-label
|
||||
* header) keeps the click on the actual reply word, not metadata or whitespace.
|
||||
* Viewport coordinates of the first character of `needle` inside the most
|
||||
* recent message containing it. Measuring the `needle` text node itself (not
|
||||
* the first text node in `.message-render`, which may be a `select-none`
|
||||
* screen-reader/model-label header) keeps the gesture on the actual reply word,
|
||||
* not metadata or whitespace.
|
||||
*/
|
||||
async function doubleClickWord(page: Page, needle: string) {
|
||||
const point = await page.evaluate((text) => {
|
||||
function measureNeedle(page: Page, needle: string) {
|
||||
return page.evaluate((text) => {
|
||||
const renders = Array.from(document.querySelectorAll('.message-render'));
|
||||
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
|
||||
if (!host) {
|
||||
|
|
@ -78,13 +86,166 @@ async function doubleClickWord(page: Page, needle: string) {
|
|||
const r = range.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}, needle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click the first word of `needle` using native mouse events. Unlike
|
||||
* `selectMessageText` (a programmatic Range), this exercises the browser's own
|
||||
* double-click word selection — the path the `dblclick` listener guards.
|
||||
*/
|
||||
async function doubleClickWord(page: Page, needle: string) {
|
||||
const point = await measureNeedle(page, needle);
|
||||
await page.mouse.dblclick(point.x, point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triple-click the block containing `needle` with native mouse events, which
|
||||
* makes Chromium select that whole block and park the selection's far boundary
|
||||
* at the start of the *next* one. For a message's closing block that boundary
|
||||
* lands outside `.message-render`, on the composer wrapper — the case that used
|
||||
* to suppress the popup even though no text outside the message was selected.
|
||||
*/
|
||||
async function tripleClickText(page: Page, needle: string) {
|
||||
const point = await measureNeedle(page, needle);
|
||||
await page.mouse.click(point.x, point.y, { clickCount: 3 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Select from inside one message through into the next, then `mouseup`. This is
|
||||
* a genuine cross-message drag — text outside the message really is selected —
|
||||
* and must never produce a quote, however the boundary clamping treats the
|
||||
* block-overhang cases around it.
|
||||
*/
|
||||
async function selectAcrossMessages(page: Page, fromNeedle: string, toNeedle: string) {
|
||||
await page.evaluate(
|
||||
({ from, to }) => {
|
||||
const findText = (text: string) => {
|
||||
const renders = Array.from(document.querySelectorAll('.message-render'));
|
||||
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
|
||||
if (!host) {
|
||||
throw new Error(`No message contains: ${text}`);
|
||||
}
|
||||
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node && !(node.nodeValue ?? '').includes(text)) {
|
||||
node = walker.nextNode();
|
||||
}
|
||||
if (!node) {
|
||||
throw new Error(`No text node contains: ${text}`);
|
||||
}
|
||||
return { node, index: (node.nodeValue ?? '').indexOf(text) };
|
||||
};
|
||||
|
||||
const start = findText(from);
|
||||
const end = findText(to);
|
||||
const range = document.createRange();
|
||||
range.setStart(start.node, start.index);
|
||||
range.setEnd(end.node, end.index + to.length);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
throw new Error('Selection API unavailable');
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
},
|
||||
{ from: fromNeedle, to: toNeedle },
|
||||
);
|
||||
}
|
||||
|
||||
/** Viewport-relative bottom edge of the live selection. */
|
||||
function selectionBottom(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return selection.getRangeAt(0).getBoundingClientRect().bottom;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the message list so the block containing `needle` sits at `fraction` of
|
||||
* the visible height, returning the signed distance moved.
|
||||
*
|
||||
* Specs move the selection between two *visible* positions rather than nudging
|
||||
* blindly by a pixel count. Blind nudges kept scrolling the selection under the
|
||||
* composer, where the popup correctly hides — real behaviour, but the opposite
|
||||
* of what a "popup follows the scroll" spec means to assert, and the chat's own
|
||||
* auto-scroll made where it landed unpredictable.
|
||||
*
|
||||
* The scroller is reached from the message itself rather than by querying
|
||||
* `.scrollbar-gutter-stable` directly: the nav and side panels carry that class
|
||||
* too, so a document-wide query can return a sidebar list that never scrolls —
|
||||
* which is exactly how these specs passed locally and moved 0px in CI. This
|
||||
* mirrors how the app resolves the same container (`MessageNav.tsx`).
|
||||
*/
|
||||
function scrollSelectionTo(page: Page, needle: string, fraction: number) {
|
||||
return page.evaluate(
|
||||
({ text, at }) => {
|
||||
const renders = Array.from(document.querySelectorAll('.message-render'));
|
||||
const host = [...renders].reverse().find((el) => (el.textContent ?? '').includes(text));
|
||||
if (!host) {
|
||||
throw new Error(`No message contains: ${text}`);
|
||||
}
|
||||
const scroller = host.closest('.scrollbar-gutter-stable');
|
||||
if (!scroller) {
|
||||
throw new Error('Message is not inside a scroll container');
|
||||
}
|
||||
const room = scroller.scrollHeight - scroller.clientHeight;
|
||||
if (room <= 0) {
|
||||
throw new Error(
|
||||
`Message list does not overflow (scrollHeight ${scroller.scrollHeight}, clientHeight ${scroller.clientHeight})`,
|
||||
);
|
||||
}
|
||||
const blocks = Array.from(host.querySelectorAll('p, li, td, th, pre'));
|
||||
const target = blocks.find((el) => (el.textContent ?? '').includes(text)) ?? host;
|
||||
const targetBox = target.getBoundingClientRect();
|
||||
const scrollerBox = scroller.getBoundingClientRect();
|
||||
const wanted = scrollerBox.top + scrollerBox.height * at;
|
||||
const delta = targetBox.top + targetBox.height / 2 - wanted;
|
||||
const start = scroller.scrollTop;
|
||||
scroller.scrollTop = Math.min(room, Math.max(0, start + delta));
|
||||
return scroller.scrollTop - start;
|
||||
},
|
||||
{ text: needle, at: fraction },
|
||||
);
|
||||
}
|
||||
|
||||
const addToChat = (page: Page) => page.getByTestId('add-to-chat-button');
|
||||
const pendingChips = (page: Page) => page.getByTestId('pending-quote-chips');
|
||||
const messageQuotes = (page: Page) => messagesView(page).getByTestId('message-quotes');
|
||||
|
||||
/** A phone's context options, minus `defaultBrowserType` — Playwright refuses
|
||||
* that one inside a describe group because it would force a separate worker,
|
||||
* and this suite already runs on the Chromium it asks for. */
|
||||
const PIXEL_5 = {
|
||||
userAgent: devices['Pixel 5'].userAgent,
|
||||
viewport: devices['Pixel 5'].viewport,
|
||||
deviceScaleFactor: devices['Pixel 5'].deviceScaleFactor,
|
||||
isMobile: devices['Pixel 5'].isMobile,
|
||||
hasTouch: devices['Pixel 5'].hasTouch,
|
||||
};
|
||||
|
||||
/** Prompt whose mock reply renders as several paragraphs, so a spec can act on
|
||||
* the message's *closing* block and can scroll a reply taller than a phone. */
|
||||
const PARAGRAPHS_PROMPT = 'E2E_PARAGRAPHS_REPLY';
|
||||
const OPENING_PARAGRAPH = 'E2E opening paragraph';
|
||||
const CLOSING_PARAGRAPH = 'E2E closing paragraph';
|
||||
/** Sits inside `.markdown-table-wrapper`, a scroll container nested in the message. */
|
||||
const TABLE_CELL = 'E2E table cell text';
|
||||
/** Comfortably longer than the component's 300ms selection-settle interval. */
|
||||
const SETTLE_OBSERVATION_MS = 1500;
|
||||
|
||||
/** Seed a conversation whose latest reply has several paragraphs. */
|
||||
async function seedParagraphReply(page: Page) {
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
const response = await sendMessage(page, PARAGRAPHS_PROMPT);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(messagesView(page).getByText(CLOSING_PARAGRAPH)).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
/** The mock model echoes this when a blockquote containing the token reached the prompt. */
|
||||
const QUOTE_ASSERTION_PASSED = 'E2E quote assertion passed: reply';
|
||||
|
||||
|
|
@ -168,6 +329,138 @@ test.describe('quote references', () => {
|
|||
await expect(pendingChips(page)).toContainText(/E2E|mock|reply/i);
|
||||
});
|
||||
|
||||
test("summons the popup from a triple-click on the reply's closing paragraph", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
// Triple-clicking any *earlier* paragraph always worked, because the
|
||||
// selection's far boundary landed on the next paragraph — still inside the
|
||||
// message. On the closing paragraph that boundary escapes `.message-render`
|
||||
// and used to suppress the popup entirely, even though the user selected
|
||||
// nothing outside the message.
|
||||
await messagesView(page).getByText(CLOSING_PARAGRAPH).scrollIntoViewIfNeeded();
|
||||
await expect(async () => {
|
||||
await tripleClickText(page, CLOSING_PARAGRAPH);
|
||||
const button = addToChat(page);
|
||||
await expect(button).toBeVisible({ timeout: 3000 });
|
||||
await button.click();
|
||||
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// The excerpt is the closing paragraph itself, not the overhang.
|
||||
await expect(pendingChips(page)).toContainText(CLOSING_PARAGRAPH);
|
||||
});
|
||||
|
||||
test('still refuses a selection that really spans two messages', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
// Clamping the block-boundary overhang must not soften this: here visible
|
||||
// text from both the user's message and the reply is selected.
|
||||
await selectAcrossMessages(page, PARAGRAPHS_PROMPT, OPENING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('keeps the popup pinned to the selection while the chat scrolls', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
// A short viewport guarantees the reply overflows and can actually scroll.
|
||||
await page.setViewportSize({ width: 900, height: 500 });
|
||||
await seedParagraphReply(page);
|
||||
|
||||
await expect(async () => {
|
||||
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.75);
|
||||
await selectMessageText(page, OPENING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const before = await addToChat(page).boundingBox();
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Scrolling used to dismiss the popup on the first event, which the chat's
|
||||
// own auto-scroll fires constantly while streaming. It now follows instead.
|
||||
const moved = await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.25);
|
||||
expect(Math.abs(moved)).toBeGreaterThan(0);
|
||||
|
||||
await expect(addToChat(page)).toBeVisible();
|
||||
await expect(async () => {
|
||||
const after = await addToChat(page).boundingBox();
|
||||
expect(after).not.toBeNull();
|
||||
expect(Math.abs(after!.y - before!.y)).toBeGreaterThan(Math.abs(moved) / 2);
|
||||
}).toPass({ timeout: 5000 });
|
||||
|
||||
// Still the right excerpt after travelling with the text.
|
||||
await addToChat(page).click();
|
||||
await expect(pendingChips(page)).toContainText(OPENING_PARAGRAPH);
|
||||
});
|
||||
|
||||
test('hides the popup when a selection inside a table scrolls out of the chat', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await page.setViewportSize({ width: 900, height: 500 });
|
||||
await seedParagraphReply(page);
|
||||
|
||||
// Table cells live in `.markdown-table-wrapper`, a scroll container nested
|
||||
// inside the message. Honouring only the nearest clipper would let the outer
|
||||
// list carry the whole table under the header while the wrapper still
|
||||
// reported the selection visible, stranding the popup over the composer.
|
||||
await expect(async () => {
|
||||
await scrollSelectionTo(page, TABLE_CELL, 0.5);
|
||||
await selectMessageText(page, TABLE_CELL);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const scrolledAway = await page.evaluate(() => {
|
||||
const message = document.querySelector('.message-render');
|
||||
const scroller = message?.closest('.scrollbar-gutter-stable');
|
||||
if (!scroller) {
|
||||
throw new Error('No message scroll container');
|
||||
}
|
||||
const start = scroller.scrollTop;
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
return scroller.scrollTop - start;
|
||||
});
|
||||
expect(Math.abs(scrolledAway)).toBeGreaterThan(0);
|
||||
|
||||
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('hides the popup when a table is scrolled sideways past the selection', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.setViewportSize({ width: 900, height: 500 });
|
||||
await seedParagraphReply(page);
|
||||
|
||||
// A wide table scrolls inside the message, so the selected cell can leave
|
||||
// view without the message moving at all. Judging visibility from the
|
||||
// message's ancestors, or on the vertical axis alone, would miss this
|
||||
// entirely and leave the popup pinned beside a cell that is no longer there.
|
||||
await expect(async () => {
|
||||
await scrollSelectionTo(page, TABLE_CELL, 0.5);
|
||||
await selectMessageText(page, TABLE_CELL);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 3000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const scrolledSideways = await page.evaluate(() => {
|
||||
const wrapper = document.querySelector('.markdown-table-wrapper');
|
||||
if (!wrapper) {
|
||||
throw new Error('No table wrapper');
|
||||
}
|
||||
const room = wrapper.scrollWidth - wrapper.clientWidth;
|
||||
if (room <= 0) {
|
||||
throw new Error(
|
||||
`Table does not overflow sideways (scrollWidth ${wrapper.scrollWidth}, clientWidth ${wrapper.clientWidth})`,
|
||||
);
|
||||
}
|
||||
wrapper.scrollLeft = room;
|
||||
return wrapper.scrollLeft;
|
||||
});
|
||||
expect(scrolledSideways).toBeGreaterThan(0);
|
||||
|
||||
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('hides the popup when the selection collapses without a mouse event', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
|
|
@ -297,3 +590,259 @@ test.describe('quote references', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A phone reaches this feature through a completely different event path than a
|
||||
* desktop: there is no `mouseup` to hang the popup off, and the tap that would
|
||||
* accept it is also the gesture that dismisses the selection. Both halves are
|
||||
* covered here on an emulated Pixel 5 with a real touchscreen.
|
||||
*
|
||||
* Headless Chromium has no Android/iOS long-press-to-select gesture, so each
|
||||
* test presses with the touchscreen (which is what marks the selection as
|
||||
* touch-driven, and is the only pointer event a long-press delivers) and then
|
||||
* places the selection directly. What reaches the component is exactly what a
|
||||
* real phone leaves behind: a selection announced by `selectionchange` alone,
|
||||
* with no mouse event anywhere in the sequence.
|
||||
*/
|
||||
test.describe('quote references on touch devices', () => {
|
||||
test.use(PIXEL_5);
|
||||
|
||||
/** Long-press equivalent: touch the text, then select it without any mouse event. */
|
||||
async function touchSelect(page: Page, needle: string) {
|
||||
await messagesView(page).getByText(needle).scrollIntoViewIfNeeded();
|
||||
const point = await measureNeedle(page, needle);
|
||||
/** Drop any earlier selection *before* the press. Chromium answers a
|
||||
* synthetic tap with compatibility mouse events, and a leftover selection
|
||||
* would let that `mouseup` summon the popup down the desktop path —
|
||||
* passing this spec for a reason no phone ever reproduces. Cleared first,
|
||||
* the press carries only its touch pointer, and the selection that follows
|
||||
* is announced by `selectionchange` alone. */
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.touchscreen.tap(point.x, point.y);
|
||||
await selectMessageText(page, needle, false);
|
||||
}
|
||||
|
||||
test('summons the popup from a mouse-less touch selection and adds it by tap', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
await expect(async () => {
|
||||
await touchSelect(page, CLOSING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// The OS callout (Copy/Share/Look Up) claims the space directly above a
|
||||
// touch selection, so the popup takes the space below it.
|
||||
const popup = await addToChat(page).boundingBox();
|
||||
const bottom = await selectionBottom(page);
|
||||
expect(popup).not.toBeNull();
|
||||
expect(bottom).not.toBeNull();
|
||||
expect(popup!.y).toBeGreaterThanOrEqual(bottom!);
|
||||
|
||||
// Comfortable tap target, not the compact desktop pill.
|
||||
expect(popup!.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
// Tapping has to commit before the tap dismisses the selection out from
|
||||
// under the click — the second reason this was unusable on a phone.
|
||||
await addToChat(page).tap();
|
||||
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
|
||||
await expect(pendingChips(page)).toContainText(CLOSING_PARAGRAPH);
|
||||
});
|
||||
|
||||
test('carries a touch-selected excerpt through to the model', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
|
||||
|
||||
const seeded = await sendMessage(page, 'seed for touch quote');
|
||||
expect(seeded.ok()).toBeTruthy();
|
||||
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await expect(async () => {
|
||||
await touchSelect(page, MOCK_REPLY_TEXT);
|
||||
const button = addToChat(page);
|
||||
await expect(button).toBeVisible({ timeout: 5000 });
|
||||
await button.tap();
|
||||
await expect(pendingChips(page)).toHaveAttribute('data-quote-count', '1');
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// End to end from a finger: the mock model confirms the blockquote arrived.
|
||||
const response = await sendMessage(page, 'E2E_ASSERT_QUOTE:reply');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(messagesView(page).getByText(QUOTE_ASSERTION_PASSED)).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(messageQuotes(page)).toContainText(MOCK_REPLY_TEXT);
|
||||
});
|
||||
|
||||
test('adds nothing when a press on the popup is dragged away and released', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
await expect(async () => {
|
||||
await touchSelect(page, CLOSING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Committing on the press would make this gesture — starting a scroll on
|
||||
// the button, or touching it and thinking better of it — add the quote
|
||||
// anyway. A button has to stay cancellable.
|
||||
const popup = await addToChat(page).boundingBox();
|
||||
expect(popup).not.toBeNull();
|
||||
const centre = { x: popup!.x + popup!.width / 2, y: popup!.y + popup!.height / 2 };
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
const button = document.querySelector('[data-testid="add-to-chat-button"]');
|
||||
if (!button) {
|
||||
throw new Error('Popup is not mounted');
|
||||
}
|
||||
const options = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'touch' };
|
||||
button.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { ...options, clientX: x, clientY: y }),
|
||||
);
|
||||
/** Released far from the button, the way a drag-away cancel ends. */
|
||||
button.dispatchEvent(
|
||||
new PointerEvent('pointerup', { ...options, clientX: x, clientY: y + 400 }),
|
||||
);
|
||||
},
|
||||
{ x: centre.x, y: centre.y },
|
||||
);
|
||||
|
||||
await expect(pendingChips(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismisses the popup when a cancelled press took the selection with it', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
await expect(async () => {
|
||||
await touchSelect(page, CLOSING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
const popup = await addToChat(page).boundingBox();
|
||||
expect(popup).not.toBeNull();
|
||||
|
||||
// A press keeps the button alive through a collapsing selection so the
|
||||
// release has something to land on. When that press is then cancelled, the
|
||||
// collapse it masked still has to be honoured — otherwise the popup lingers
|
||||
// over a selection that no longer exists and a later tap adds a dead quote.
|
||||
//
|
||||
// The three steps are deliberately separate. `selectionchange` is delivered
|
||||
// asynchronously, so collapsing and cancelling in one synchronous block lets
|
||||
// the event arrive *after* the press has already ended — the ordinary path,
|
||||
// which passes with or without the fix. Waiting for delivery in between is
|
||||
// what reproduces a real press: long enough for the collapse to land while
|
||||
// the press is still masking it.
|
||||
const press = { x: popup!.x + popup!.width / 2, y: popup!.y + popup!.height / 2 };
|
||||
await page.evaluate(({ x, y }) => {
|
||||
const button = document.querySelector('[data-testid="add-to-chat-button"]');
|
||||
if (!button) {
|
||||
throw new Error('Popup is not mounted');
|
||||
}
|
||||
button.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}),
|
||||
);
|
||||
}, press);
|
||||
|
||||
const collapseDelivered = await page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 2000);
|
||||
document.addEventListener(
|
||||
'selectionchange',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}),
|
||||
);
|
||||
expect(collapseDelivered, 'the masked collapse must reach the component').toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-testid="add-to-chat-button"]');
|
||||
if (!button) {
|
||||
throw new Error('Popup was dismissed before the press ended');
|
||||
}
|
||||
button.dispatchEvent(
|
||||
new PointerEvent('pointercancel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(addToChat(page)).toBeHidden({ timeout: 5000 });
|
||||
await expect(pendingChips(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('never shows a popup for a selection scrolled away during the settle wait', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
// A mouse-less selection is only published once it has been quiet for the
|
||||
// settle interval, and nothing is tracked until then — so a scroll inside
|
||||
// that window is invisible to the re-anchoring path. Publishing without
|
||||
// re-checking would clamp an off-screen reading into view and strand the
|
||||
// popup over the composer.
|
||||
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.5);
|
||||
await selectMessageText(page, OPENING_PARAGRAPH, false);
|
||||
// Just past the top edge, not all the way to the end of the conversation:
|
||||
// a violent scroll re-renders the messages and drops the selection outright,
|
||||
// which would hide the popup for a reason that has nothing to do with this.
|
||||
const scrolledAway = await scrollSelectionTo(page, OPENING_PARAGRAPH, -0.4);
|
||||
expect(Math.abs(scrolledAway)).toBeGreaterThan(0);
|
||||
|
||||
// The selection has to survive, or this proves nothing.
|
||||
const stillSelected = await page.evaluate(() => {
|
||||
const selection = window.getSelection();
|
||||
return !!selection && selection.rangeCount > 0 && !selection.isCollapsed;
|
||||
});
|
||||
expect(stillSelected, 'the selection must outlive the scroll').toBe(true);
|
||||
|
||||
// Sit out the settle interval before asserting. `toBeHidden` is satisfied by
|
||||
// an element that has not been created *yet*, so checking straight away
|
||||
// would pass before the timer had a chance to publish anything.
|
||||
await page.waitForTimeout(SETTLE_OBSERVATION_MS);
|
||||
await expect(addToChat(page)).toBeHidden();
|
||||
await expect(pendingChips(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('survives the scroll a phone fires while selecting', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await seedParagraphReply(page);
|
||||
|
||||
await expect(async () => {
|
||||
await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.75);
|
||||
await touchSelect(page, OPENING_PARAGRAPH);
|
||||
await expect(addToChat(page)).toBeVisible({ timeout: 5000 });
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Nudging the list (the URL bar collapsing does the same via `resize`) used
|
||||
// to throw the selection away before the user could reach the button.
|
||||
const moved = await scrollSelectionTo(page, OPENING_PARAGRAPH, 0.25);
|
||||
expect(Math.abs(moved)).toBeGreaterThan(0);
|
||||
|
||||
await expect(addToChat(page)).toBeVisible();
|
||||
await addToChat(page).tap();
|
||||
await expect(pendingChips(page)).toContainText(OPENING_PARAGRAPH);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue