History

feat(web): add surround selection in composer (#1502)

Magnus Buvarp committed 7 months ago

Commit 65d797c1 parent 7c0849f4 files · +888 −17Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.browser.tsx +373 −1
  2. apps/web/src/components/ComposerPromptEditor.tsx +331 −1
  3. apps/web/src/composer-editor-mentions.test.ts +59 −1
  4. apps/web/src/composer-editor-mentions.ts +125 −14
apps/web/src/components/ChatView.browser.tsx+373−1
@@ -915,13 +915,189 @@ async function waitForComposerEditor(): Promise<HTMLElement> { · 914 unmodified lines
915915 );
916916 }
917917
918+async function pressComposerKey(key: string): Promise<void> {
919+ const composerEditor = await waitForComposerEditor();
920+ composerEditor.focus();
921+ const keydownEvent = new KeyboardEvent("keydown", {
922+ key,
923+ bubbles: true,
924+ cancelable: true,
925+ });
926+ composerEditor.dispatchEvent(keydownEvent);
927+ if (keydownEvent.defaultPrevented) {
928+ await waitForLayout();
929+ return;
930+ }
931+
932+ const beforeInputEvent = new InputEvent("beforeinput", {
933+ data: key,
934+ inputType: "insertText",
935+ bubbles: true,
936+ cancelable: true,
937+ });
938+ composerEditor.dispatchEvent(beforeInputEvent);
939+ if (beforeInputEvent.defaultPrevented) {
940+ await waitForLayout();
941+ return;
942+ }
943+
944+ if (
945+ typeof document.execCommand === "function" &&
946+ document.execCommand("insertText", false, key)
947+ ) {
948+ await waitForLayout();
949+ return;
950+ }
951+
952+ const selection = window.getSelection();
953+ if (!selection || selection.rangeCount === 0) {
954+ throw new Error("Unable to resolve composer selection for text input.");
955+ }
956+ const range = selection.getRangeAt(0);
957+ range.deleteContents();
958+ const textNode = document.createTextNode(key);
959+ range.insertNode(textNode);
960+ range.setStartAfter(textNode);
961+ range.collapse(true);
962+ selection.removeAllRanges();
963+ selection.addRange(range);
964+ composerEditor.dispatchEvent(
965+ new InputEvent("input", {
966+ data: key,
967+ inputType: "insertText",
968+ bubbles: true,
969+ }),
970+ );
971+ await waitForLayout();
972+}
973+
974+async function pressComposerUndo(): Promise<void> {
975+ const composerEditor = await waitForComposerEditor();
976+ const useMetaForMod = isMacPlatform(navigator.platform);
977+ composerEditor.focus();
978+ composerEditor.dispatchEvent(
979+ new KeyboardEvent("keydown", {
980+ key: "z",
981+ metaKey: useMetaForMod,
982+ ctrlKey: !useMetaForMod,
983+ bubbles: true,
984+ cancelable: true,
985+ }),
986+ );
987+ await waitForLayout();
988+}
989+
990+async function waitForComposerText(expectedText: string): Promise<void> {
991+ await vi.waitFor(
992+ () => {
993+ expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(
994+ expectedText,
995+ );
996+ },
997+ { timeout: 8_000, interval: 16 },
998+ );
999+}
1000+
1001+async function setComposerSelectionByTextOffsets(options: {
1002+ start: number;
1003+ end: number;
1004+ direction?: "forward" | "backward";
1005+}): Promise<void> {
1006+ const composerEditor = await waitForComposerEditor();
1007+ composerEditor.focus();
1008+ const resolvePoint = (targetOffset: number) => {
1009+ const traversedRef = { value: 0 };
1010+
1011+ const visitNode = (node: Node): { node: Node; offset: number } | null => {
1012+ if (node.nodeType === Node.TEXT_NODE) {
1013+ const textLength = node.textContent?.length ?? 0;
1014+ if (targetOffset <= traversedRef.value + textLength) {
1015+ return {
1016+ node,
1017+ offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)),
1018+ };
1019+ }
1020+ traversedRef.value += textLength;
1021+ return null;
1022+ }
1023+
1024+ if (node instanceof HTMLBRElement) {
1025+ const parent = node.parentNode;
1026+ if (!parent) {
1027+ return null;
1028+ }
1029+ const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node);
1030+ if (targetOffset <= traversedRef.value) {
1031+ return { node: parent, offset: siblingIndex };
1032+ }
1033+ if (targetOffset <= traversedRef.value + 1) {
1034+ return { node: parent, offset: siblingIndex + 1 };
1035+ }
1036+ traversedRef.value += 1;
1037+ return null;
1038+ }
1039+
1040+ if (node instanceof Element || node instanceof DocumentFragment) {
1041+ for (const child of node.childNodes) {
1042+ const point = visitNode(child);
1043+ if (point) {
1044+ return point;
1045+ }
1046+ }
1047+ }
1048+
1049+ return null;
1050+ };
1051+
1052+ return (
1053+ visitNode(composerEditor) ?? {
1054+ node: composerEditor,
1055+ offset: composerEditor.childNodes.length,
1056+ }
1057+ );
1058+ };
1059+
1060+ const startPoint = resolvePoint(options.start);
1061+ const endPoint = resolvePoint(options.end);
1062+ const selection = window.getSelection();
1063+ if (!selection) {
1064+ throw new Error("Unable to resolve window selection.");
1065+ }
1066+ selection.removeAllRanges();
1067+
1068+ if (options.direction === "backward" && "setBaseAndExtent" in selection) {
1069+ selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset);
1070+ await waitForLayout();
1071+ return;
1072+ }
1073+
1074+ const range = document.createRange();
1075+ range.setStart(startPoint.node, startPoint.offset);
1076+ range.setEnd(endPoint.node, endPoint.offset);
1077+ selection.addRange(range);
1078+ await waitForLayout();
1079+}
1080+
1081+async function selectAllComposerContent(): Promise<void> {
1082+ const composerEditor = await waitForComposerEditor();
1083+ composerEditor.focus();
1084+ const selection = window.getSelection();
1085+ if (!selection) {
1086+ throw new Error("Unable to resolve window selection.");
1087+ }
1088+ selection.removeAllRanges();
1089+ const range = document.createRange();
1090+ range.selectNodeContents(composerEditor);
1091+ selection.addRange(range);
1092+ await waitForLayout();
1093+}
1094+
9181095 async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> {
9191096 return waitForElement(
9201097 () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`),
9211098 `Unable to find composer menu item "${itemId}".`,
9221099 );
9231100 }
924−
9251101 async function waitForSendButton(): Promise<HTMLButtonElement> {
9261102 return waitForElement(
9271103 () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
@@ -2336,6 +2512,202 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1408 unmodified lines
23362512 }
23372513 });
23382514
2515+ it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => {
2516+ const mounted = await mountChatView({
2517+ viewport: DEFAULT_VIEWPORT,
2518+ snapshot: createSnapshotForTargetUser({
2519+ targetMessageId: "msg-user-surround-basic" as MessageId,
2520+ targetText: "surround basic",
2521+ }),
2522+ });
2523+
2524+ try {
2525+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected");
2526+ await waitForComposerText("selected");
2527+ await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length });
2528+ await pressComposerKey("(");
2529+ await waitForComposerText("(selected)");
2530+
2531+ await pressComposerKey("[");
2532+ await waitForComposerText("([selected])");
2533+ } finally {
2534+ await mounted.cleanup();
2535+ }
2536+ });
2537+
2538+ it("leaves collapsed-caret typing unchanged for surround symbols", async () => {
2539+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "selected");
2540+
2541+ const mounted = await mountChatView({
2542+ viewport: DEFAULT_VIEWPORT,
2543+ snapshot: createSnapshotForTargetUser({
2544+ targetMessageId: "msg-user-surround-collapsed" as MessageId,
2545+ targetText: "surround collapsed",
2546+ }),
2547+ });
2548+
2549+ try {
2550+ await waitForComposerText("selected");
2551+ await setComposerSelectionByTextOffsets({
2552+ start: "selected".length,
2553+ end: "selected".length,
2554+ });
2555+ await pressComposerKey("(");
2556+ await waitForComposerText("selected(");
2557+ } finally {
2558+ await mounted.cleanup();
2559+ }
2560+ });
2561+
2562+ it("supports symmetric and backward-selection surrounds", async () => {
2563+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "backward");
2564+
2565+ const mounted = await mountChatView({
2566+ viewport: DEFAULT_VIEWPORT,
2567+ snapshot: createSnapshotForTargetUser({
2568+ targetMessageId: "msg-user-surround-backward" as MessageId,
2569+ targetText: "surround backward",
2570+ }),
2571+ });
2572+
2573+ try {
2574+ await waitForComposerText("backward");
2575+ await setComposerSelectionByTextOffsets({
2576+ start: 0,
2577+ end: "backward".length,
2578+ direction: "backward",
2579+ });
2580+ await pressComposerKey("*");
2581+ await waitForComposerText("*backward*");
2582+ } finally {
2583+ await mounted.cleanup();
2584+ }
2585+ });
2586+
2587+ it("supports option-produced surround symbols like guillemets", async () => {
2588+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted");
2589+
2590+ const mounted = await mountChatView({
2591+ viewport: DEFAULT_VIEWPORT,
2592+ snapshot: createSnapshotForTargetUser({
2593+ targetMessageId: "msg-user-surround-guillemet" as MessageId,
2594+ targetText: "surround guillemet",
2595+ }),
2596+ });
2597+
2598+ try {
2599+ await waitForComposerText("quoted");
2600+ await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
2601+ await pressComposerKey("«");
2602+ await waitForComposerText("«quoted»");
2603+ } finally {
2604+ await mounted.cleanup();
2605+ }
2606+ });
2607+
2608+ it("supports dead-key composition that resolves to another surround symbol without an extra undo step", async () => {
2609+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "quoted");
2610+
2611+ const mounted = await mountChatView({
2612+ viewport: DEFAULT_VIEWPORT,
2613+ snapshot: createSnapshotForTargetUser({
2614+ targetMessageId: "msg-user-surround-dead-quote" as MessageId,
2615+ targetText: "surround dead quote",
2616+ }),
2617+ });
2618+
2619+ try {
2620+ await waitForComposerText("quoted");
2621+ await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length });
2622+ const composerEditor = await waitForComposerEditor();
2623+ composerEditor.focus();
2624+ composerEditor.dispatchEvent(
2625+ new KeyboardEvent("keydown", {
2626+ key: "Dead",
2627+ bubbles: true,
2628+ cancelable: true,
2629+ }),
2630+ );
2631+ composerEditor.dispatchEvent(
2632+ new InputEvent("beforeinput", {
2633+ data: "'",
2634+ inputType: "insertCompositionText",
2635+ bubbles: true,
2636+ cancelable: true,
2637+ }),
2638+ );
2639+ const resolvedInputEvent = new InputEvent("beforeinput", {
2640+ data: "'",
2641+ inputType: "insertText",
2642+ bubbles: true,
2643+ cancelable: true,
2644+ });
2645+ composerEditor.dispatchEvent(resolvedInputEvent);
2646+ expect(resolvedInputEvent.defaultPrevented).toBe(true);
2647+ await waitForComposerText("'quoted'");
2648+ await pressComposerUndo();
2649+ await waitForComposerText("quoted");
2650+ } finally {
2651+ await mounted.cleanup();
2652+ }
2653+ });
2654+
2655+ it("surrounds text after a mention using the correct expanded offsets", async () => {
2656+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there");
2657+
2658+ const mounted = await mountChatView({
2659+ viewport: DEFAULT_VIEWPORT,
2660+ snapshot: createSnapshotForTargetUser({
2661+ targetMessageId: "msg-user-surround-after-mention" as MessageId,
2662+ targetText: "surround after mention",
2663+ }),
2664+ });
2665+
2666+ try {
2667+ await vi.waitFor(
2668+ () => {
2669+ expect(document.body.textContent).toContain("package.json");
2670+ },
2671+ { timeout: 8_000, interval: 16 },
2672+ );
2673+ await waitForComposerText("hi @package.json there");
2674+ await setComposerSelectionByTextOffsets({
2675+ start: "hi package.json ".length,
2676+ end: "hi package.json there".length,
2677+ });
2678+ await pressComposerKey("(");
2679+ await waitForComposerText("hi @package.json (there)");
2680+ } finally {
2681+ await mounted.cleanup();
2682+ }
2683+ });
2684+
2685+ it("falls back to normal replacement when the selection includes a mention token", async () => {
2686+ useComposerDraftStore.getState().setPrompt(THREAD_REF, "hi @package.json there ");
2687+
2688+ const mounted = await mountChatView({
2689+ viewport: DEFAULT_VIEWPORT,
2690+ snapshot: createSnapshotForTargetUser({
2691+ targetMessageId: "msg-user-surround-token" as MessageId,
2692+ targetText: "surround token",
2693+ }),
2694+ });
2695+
2696+ try {
2697+ await vi.waitFor(
2698+ () => {
2699+ expect(document.body.textContent).toContain("package.json");
2700+ },
2701+ { timeout: 8_000, interval: 16 },
2702+ );
2703+ await selectAllComposerContent();
2704+ await pressComposerKey("(");
2705+ await waitForComposerText("(");
2706+ } finally {
2707+ await mounted.cleanup();
2708+ }
2709+ });
2710+
23392711 it("shows runtime mode descriptions in the desktop composer access select", async () => {
23402712 setDraftThreadWithoutWorktree();
23412713
apps/web/src/components/ComposerPromptEditor.tsx+331−1
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 COMMAND_PRIORITY_HIGH,
2727 KEY_BACKSPACE_COMMAND,
2828 $getRoot,
29+ HISTORY_MERGE_TAG,
2930 DecoratorNode,
3031 type ElementNode,
3132 type LexicalNode,
@@ -58,7 +59,10 @@ import { · 26 unmodified lines
5859 expandCollapsedComposerCursor,
5960 isCollapsedCursorAdjacentToInlineToken,
6061 } from "~/composer-logic";
61−import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
62+import {
63+ selectionTouchesMentionBoundary,
64+ splitPromptIntoComposerSegments,
65+} from "~/composer-editor-mentions";
6266 import {
6367 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
6468 type TerminalContextDraft,
@@ -73,6 +77,21 @@ import { · 8 unmodified lines
7377 import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
7478
7579 const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
80+const SURROUND_SYMBOLS: [string, string][] = [
81+ ["(", ")"],
82+ ["[", "]"],
83+ ["{", "}"],
84+ ["'", "'"],
85+ ['"', '"'],
86+ ["“", "”"],
87+ ["`", "`"],
88+ ["<", ">"],
89+ ["«", "»"],
90+ ["*", "*"],
91+ ["_", "_"],
92+];
93+const SURROUND_SYMBOLS_MAP = new Map<string, string>(SURROUND_SYMBOLS);
94+const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null;
7695
7796 type SerializedComposerMentionNode = Spread<
7897 {
@@ -553,6 +572,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { · 474 unmodified lines
553572 $setSelection(selection);
554573 }
555574
575+function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void {
576+ const root = $getRoot();
577+ const composerLength = $getComposerRootLength();
578+ const boundedStart = Math.max(0, Math.min(startOffset, composerLength));
579+ const boundedEnd = Math.max(0, Math.min(endOffset, composerLength));
580+ const anchorRemainingRef = { value: boundedStart };
581+ const focusRemainingRef = { value: boundedEnd };
582+ const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? {
583+ key: root.getKey(),
584+ offset: root.getChildren().length,
585+ type: "element" as const,
586+ };
587+ const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? {
588+ key: root.getKey(),
589+ offset: root.getChildren().length,
590+ type: "element" as const,
591+ };
592+ const selection = $createRangeSelection();
593+ selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type);
594+ selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type);
595+ $setSelection(selection);
596+}
597+
598+function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType<typeof $getSelection>): {
599+ start: number;
600+ end: number;
601+} | null {
602+ if (!$isRangeSelection(selection)) {
603+ return null;
604+ }
605+ const anchorNode = selection.anchor.getNode();
606+ const focusNode = selection.focus.getNode();
607+ const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
608+ const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset);
609+ return {
610+ start: Math.min(anchorOffset, focusOffset),
611+ end: Math.max(anchorOffset, focusOffset),
612+ };
613+}
614+
615+function $selectionTouchesInlineToken(selection: ReturnType<typeof $getSelection>): boolean {
616+ if (!$isRangeSelection(selection)) {
617+ return false;
618+ }
619+ return selection.getNodes().some((node) => isComposerInlineTokenNode(node));
620+}
621+
556622 function $readSelectionOffsetFromEditorState(fallback: number): number {
557623 const selection = $getSelection();
558624 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
@@ -878,6 +944,269 @@ function ComposerInlineTokenBackspacePlugin() { · 319 unmodified lines
878944 return null;
879945 }
880946
947+function ComposerSurroundSelectionPlugin(props: {
948+ terminalContexts: ReadonlyArray<TerminalContextDraft>;
949+}) {
950+ const [editor] = useLexicalComposerContext();
951+ const terminalContextsRef = useRef(props.terminalContexts);
952+ const pendingSurroundSelectionRef = useRef<{
953+ value: string;
954+ expandedStart: number;
955+ expandedEnd: number;
956+ } | null>(null);
957+ const pendingDeadKeySelectionRef = useRef<{
958+ value: string;
959+ expandedStart: number;
960+ expandedEnd: number;
961+ } | null>(null);
962+
963+ useEffect(() => {
964+ terminalContextsRef.current = props.terminalContexts;
965+ }, [props.terminalContexts]);
966+
967+ const applySurroundInsertion = useCallback(
968+ (inputData: string): boolean => {
969+ const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData);
970+ const pendingSurroundSelection = pendingSurroundSelectionRef.current;
971+ if (!surroundCloseSymbol) {
972+ pendingSurroundSelectionRef.current = null;
973+ return false;
974+ }
975+
976+ let handled = false;
977+ editor.update(() => {
978+ const selectionSnapshot =
979+ pendingSurroundSelection ??
980+ (() => {
981+ const selection = $getSelection();
982+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
983+ return null;
984+ }
985+ if ($selectionTouchesInlineToken(selection)) {
986+ return null;
987+ }
988+ const range = getSelectionRangeForExpandedComposerOffsets(selection);
989+ if (!range || range.start === range.end) {
990+ return null;
991+ }
992+ const value = $getRoot().getTextContent();
993+ if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
994+ return null;
995+ }
996+ return {
997+ value,
998+ expandedStart: range.start,
999+ expandedEnd: range.end,
1000+ };
1001+ })();
1002+
1003+ if (!selectionSnapshot || !surroundCloseSymbol) {
1004+ return;
1005+ }
1006+
1007+ const selectedText = selectionSnapshot.value.slice(
1008+ selectionSnapshot.expandedStart,
1009+ selectionSnapshot.expandedEnd,
1010+ );
1011+ const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`;
1012+ $setComposerEditorPrompt(nextValue, terminalContextsRef.current);
1013+ const selectionStart = collapseExpandedComposerCursor(
1014+ nextValue,
1015+ selectionSnapshot.expandedStart,
1016+ );
1017+ $setSelectionRangeAtComposerOffsets(
1018+ selectionStart + inputData.length,
1019+ selectionStart + inputData.length + selectedText.length,
1020+ );
1021+ handled = true;
1022+ pendingSurroundSelectionRef.current = null;
1023+ });
1024+
1025+ return handled;
1026+ },
1027+ [editor],
1028+ );
1029+
1030+ useEffect(() => {
1031+ const onKeyDown = (event: KeyboardEvent) => {
1032+ if (pendingDeadKeySelectionRef.current) {
1033+ if (event.key === "Dead" || event.key === " " || event.code === "Space") {
1034+ return;
1035+ }
1036+ pendingDeadKeySelectionRef.current = null;
1037+ }
1038+
1039+ if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) {
1040+ pendingSurroundSelectionRef.current = null;
1041+ pendingDeadKeySelectionRef.current = null;
1042+ return;
1043+ }
1044+
1045+ editor.getEditorState().read(() => {
1046+ const selection = $getSelection();
1047+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
1048+ pendingSurroundSelectionRef.current = null;
1049+ pendingDeadKeySelectionRef.current = null;
1050+ return;
1051+ }
1052+ if ($selectionTouchesInlineToken(selection)) {
1053+ pendingSurroundSelectionRef.current = null;
1054+ pendingDeadKeySelectionRef.current = null;
1055+ return;
1056+ }
1057+ const range = getSelectionRangeForExpandedComposerOffsets(selection);
1058+ if (!range || range.start === range.end) {
1059+ pendingSurroundSelectionRef.current = null;
1060+ pendingDeadKeySelectionRef.current = null;
1061+ return;
1062+ }
1063+ const value = $getRoot().getTextContent();
1064+ if (selectionTouchesMentionBoundary(value, range.start, range.end)) {
1065+ pendingSurroundSelectionRef.current = null;
1066+ pendingDeadKeySelectionRef.current = null;
1067+ return;
1068+ }
1069+ const snapshot = {
1070+ value,
1071+ expandedStart: range.start,
1072+ expandedEnd: range.end,
1073+ };
1074+ pendingSurroundSelectionRef.current = snapshot;
1075+ pendingDeadKeySelectionRef.current = null;
1076+ });
1077+ };
1078+
1079+ const onBeforeInput = (event: InputEvent) => {
1080+ if (
1081+ event.inputType === "insertCompositionText" &&
1082+ event.data === "`" &&
1083+ BACKTICK_SURROUND_CLOSE_SYMBOL !== null &&
1084+ pendingSurroundSelectionRef.current
1085+ ) {
1086+ pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current;
1087+ return;
1088+ }
1089+
1090+ if (pendingDeadKeySelectionRef.current) {
1091+ return;
1092+ }
1093+
1094+ if (event.inputType === "insertCompositionText") {
1095+ return;
1096+ }
1097+
1098+ if (typeof event.data !== "string") {
1099+ pendingSurroundSelectionRef.current = null;
1100+ return;
1101+ }
1102+ const inputData = event.inputType === "insertText" ? event.data : null;
1103+ if (!inputData || inputData.length !== 1) {
1104+ pendingSurroundSelectionRef.current = null;
1105+ return;
1106+ }
1107+ if (!applySurroundInsertion(inputData)) {
1108+ return;
1109+ }
1110+
1111+ event.preventDefault();
1112+ event.stopPropagation();
1113+ event.stopImmediatePropagation();
1114+ };
1115+
1116+ const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => {
1117+ queueMicrotask(() => {
1118+ editor.update(
1119+ () => {
1120+ const pendingDeadKeySelection = pendingDeadKeySelectionRef.current;
1121+ if (!pendingDeadKeySelection) {
1122+ return;
1123+ }
1124+
1125+ const currentValue = $getRoot().getTextContent();
1126+ const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL;
1127+ if (backtickCloseSymbol === null) {
1128+ pendingDeadKeySelectionRef.current = null;
1129+ return;
1130+ }
1131+
1132+ const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`;
1133+ if (currentValue !== expectedResolvedValue) {
1134+ if (options?.finalAttempt) {
1135+ pendingSurroundSelectionRef.current = null;
1136+ pendingDeadKeySelectionRef.current = null;
1137+ }
1138+ return;
1139+ }
1140+
1141+ const selectedText = pendingDeadKeySelection.value.slice(
1142+ pendingDeadKeySelection.expandedStart,
1143+ pendingDeadKeySelection.expandedEnd,
1144+ );
1145+ const replacementStart = collapseExpandedComposerCursor(
1146+ currentValue,
1147+ pendingDeadKeySelection.expandedStart,
1148+ );
1149+ $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1);
1150+ const replacementSelection = $getSelection();
1151+ if (!$isRangeSelection(replacementSelection)) {
1152+ pendingSurroundSelectionRef.current = null;
1153+ pendingDeadKeySelectionRef.current = null;
1154+ return;
1155+ }
1156+ replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`);
1157+ $setSelectionRangeAtComposerOffsets(
1158+ replacementStart + 1,
1159+ replacementStart + 1 + selectedText.length,
1160+ );
1161+ pendingSurroundSelectionRef.current = null;
1162+ pendingDeadKeySelectionRef.current = null;
1163+ },
1164+ { tag: HISTORY_MERGE_TAG },
1165+ );
1166+ });
1167+ };
1168+
1169+ const onInput = (event: Event) => {
1170+ const inputEvent = event as InputEvent;
1171+ if (
1172+ inputEvent.inputType === "insertText" ||
1173+ inputEvent.inputType === "insertCompositionText"
1174+ ) {
1175+ tryApplyDeadKeyBacktickSurround();
1176+ }
1177+ };
1178+
1179+ const onCompositionEnd = () => {
1180+ tryApplyDeadKeyBacktickSurround({ finalAttempt: true });
1181+ };
1182+
1183+ let activeRootElement: HTMLElement | null = null;
1184+ const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
1185+ prevRootElement?.removeEventListener("keydown", onKeyDown);
1186+ prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true);
1187+ prevRootElement?.removeEventListener("input", onInput);
1188+ prevRootElement?.removeEventListener("compositionend", onCompositionEnd);
1189+ rootElement?.addEventListener("keydown", onKeyDown);
1190+ rootElement?.addEventListener("beforeinput", onBeforeInput, true);
1191+ rootElement?.addEventListener("input", onInput);
1192+ rootElement?.addEventListener("compositionend", onCompositionEnd);
1193+ activeRootElement = rootElement;
1194+ });
1195+
1196+ return () => {
1197+ if (activeRootElement) {
1198+ activeRootElement.removeEventListener("keydown", onKeyDown);
1199+ activeRootElement.removeEventListener("beforeinput", onBeforeInput, true);
1200+ activeRootElement.removeEventListener("input", onInput);
1201+ activeRootElement.removeEventListener("compositionend", onCompositionEnd);
1202+ }
1203+ unregisterRootListener();
1204+ };
1205+ }, [applySurroundInsertion, editor]);
1206+
1207+ return null;
1208+}
1209+
8811210 function ComposerPromptEditorInner({
8821211 value,
8831212 cursor,
@@ -1113,6 +1442,7 @@ function ComposerPromptEditorInner({ · 229 unmodified lines
11131442 />
11141443 <OnChangePlugin onChange={handleEditorChange} />
11151444 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
1445+ <ComposerSurroundSelectionPlugin terminalContexts={terminalContexts} />
11161446 <ComposerInlineTokenArrowPlugin />
11171447 <ComposerInlineTokenSelectionNormalizePlugin />
11181448 <ComposerInlineTokenBackspacePlugin />
apps/web/src/composer-editor-mentions.test.ts+59−1
@@ -1,6 +1,9 @@
11 import { describe, expect, it } from "vitest";
22
3−import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
3+import {
4+ selectionTouchesMentionBoundary,
5+ splitPromptIntoComposerSegments,
6+} from "./composer-editor-mentions";
47 import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
58
69 describe("splitPromptIntoComposerSegments", () => {
@@ -38,4 +41,59 @@ describe("splitPromptIntoComposerSegments", () => { · 31 unmodified lines
3841 { type: "text", text: " please" },
3942 ]);
4043 });
44+
45+ it("preserves consecutive terminal context placeholders without dropping positions", () => {
46+ expect(
47+ splitPromptIntoComposerSegments(
48+ `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}tail`,
49+ ),
50+ ).toEqual([
51+ { type: "terminal-context", context: null },
52+ { type: "terminal-context", context: null },
53+ { type: "text", text: "tail" },
54+ ]);
55+ });
56+});
57+
58+describe("selectionTouchesMentionBoundary", () => {
59+ it("returns true when selection includes the whitespace after a mention", () => {
60+ expect(
61+ selectionTouchesMentionBoundary(
62+ "hi @package.json there",
63+ "hi @package.json".length,
64+ "hi @package.json there".length,
65+ ),
66+ ).toBe(true);
67+ });
68+
69+ it("returns true when selection includes the whitespace before a mention", () => {
70+ expect(
71+ selectionTouchesMentionBoundary(
72+ "hi there @package.json later",
73+ "hi there".length,
74+ "hi there ".length,
75+ ),
76+ ).toBe(true);
77+ });
78+
79+ it("returns false when selection starts after the mention boundary whitespace", () => {
80+ expect(
81+ selectionTouchesMentionBoundary(
82+ "hi @package.json there",
83+ "hi @package.json ".length,
84+ "hi @package.json there".length,
85+ ),
86+ ).toBe(false);
87+ });
88+
89+ it("returns true when selection includes whitespace after a mention following a terminal placeholder", () => {
90+ const prompt = `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md there`;
91+ expect(
92+ selectionTouchesMentionBoundary(
93+ prompt,
94+ `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md`.length,
95+ prompt.length,
96+ ),
97+ ).toBe(true);
98+ });
4199 });
apps/web/src/composer-editor-mentions.ts+125−14
@@ -19,6 +19,10 @@ export type ComposerPromptSegment = · 18 unmodified lines
1919
2020 const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
2121
22+function rangeIncludesIndex(start: number, end: number, index: number): boolean {
23+ return start <= index && index < end;
24+}
25+
2226 function pushTextSegment(segments: ComposerPromptSegment[], text: string): void {
2327 if (!text) return;
2428 const last = segments[segments.length - 1];
@@ -29,6 +33,84 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void · 4 unmodified lines
2933 segments.push({ type: "text", text });
3034 }
3135
36+function forEachPromptSegmentSlice(
37+ prompt: string,
38+ visitor: (
39+ slice:
40+ | {
41+ type: "text";
42+ text: string;
43+ promptOffset: number;
44+ }
45+ | {
46+ type: "terminal-context";
47+ promptOffset: number;
48+ },
49+ ) => boolean | void,
50+): boolean {
51+ let textCursor = 0;
52+
53+ for (let index = 0; index < prompt.length; index += 1) {
54+ if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
55+ continue;
56+ }
57+
58+ if (
59+ index > textCursor &&
60+ visitor({
61+ type: "text",
62+ text: prompt.slice(textCursor, index),
63+ promptOffset: textCursor,
64+ }) === true
65+ ) {
66+ return true;
67+ }
68+ if (visitor({ type: "terminal-context", promptOffset: index }) === true) {
69+ return true;
70+ }
71+ textCursor = index + 1;
72+ }
73+
74+ if (
75+ textCursor < prompt.length &&
76+ visitor({
77+ type: "text",
78+ text: prompt.slice(textCursor),
79+ promptOffset: textCursor,
80+ }) === true
81+ ) {
82+ return true;
83+ }
84+
85+ return false;
86+}
87+
88+function forEachPromptTextSlice(
89+ prompt: string,
90+ visitor: (text: string, promptOffset: number) => boolean | void,
91+): boolean {
92+ return forEachPromptSegmentSlice(prompt, (slice) => {
93+ if (slice.type !== "text") {
94+ return false;
95+ }
96+ return visitor(slice.text, slice.promptOffset);
97+ });
98+}
99+
100+function forEachMentionMatch(
101+ prompt: string,
102+ visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
103+): boolean {
104+ return forEachPromptTextSlice(prompt, (text, promptOffset) => {
105+ for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
106+ if (visitor(match, promptOffset) === true) {
107+ return true;
108+ }
109+ }
110+ return false;
111+ });
112+}
113+
32114 function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] {
33115 const segments: ComposerPromptSegment[] = [];
34116 if (!text) {
@@ -64,6 +146,43 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen · 29 unmodified lines
64146 return segments;
65147 }
66148
149+export function selectionTouchesMentionBoundary(
150+ prompt: string,
151+ start: number,
152+ end: number,
153+): boolean {
154+ if (!prompt || start >= end) {
155+ return false;
156+ }
157+
158+ return forEachMentionMatch(prompt, (match, promptOffset) => {
159+ const fullMatch = match[0];
160+ const prefix = match[1] ?? "";
161+ const matchIndex = match.index ?? 0;
162+ const mentionStart = promptOffset + matchIndex + prefix.length;
163+ const mentionEnd = mentionStart + fullMatch.length - prefix.length;
164+ const beforeMentionIndex = mentionStart - 1;
165+ const afterMentionIndex = mentionEnd;
166+
167+ if (
168+ beforeMentionIndex >= 0 &&
169+ /\s/.test(prompt[beforeMentionIndex] ?? "") &&
170+ rangeIncludesIndex(start, end, beforeMentionIndex)
171+ ) {
172+ return true;
173+ }
174+
175+ if (
176+ afterMentionIndex < prompt.length &&
177+ /\s/.test(prompt[afterMentionIndex] ?? "") &&
178+ rangeIncludesIndex(start, end, afterMentionIndex)
179+ ) {
180+ return true;
181+ }
182+ return false;
183+ });
184+}
185+
67186 export function splitPromptIntoComposerSegments(
68187 prompt: string,
69188 terminalContexts: ReadonlyArray<TerminalContextDraft> = [],
@@ -73,28 +192,20 @@ export function splitPromptIntoComposerSegments( · 3 unmodified lines
73192 }
74193
75194 const segments: ComposerPromptSegment[] = [];
76− let textCursor = 0;
77195 let terminalContextIndex = 0;
78−
79− for (let index = 0; index < prompt.length; index += 1) {
80− if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
81− continue;
196+ forEachPromptSegmentSlice(prompt, (slice) => {
197+ if (slice.type === "text") {
198+ segments.push(...splitPromptTextIntoComposerSegments(slice.text));
199+ return false;
82200 }
83201
84− if (index > textCursor) {
85− segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor, index)));
86− }
87202 segments.push({
88203 type: "terminal-context",
89204 context: terminalContexts[terminalContextIndex] ?? null,
90205 });
91206 terminalContextIndex += 1;
92− textCursor = index + 1;
93− }
94−
95− if (textCursor < prompt.length) {
96− segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor)));
97− }
207+ return false;
208+ });
98209
99210 return segments;
100211 }