Skip to content
Open
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
46 changes: 46 additions & 0 deletions src/features/app/hooks/useComposerInsert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTextAreaElement | null> = { 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();
});
});
20 changes: 13 additions & 7 deletions src/features/app/hooks/useComposerInsert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
52 changes: 49 additions & 3 deletions src/features/messages/components/MessageRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type MessageRowProps = MarkdownFileLinkProps & {
item: Extract<ConversationItem, { kind: "message" }>;
isCopied: boolean;
onCopy: (item: Extract<ConversationItem, { kind: "message" }>) => void;
onQuote?: (item: Extract<ConversationItem, { kind: "message" }>) => void;
onQuote?: (item: Extract<ConversationItem, { kind: "message" }>, selectedText?: string) => void;
codeBlockCopyUseModifier?: boolean;
};

Expand Down Expand Up @@ -370,6 +370,8 @@ export const MessageRow = memo(function MessageRow({
onOpenThreadLink,
}: MessageRowProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const selectionSnapshotRef = useRef<string | null>(null);
const hasText = item.text.trim().length > 0;
const imageItems = useMemo(() => {
if (!item.images || item.images.length === 0) {
Expand All @@ -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 (
<div className={`message ${item.role}`}>
<div className="bubble message-bubble">
<div ref={bubbleRef} className="bubble message-bubble">
{imageItems.length > 0 && (
<MessageImageGrid
images={imageItems}
Expand Down Expand Up @@ -420,7 +460,13 @@ export const MessageRow = memo(function MessageRow({
<button
type="button"
className="ghost message-quote-button"
onClick={() => onQuote(item)}
onMouseDown={() => {
selectionSnapshotRef.current = getSelectedMessageText();
}}
onTouchStart={() => {
selectionSnapshotRef.current = getSelectedMessageText();
Comment on lines +463 to +467

Choose a reason for hiding this comment

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

P2 Badge Avoid overwriting touch selection snapshot on mousedown

On touch browsers that emit compatibility mouse events (notably iOS Safari and many Android WebViews), onTouchStart can capture the highlighted fragment, but the later synthetic onMouseDown on the same button may run after the selection has collapsed and overwrite selectionSnapshotRef with null; handleQuote then quotes the full message instead of the selected fragment. This makes touch-based fragment quoting unreliable specifically in those environments.

Useful? React with 👍 / 👎.

}}
onClick={handleQuote}
aria-label="Quote message"
title="Quote message"
>
Expand Down
42 changes: 42 additions & 0 deletions src/features/messages/components/Messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Messages
items={items}
threadId="thread-1"
workspaceId="ws-1"
isThinking={false}
openTargets={[]}
selectedOpenAppId=""
onQuoteMessage={onQuoteMessage}
/>,
);

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[] = [
Expand Down
5 changes: 3 additions & 2 deletions src/features/messages/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,12 @@ export const Messages = memo(function Messages({
);

const handleQuoteMessage = useCallback(
(item: Extract<ConversationItem, { kind: "message" }>) => {
(item: Extract<ConversationItem, { kind: "message" }>, 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;
}
Expand Down
Loading