Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/web/src/lib/selectionActions.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { fakeTimers } from "../../test/render";
import { observeSelectionActions } from "./selectionActions";

describe("observeSelectionActions", () => {
it("reports a touch long-press selection that cancels the pointer", async () => {
const element = document.createElement("div");
element.textContent = "Select this text on a phone";
document.body.append(element);
const onSelection = vi.fn();
const actions = observeSelectionActions({ element, onSelection, onDismiss: vi.fn() });
document.addEventListener("selectionchange", actions.selectionChanged);
using _cleanup = {
[Symbol.dispose]: () => {
document.removeEventListener("selectionchange", actions.selectionChanged);
actions.dispose();
element.remove();
},
};

const touch = { bubbles: true, isPrimary: true, pointerType: "touch", button: 0 };
element.dispatchEvent(new PointerEvent("pointerdown", touch));
element.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
window.dispatchEvent(new PointerEvent("pointercancel", touch));
window.getSelection()!.selectAllChildren(element);

await vi.waitFor(() => expect(onSelection).toHaveBeenCalledWith(null));
});

it("keeps a dismissed touch selection hidden when the viewport scrolls", () => {
const element = document.createElement("div");
element.textContent = "Select this text on a phone";
document.body.append(element);
const onSelection = vi.fn();
const actions = observeSelectionActions({
element,
restoreAfterScroll: true,
onSelection,
onDismiss: vi.fn(),
});
using _timers = fakeTimers();
using _cleanup = {
[Symbol.dispose]: () => {
actions.dispose();
element.remove();
},
};

const touch = { bubbles: true, isPrimary: true, pointerType: "touch", button: 0 };
element.dispatchEvent(new PointerEvent("pointerdown", touch));
window.dispatchEvent(new PointerEvent("pointercancel", touch));
actions.selectionChanged();
vi.advanceTimersByTime(1_000);
expect(onSelection).toHaveBeenCalledTimes(1);

document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
element.dispatchEvent(new Event("scroll"));
actions.selectionChanged();
vi.advanceTimersByTime(1_000);
expect(onSelection).toHaveBeenCalledTimes(1);
});
});
24 changes: 19 additions & 5 deletions apps/web/src/lib/selectionActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function observeSelectionActions({
let gestureActive = false;
let dismissed = false;
let restorable = false;
let touch = false;
let pointer: SelectionActionPoint | null = null;
let timer: number | null = null;
let frame: number | null = null;
Expand All @@ -76,7 +77,7 @@ export function observeSelectionActions({
};
const cancel = () => {
cancelPending();
pointerDown = gestureActive = false;
pointerDown = gestureActive = touch = false;
pointer = null;
dismissed = true;
restorable = false;
Expand All @@ -86,6 +87,8 @@ export function observeSelectionActions({
onDismiss("cancel");
};
const onContextMenu = () => {
// A touch long-press is how text gets selected, not a context menu.
if (touch) return;
cancel();
onDismiss("interaction");
};
Expand All @@ -102,6 +105,7 @@ export function observeSelectionActions({
};
const onPointerDown = (event: PointerEvent) => {
if (!event.isPrimary) return;
touch = event.pointerType === "touch";
if (isActionTarget(event.target)) {
cancel();
return;
Expand All @@ -123,6 +127,11 @@ export function observeSelectionActions({
gestureActive = event.button === 0 && !event.defaultPrevented;
dismissed = !gestureActive;
};
const onPointerCancel = (event: PointerEvent) => {
// Touch long-press selection and panning cancel the pointer; selectionchange and scroll own those.
if (event.pointerType !== "touch") return dismiss();
pointerDown = gestureActive = false;
};
const onPointerUp = (event: PointerEvent) => {
// Preventing pointerdown (for example on a toolbar) can suppress mouseup.
if (event.isPrimary && event.button === 0) pointerDown = false;
Expand Down Expand Up @@ -155,7 +164,8 @@ export function observeSelectionActions({
// Autoscroll during a drag must not cancel that gesture's eventual release.
if (!pointerDown) {
pointer = null;
if (restoreAfterScroll && !dismissed && restorable) schedule(SELECTION_SCROLL_SETTLE_MS);
if (restoreAfterScroll && (touch || (!dismissed && restorable)))
schedule(SELECTION_SCROLL_SETTLE_MS);
Comment thread
Fryuni marked this conversation as resolved.
else dismissed = true;
}
onDismiss("cancel");
Expand All @@ -165,7 +175,7 @@ export function observeSelectionActions({
element.addEventListener("pointerdown", onSelectionStart);
view.addEventListener("pointerup", onPointerUp);
view.addEventListener("mouseup", onMouseUp);
view.addEventListener("pointercancel", dismiss);
view.addEventListener("pointercancel", onPointerCancel);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("focusin", onFocusIn);
element.addEventListener("contextmenu", onContextMenu, true);
Expand All @@ -180,7 +190,11 @@ export function observeSelectionActions({
cancel,
selectionChanged() {
if (isActionTarget(document.activeElement)) return;
if (pointerDown || gestureActive) {
// Touch selection handles dispatch no pointer events, so wait for the selection to settle.
if (touch) {
onDismiss("cancel");
schedule(SELECTION_SCROLL_SETTLE_MS);
} else if (pointerDown || gestureActive) {
onDismiss("cancel");
} else if (!dismissed && timer === null && frame === null) {
schedule(0);
Expand All @@ -192,7 +206,7 @@ export function observeSelectionActions({
element.removeEventListener("pointerdown", onSelectionStart);
view.removeEventListener("pointerup", onPointerUp);
view.removeEventListener("mouseup", onMouseUp);
view.removeEventListener("pointercancel", dismiss);
view.removeEventListener("pointercancel", onPointerCancel);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("focusin", onFocusIn);
element.removeEventListener("contextmenu", onContextMenu, true);
Expand Down
21 changes: 21 additions & 0 deletions docs/adr/0026-show-cite-for-touch-selections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# ADR 0026: Show Cite for touch text selections

- Status: accepted
- Date: 2026-10-26
- Compared with upstream: `pingdotgg/t3code` at `6ffde5ba3` (2026-10-26)

Selecting assistant text in a mobile browser (seen on Chrome for Android) never
showed the **Cite** button. A touch selection starts with a long-press. That
long-press fires `contextmenu` and `pointercancel`, and both dismissed the
selection action. The handles that adjust the selection afterwards fire no
pointer events, so the `mouseup` that shows the action on desktop never came.

[`observeSelectionActions`](../../apps/web/src/lib/selectionActions.ts) now
remembers whether the last primary pointer was touch. For touch it ignores
`contextmenu`, does not dismiss on `pointercancel`, and shows the action once
`selectionchange` or scrolling has settled for 150 ms. An explicit dismissal,
such as Escape, still holds until the next press. Mouse and pen behaviour is
unchanged.

When updating from upstream, keep the touch path if upstream's observer still
waits for `mouseup`. Remove this if upstream handles touch selections itself.
Loading