diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..4880e40b06 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -694,6 +694,167 @@ async function waitForComposerEditor(): Promise { ); } +async function pressComposerKey(key: string): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const keydownEvent = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(keydownEvent); + if (keydownEvent.defaultPrevented) { + await waitForLayout(); + return; + } + + const beforeInputEvent = new InputEvent("beforeinput", { + data: key, + inputType: "insertText", + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(beforeInputEvent); + if (beforeInputEvent.defaultPrevented) { + await waitForLayout(); + return; + } + + if ( + typeof document.execCommand === "function" && + document.execCommand("insertText", false, key) + ) { + await waitForLayout(); + return; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + throw new Error("Unable to resolve composer selection for text input."); + } + const range = selection.getRangeAt(0); + range.deleteContents(); + const textNode = document.createTextNode(key); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + composerEditor.dispatchEvent( + new InputEvent("input", { + data: key, + inputType: "insertText", + bubbles: true, + }), + ); + await waitForLayout(); +} + +async function waitForComposerText(expectedText: string): Promise { + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe( + expectedText, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function setComposerSelectionByTextOffsets(options: { + start: number; + end: number; + direction?: "forward" | "backward"; +}): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const resolvePoint = (targetOffset: number) => { + const traversedRef = { value: 0 }; + + const visitNode = (node: Node): { node: Node; offset: number } | null => { + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length ?? 0; + if (targetOffset <= traversedRef.value + textLength) { + return { + node, + offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)), + }; + } + traversedRef.value += textLength; + return null; + } + + if (node instanceof HTMLBRElement) { + const parent = node.parentNode; + if (!parent) { + return null; + } + const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node); + if (targetOffset <= traversedRef.value) { + return { node: parent, offset: siblingIndex }; + } + if (targetOffset <= traversedRef.value + 1) { + return { node: parent, offset: siblingIndex + 1 }; + } + traversedRef.value += 1; + return null; + } + + if (node instanceof Element || node instanceof DocumentFragment) { + for (const child of node.childNodes) { + const point = visitNode(child); + if (point) { + return point; + } + } + } + + return null; + }; + + return ( + visitNode(composerEditor) ?? { + node: composerEditor, + offset: composerEditor.childNodes.length, + } + ); + }; + + const startPoint = resolvePoint(options.start); + const endPoint = resolvePoint(options.end); + const selection = window.getSelection(); + if (!selection) { + throw new Error("Unable to resolve window selection."); + } + selection.removeAllRanges(); + + if (options.direction === "backward" && "setBaseAndExtent" in selection) { + selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset); + await waitForLayout(); + return; + } + + const range = document.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + selection.addRange(range); + await waitForLayout(); +} + +async function selectAllComposerContent(): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const selection = window.getSelection(); + if (!selection) { + throw new Error("Unable to resolve window selection."); + } + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(composerEditor); + selection.addRange(range); + await waitForLayout(); +} + async function waitForSendButton(): Promise { return waitForElement( () => document.querySelector('button[aria-label="Send message"]'), @@ -1675,6 +1836,155 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-basic" as MessageId, + targetText: "surround basic", + }), + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected"); + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); + await pressComposerKey("("); + await waitForComposerText("(selected)"); + + await pressComposerKey("["); + await waitForComposerText("([selected])"); + } finally { + await mounted.cleanup(); + } + }); + + it("leaves collapsed-caret typing unchanged for surround symbols", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-collapsed" as MessageId, + targetText: "surround collapsed", + }), + }); + + try { + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ + start: "selected".length, + end: "selected".length, + }); + await pressComposerKey("("); + await waitForComposerText("selected("); + } finally { + await mounted.cleanup(); + } + }); + + it("supports symmetric and backward-selection surrounds", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "backward"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-backward" as MessageId, + targetText: "surround backward", + }), + }); + + try { + await waitForComposerText("backward"); + await setComposerSelectionByTextOffsets({ + start: 0, + end: "backward".length, + direction: "backward", + }); + await pressComposerKey("*"); + await waitForComposerText("*backward*"); + } finally { + await mounted.cleanup(); + } + }); + + it("supports option-produced surround symbols like guillemets", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "quoted"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-guillemet" as MessageId, + targetText: "surround guillemet", + }), + }); + + try { + await waitForComposerText("quoted"); + await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); + await pressComposerKey("«"); + await waitForComposerText("«quoted»"); + } finally { + await mounted.cleanup(); + } + }); + + it("surrounds text after a mention using the correct expanded offsets", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-after-mention" as MessageId, + targetText: "surround after mention", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForComposerText("hi @package.json there"); + await setComposerSelectionByTextOffsets({ + start: "hi package.json ".length, + end: "hi package.json there".length, + }); + await pressComposerKey("("); + await waitForComposerText("hi @package.json (there)"); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to normal replacement when the selection includes a mention token", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there "); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-token" as MessageId, + targetText: "surround token", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await selectAllComposerContent(); + await pressComposerKey("("); + await waitForComposerText("("); + } finally { + await mounted.cleanup(); + } + }); + it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..6b56056484 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -26,6 +26,7 @@ import { COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, $getRoot, + HISTORY_PUSH_TAG, DecoratorNode, type ElementNode, type LexicalNode, @@ -58,7 +59,10 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; -import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { + selectionTouchesMentionBoundary, + splitPromptIntoComposerSegments, +} from "~/composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -73,6 +77,21 @@ import { import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; +const SURROUND_SYMBOLS: [string, string][] = [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["'", "'"], + ['"', '"'], + ["“", "”"], + ["`", "`"], + ["<", ">"], + ["«", "»"], + ["*", "*"], + ["_", "_"], +]; +const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); +const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; type SerializedComposerMentionNode = Spread< { @@ -553,6 +572,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { $setSelection(selection); } +function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { + const root = $getRoot(); + const composerLength = $getComposerRootLength(); + const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); + const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); + const anchorRemainingRef = { value: boundedStart }; + const focusRemainingRef = { value: boundedEnd }; + const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const selection = $createRangeSelection(); + selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); + selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); + $setSelection(selection); +} + +function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { + start: number; + end: number; +} | null { + if (!$isRangeSelection(selection)) { + return null; + } + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + }; +} + +function $selectionTouchesInlineToken(selection: ReturnType): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); +} + function $readSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { @@ -878,6 +944,257 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +function ComposerSurroundSelectionPlugin(props: { + terminalContexts: ReadonlyArray; +}) { + const [editor] = useLexicalComposerContext(); + const terminalContextsRef = useRef(props.terminalContexts); + const pendingSurroundSelectionRef = useRef<{ + value: string; + expandedStart: number; + expandedEnd: number; + } | null>(null); + const pendingDeadKeySelectionRef = useRef<{ + value: string; + expandedStart: number; + expandedEnd: number; + } | null>(null); + + useEffect(() => { + terminalContextsRef.current = props.terminalContexts; + }, [props.terminalContexts]); + + const applySurroundInsertion = useCallback( + (inputData: string): boolean => { + const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); + const pendingSurroundSelection = pendingSurroundSelectionRef.current; + if (!surroundCloseSymbol) { + pendingSurroundSelectionRef.current = null; + return false; + } + + let handled = false; + editor.update(() => { + const selectionSnapshot = + pendingSurroundSelection ?? + (() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return null; + } + if ($selectionTouchesInlineToken(selection)) { + return null; + } + const range = getSelectionRangeForExpandedComposerOffsets(selection); + if (!range || range.start === range.end) { + return null; + } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + return null; + } + return { + value, + expandedStart: range.start, + expandedEnd: range.end, + }; + })(); + + if (!selectionSnapshot || !surroundCloseSymbol) { + return; + } + + const selectedText = selectionSnapshot.value.slice( + selectionSnapshot.expandedStart, + selectionSnapshot.expandedEnd, + ); + const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; + $setComposerEditorPrompt(nextValue, terminalContextsRef.current); + const selectionStart = collapseExpandedComposerCursor( + nextValue, + selectionSnapshot.expandedStart, + ); + $setSelectionRangeAtComposerOffsets( + selectionStart + inputData.length, + selectionStart + inputData.length + selectedText.length, + ); + handled = true; + pendingSurroundSelectionRef.current = null; + }); + + return handled; + }, + [editor], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (pendingDeadKeySelectionRef.current) { + if (event.key === "Dead" || event.key === " " || event.code === "Space") { + return; + } + pendingDeadKeySelectionRef.current = null; + } + + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + + const shouldTrackDeadKeyBacktick = + BACKTICK_SURROUND_CLOSE_SYMBOL !== null && event.key === "Dead"; + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + if ($selectionTouchesInlineToken(selection)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const range = getSelectionRangeForExpandedComposerOffsets(selection); + if (!range || range.start === range.end) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const snapshot = { + value, + expandedStart: range.start, + expandedEnd: range.end, + }; + pendingSurroundSelectionRef.current = snapshot; + pendingDeadKeySelectionRef.current = shouldTrackDeadKeyBacktick ? snapshot : null; + }); + }; + + const onBeforeInput = (event: InputEvent) => { + if (pendingDeadKeySelectionRef.current) { + return; + } + + if (typeof event.data !== "string") { + pendingSurroundSelectionRef.current = null; + return; + } + const inputData = event.inputType === "insertText" ? event.data : null; + if (!inputData || inputData.length !== 1) { + pendingSurroundSelectionRef.current = null; + return; + } + if (!applySurroundInsertion(inputData)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { + queueMicrotask(() => { + editor.update( + () => { + const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; + if (!pendingDeadKeySelection) { + return; + } + + const currentValue = $getRoot().getTextContent(); + const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; + if (backtickCloseSymbol === null) { + pendingDeadKeySelectionRef.current = null; + return; + } + + const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; + if (currentValue !== expectedResolvedValue) { + if (options?.finalAttempt) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + } + return; + } + + const selectedText = pendingDeadKeySelection.value.slice( + pendingDeadKeySelection.expandedStart, + pendingDeadKeySelection.expandedEnd, + ); + const replacementStart = collapseExpandedComposerCursor( + currentValue, + pendingDeadKeySelection.expandedStart, + ); + $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); + const replacementSelection = $getSelection(); + if (!$isRangeSelection(replacementSelection)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); + $setSelectionRangeAtComposerOffsets( + replacementStart + 1, + replacementStart + 1 + selectedText.length, + ); + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + }, + { tag: HISTORY_PUSH_TAG }, + ); + }); + }; + + const onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if ( + inputEvent.inputType === "insertText" || + inputEvent.inputType === "insertCompositionText" + ) { + tryApplyDeadKeyBacktickSurround(); + } + }; + + const onCompositionEnd = () => { + tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); + }; + + let activeRootElement: HTMLElement | null = null; + const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { + prevRootElement?.removeEventListener("keydown", onKeyDown); + prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); + prevRootElement?.removeEventListener("input", onInput); + prevRootElement?.removeEventListener("compositionend", onCompositionEnd); + rootElement?.addEventListener("keydown", onKeyDown); + rootElement?.addEventListener("beforeinput", onBeforeInput, true); + rootElement?.addEventListener("input", onInput); + rootElement?.addEventListener("compositionend", onCompositionEnd); + activeRootElement = rootElement; + }); + + return () => { + if (activeRootElement) { + activeRootElement.removeEventListener("keydown", onKeyDown); + activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); + activeRootElement.removeEventListener("input", onInput); + activeRootElement.removeEventListener("compositionend", onCompositionEnd); + } + unregisterRootListener(); + }; + }, [applySurroundInsertion, editor]); + + return null; +} + function ComposerPromptEditorInner({ value, cursor, @@ -1113,6 +1430,7 @@ function ComposerPromptEditorInner({ /> + diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 1f0a07e096..c89f7a59e6 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; +import { + selectionTouchesMentionBoundary, + splitPromptIntoComposerSegments, +} from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; describe("splitPromptIntoComposerSegments", () => { @@ -39,3 +42,35 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); }); + +describe("selectionTouchesMentionBoundary", () => { + it("returns true when selection includes the whitespace after a mention", () => { + expect( + selectionTouchesMentionBoundary( + "hi @package.json there", + "hi @package.json".length, + "hi @package.json there".length, + ), + ).toBe(true); + }); + + it("returns true when selection includes the whitespace before a mention", () => { + expect( + selectionTouchesMentionBoundary( + "hi there @package.json later", + "hi there".length, + "hi there ".length, + ), + ).toBe(true); + }); + + it("returns false when selection starts after the mention boundary whitespace", () => { + expect( + selectionTouchesMentionBoundary( + "hi @package.json there", + "hi @package.json ".length, + "hi @package.json there".length, + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index fa1761480c..1cb967db38 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -19,6 +19,10 @@ export type ComposerPromptSegment = const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +function rangeIncludesIndex(start: number, end: number, index: number): boolean { + return start <= index && index < end; +} + function pushTextSegment(segments: ComposerPromptSegment[], text: string): void { if (!text) return; const last = segments[segments.length - 1]; @@ -64,6 +68,44 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen return segments; } +export function selectionTouchesMentionBoundary( + prompt: string, + start: number, + end: number, +): boolean { + if (!prompt || start >= end) { + return false; + } + + for (const match of prompt.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const matchIndex = match.index ?? 0; + const mentionStart = matchIndex + prefix.length; + const mentionEnd = mentionStart + fullMatch.length - prefix.length; + const beforeMentionIndex = mentionStart - 1; + const afterMentionIndex = mentionEnd; + + if ( + beforeMentionIndex >= 0 && + /\s/.test(prompt[beforeMentionIndex] ?? "") && + rangeIncludesIndex(start, end, beforeMentionIndex) + ) { + return true; + } + + if ( + afterMentionIndex < prompt.length && + /\s/.test(prompt[afterMentionIndex] ?? "") && + rangeIncludesIndex(start, end, afterMentionIndex) + ) { + return true; + } + } + + return false; +} + export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [],