From ec5be47c1b3f2216b732d3ea35a72486b8038422 Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Sun, 1 Mar 2026 09:45:03 +0300 Subject: [PATCH 1/2] fix(composer): preserve quote focus on iOS devices --- .../app/hooks/useComposerInsert.test.tsx | 46 +++++++++++++++++++ src/features/app/hooks/useComposerInsert.ts | 20 +++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/features/app/hooks/useComposerInsert.test.tsx b/src/features/app/hooks/useComposerInsert.test.tsx index 9e8127a1a..d2697bc6f 100644 --- a/src/features/app/hooks/useComposerInsert.test.tsx +++ b/src/features/app/hooks/useComposerInsert.test.tsx @@ -80,4 +80,50 @@ describe("useComposerInsert", () => { expect(onDraftChange).toHaveBeenCalledWith("Hello world ./src"); }); + + it("focuses textarea immediately and reapplies caret after frame", () => { + const textarea = document.createElement("textarea"); + textarea.value = "Hello"; + textarea.selectionStart = 5; + textarea.selectionEnd = 5; + const textareaRef: RefObject = { current: textarea }; + const onDraftChange = vi.fn(); + + const focusSpy = vi.spyOn(textarea, "focus"); + const setSelectionRangeSpy = vi.spyOn(textarea, "setSelectionRange"); + const rafCallbacks: FrameRequestCallback[] = []; + const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + rafCallbacks.push(callback); + return 1; + }); + + const { result } = renderHook(() => + useComposerInsert({ + isEnabled: true, + draftText: "Hello", + onDraftChange, + textareaRef, + }), + ); + + act(() => { + result.current("./src"); + }); + + expect(onDraftChange).toHaveBeenCalledWith("Hello ./src"); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(rafCallbacks).toHaveLength(1); + + act(() => { + rafCallbacks[0](0); + }); + + expect(focusSpy).toHaveBeenCalledTimes(2); + expect(setSelectionRangeSpy).toHaveBeenLastCalledWith( + "Hello ./src".length, + "Hello ./src".length, + ); + + rafSpy.mockRestore(); + }); }); diff --git a/src/features/app/hooks/useComposerInsert.ts b/src/features/app/hooks/useComposerInsert.ts index 61a801755..f7d0a1ce8 100644 --- a/src/features/app/hooks/useComposerInsert.ts +++ b/src/features/app/hooks/useComposerInsert.ts @@ -37,20 +37,26 @@ export function useComposerInsert({ const prefix = needsSpaceBefore ? " " : ""; const suffix = needsSpaceAfter ? " " : ""; const nextText = `${before}${prefix}${insertText}${suffix}${after}`; - onDraftChange(nextText); - requestAnimationFrame(() => { + const cursor = + before.length + + prefix.length + + insertText.length + + (needsSpaceAfter ? 1 : 0); + const focusComposer = () => { const node = textareaRef.current; if (!node) { return; } - const cursor = - before.length + - prefix.length + - insertText.length + - (needsSpaceAfter ? 1 : 0); node.focus(); node.setSelectionRange(cursor, cursor); node.dispatchEvent(new Event("select", { bubbles: true })); + }; + + // Keep focus transfer in the same user gesture for mobile Safari. + focusComposer(); + onDraftChange(nextText); + requestAnimationFrame(() => { + focusComposer(); }); }, [isEnabled, onDraftChange, textareaRef], From ca24fc76f33e00662be40e7f7b763c0deb48f33d Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Sun, 1 Mar 2026 09:56:03 +0300 Subject: [PATCH 2/2] feat(messages): quote selected text fragments --- .../messages/components/MessageRows.tsx | 52 +++++++++++++++++-- .../messages/components/Messages.test.tsx | 42 +++++++++++++++ src/features/messages/components/Messages.tsx | 5 +- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/features/messages/components/MessageRows.tsx b/src/features/messages/components/MessageRows.tsx index 5fd957311..5d4094362 100644 --- a/src/features/messages/components/MessageRows.tsx +++ b/src/features/messages/components/MessageRows.tsx @@ -56,7 +56,7 @@ type MessageRowProps = MarkdownFileLinkProps & { item: Extract; isCopied: boolean; onCopy: (item: Extract) => void; - onQuote?: (item: Extract) => void; + onQuote?: (item: Extract, selectedText?: string) => void; codeBlockCopyUseModifier?: boolean; }; @@ -370,6 +370,8 @@ export const MessageRow = memo(function MessageRow({ onOpenThreadLink, }: MessageRowProps) { const [lightboxIndex, setLightboxIndex] = useState(null); + const bubbleRef = useRef(null); + const selectionSnapshotRef = useRef(null); const hasText = item.text.trim().length > 0; const imageItems = useMemo(() => { if (!item.images || item.images.length === 0) { @@ -386,9 +388,47 @@ export const MessageRow = memo(function MessageRow({ .filter(Boolean) as MessageImage[]; }, [item.images]); + const getSelectedMessageText = useCallback(() => { + const bubble = bubbleRef.current; + const selection = window.getSelection(); + if (!bubble || !selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + const selectedText = selection.toString().trim(); + if (!selectedText) { + return null; + } + const range = selection.getRangeAt(0); + if (!bubble.contains(range.commonAncestorContainer)) { + return null; + } + + const isWithinMessageControls = (node: Node | null) => { + if (!node) { + return false; + } + const element = node instanceof Element ? node : node.parentElement; + return Boolean(element?.closest(".message-quote-button, .message-copy-button")); + }; + + if (isWithinMessageControls(selection.anchorNode) || isWithinMessageControls(selection.focusNode)) { + return null; + } + return selectedText; + }, []); + + const handleQuote = useCallback(() => { + if (!onQuote) { + return; + } + const selectedText = getSelectedMessageText() ?? selectionSnapshotRef.current ?? undefined; + selectionSnapshotRef.current = null; + onQuote(item, selectedText); + }, [getSelectedMessageText, item, onQuote]); + return (
-
+
{imageItems.length > 0 && ( onQuote(item)} + onMouseDown={() => { + selectionSnapshotRef.current = getSelectedMessageText(); + }} + onTouchStart={() => { + selectionSnapshotRef.current = getSelectedMessageText(); + }} + onClick={handleQuote} aria-label="Quote message" title="Quote message" > diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index efd3990d2..77a4992b6 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -171,6 +171,48 @@ describe("Messages", () => { expect(onQuoteMessage).toHaveBeenCalledWith("> First line\n> Second line\n\n"); }); + it("quotes selected message fragment when text is highlighted", () => { + const onQuoteMessage = vi.fn(); + const items: ConversationItem[] = [ + { + id: "msg-quote-selection-1", + kind: "message", + role: "assistant", + text: "Alpha beta gamma", + }, + ]; + + render( + , + ); + + const textNode = screen.getByText("Alpha beta gamma").firstChild; + if (!(textNode instanceof Text)) { + throw new Error("Expected message text node"); + } + const range = document.createRange(); + range.setStart(textNode, 6); + range.setEnd(textNode, 10); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + const quoteButton = screen.getByRole("button", { name: "Quote message" }); + fireEvent.mouseDown(quoteButton); + fireEvent.click(quoteButton); + + expect(onQuoteMessage).toHaveBeenCalledWith("> beta\n\n"); + selection?.removeAllRanges(); + }); + it("opens linked review thread when clicking thread link", () => { const onOpenThreadLink = vi.fn(); const items: ConversationItem[] = [ diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx index aef2f92b8..451106aa5 100644 --- a/src/features/messages/components/Messages.tsx +++ b/src/features/messages/components/Messages.tsx @@ -274,11 +274,12 @@ export const Messages = memo(function Messages({ ); const handleQuoteMessage = useCallback( - (item: Extract) => { + (item: Extract, selectedText?: string) => { if (!onQuoteMessage) { return; } - const quoteText = toMarkdownQuote(item.text); + const sourceText = selectedText?.trim().length ? selectedText : item.text; + const quoteText = toMarkdownQuote(sourceText); if (!quoteText) { return; }