| author | |
| committer | |
| log | 71a7473a81c75a70bef55844a410e5d7f1e5de10 |
| tree | 77c260319f434cf6e4b58d1d9705804aee943558 |
| parent | 5c03fb62c4ede0a347938b796a611d5f6b9d61fb |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 362 insertions(+), 52 deletions(-)
apps/web/src/components/ChatView.tsx+75-33| ... | ... | @@ -38,7 +38,9 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 38 | 38 | import { isElectron } from "../env"; |
| 39 | 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 40 | 40 | import { |
| 41 | clampCollapsedComposerCursor, | |
| 41 | 42 | type ComposerTrigger, |
| 43 | collapseExpandedComposerCursor, | |
| 42 | 44 | detectComposerTrigger, |
| 43 | 45 | expandCollapsedComposerCursor, |
| 44 | 46 | parseStandaloneComposerSlashCommand, |
| ... | ... | @@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 266 | 268 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 267 | 269 | Record<string, string[]> |
| 268 | 270 | >({}); |
| 269 | const [composerCursor, setComposerCursor] = useState(() => prompt.length); | |
| 271 | const [composerCursor, setComposerCursor] = useState(() => | |
| 272 | collapseExpandedComposerCursor(prompt, prompt.length), | |
| 273 | ); | |
| 270 | 274 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => |
| 271 | 275 | detectComposerTrigger(prompt, prompt.length), |
| 272 | 276 | ); |
| ... | ... | @@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 662 | 666 | } |
| 663 | 667 | |
| 664 | 668 | promptRef.current = nextCustomAnswer; |
| 665 | setComposerCursor(nextCustomAnswer.length); | |
| 669 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); | |
| 670 | setComposerCursor(nextCursor); | |
| 666 | 671 | setComposerTrigger( |
| 667 | 672 | detectComposerTrigger( |
| 668 | 673 | nextCustomAnswer, |
| 669 | expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length), | |
| 674 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), | |
| 670 | 675 | ), |
| 671 | 676 | ); |
| 672 | 677 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1742 | 1747 | |
| 1743 | 1748 | useEffect(() => { |
| 1744 | 1749 | promptRef.current = prompt; |
| 1745 | setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); | |
| 1750 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); | |
| 1746 | 1751 | }, [prompt]); |
| 1747 | 1752 | |
| 1748 | 1753 | useEffect(() => { |
| ... | ... | @@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1755 | 1760 | setSendPhase("idle"); |
| 1756 | 1761 | setSendStartedAt(null); |
| 1757 | 1762 | setComposerHighlightedItemId(null); |
| 1758 | setComposerCursor(promptRef.current.length); | |
| 1763 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); | |
| 1759 | 1764 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 1760 | 1765 | dragDepthRef.current = 0; |
| 1761 | 1766 | setIsDragOverComposer(false); |
| ... | ... | @@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2433 | 2438 | }); |
| 2434 | 2439 | promptRef.current = trimmed; |
| 2435 | 2440 | setPrompt(trimmed); |
| 2436 | setComposerCursor(trimmed.length); | |
| 2441 | setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); | |
| 2437 | 2442 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); |
| 2438 | 2443 | setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); |
| 2439 | 2444 | } |
| ... | ... | @@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2551 | 2556 | ); |
| 2552 | 2557 | |
| 2553 | 2558 | const onChangeActivePendingUserInputCustomAnswer = useCallback( |
| 2554 | (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { | |
| 2559 | ( | |
| 2560 | questionId: string, | |
| 2561 | value: string, | |
| 2562 | nextCursor: number, | |
| 2563 | expandedCursor: number, | |
| 2564 | cursorAdjacentToMention: boolean, | |
| 2565 | ) => { | |
| 2555 | 2566 | if (!activePendingUserInput) { |
| 2556 | 2567 | return; |
| 2557 | 2568 | } |
| ... | ... | @@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2568 | 2579 | })); |
| 2569 | 2580 | setComposerCursor(nextCursor); |
| 2570 | 2581 | setComposerTrigger( |
| 2571 | cursorAdjacentToMention | |
| 2572 | ? null | |
| 2573 | : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), | |
| 2582 | cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), | |
| 2574 | 2583 | ); |
| 2575 | 2584 | }, |
| 2576 | 2585 | [activePendingUserInput], |
| ... | ... | @@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2906 | 2915 | return false; |
| 2907 | 2916 | } |
| 2908 | 2917 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 2918 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | |
| 2909 | 2919 | promptRef.current = next.text; |
| 2910 | 2920 | const activePendingQuestion = activePendingProgress?.activeQuestion; |
| 2911 | 2921 | if (activePendingQuestion && activePendingUserInput) { |
| ... | ... | @@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2922 | 2932 | } else { |
| 2923 | 2933 | setPrompt(next.text); |
| 2924 | 2934 | } |
| 2925 | setComposerCursor(next.cursor); | |
| 2926 | setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); | |
| 2935 | setComposerCursor(nextCursor); | |
| 2936 | setComposerTrigger( | |
| 2937 | detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), | |
| 2938 | ); | |
| 2927 | 2939 | window.requestAnimationFrame(() => { |
| 2928 | composerEditorRef.current?.focusAt(next.cursor); | |
| 2940 | composerEditorRef.current?.focusAt(nextCursor); | |
| 2929 | 2941 | }); |
| 2930 | 2942 | return true; |
| 2931 | 2943 | }, |
| ... | ... | @@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2935 | 2947 | const readComposerSnapshot = useCallback((): { |
| 2936 | 2948 | value: string; |
| 2937 | 2949 | cursor: number; |
| 2950 | expandedCursor: number; | |
| 2938 | 2951 | } => { |
| 2939 | 2952 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); |
| 2940 | 2953 | if (editorSnapshot) { |
| 2941 | 2954 | return editorSnapshot; |
| 2942 | 2955 | } |
| 2943 | return { value: promptRef.current, cursor: composerCursor }; | |
| 2956 | return { | |
| 2957 | value: promptRef.current, | |
| 2958 | cursor: composerCursor, | |
| 2959 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 2960 | }; | |
| 2944 | 2961 | }, [composerCursor]); |
| 2945 | 2962 | |
| 2963 | const extendReplacementRangeForTrailingSpace = ( | |
| 2964 | text: string, | |
| 2965 | rangeEnd: number, | |
| 2966 | replacement: string, | |
| 2967 | ): number => { | |
| 2968 | if (!replacement.endsWith(" ")) { | |
| 2969 | return rangeEnd; | |
| 2970 | } | |
| 2971 | return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; | |
| 2972 | }; | |
| 2973 | ||
| 2946 | 2974 | const resolveActiveComposerTrigger = useCallback((): { |
| 2947 | snapshot: { value: string; cursor: number }; | |
| 2975 | snapshot: { value: string; cursor: number; expandedCursor: number }; | |
| 2948 | 2976 | trigger: ComposerTrigger | null; |
| 2949 | 2977 | } => { |
| 2950 | 2978 | const snapshot = readComposerSnapshot(); |
| 2951 | const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); | |
| 2952 | 2979 | return { |
| 2953 | 2980 | snapshot, |
| 2954 | trigger: detectComposerTrigger(snapshot.value, expandedCursor), | |
| 2981 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | |
| 2955 | 2982 | }; |
| 2956 | 2983 | }, [readComposerSnapshot]); |
| 2957 | 2984 | |
| ... | ... | @@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2964 | 2991 | }); |
| 2965 | 2992 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| 2966 | 2993 | if (!trigger) return; |
| 2967 | const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); | |
| 2968 | 2994 | if (item.type === "path") { |
| 2995 | const replacement = `@${item.path} `; | |
| 2996 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 2997 | snapshot.value, | |
| 2998 | trigger.rangeEnd, | |
| 2999 | replacement, | |
| 3000 | ); | |
| 2969 | 3001 | const applied = applyPromptReplacement( |
| 2970 | 3002 | trigger.rangeStart, |
| 2971 | trigger.rangeEnd, | |
| 2972 | `@${item.path} `, | |
| 2973 | { expectedText: expectedToken }, | |
| 3003 | replacementRangeEnd, | |
| 3004 | replacement, | |
| 3005 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 2974 | 3006 | ); |
| 2975 | 3007 | if (applied) { |
| 2976 | 3008 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2979 | 3011 | } |
| 2980 | 3012 | if (item.type === "slash-command") { |
| 2981 | 3013 | if (item.command === "model") { |
| 2982 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { | |
| 2983 | expectedText: expectedToken, | |
| 2984 | }); | |
| 3014 | const replacement = "/model "; | |
| 3015 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | |
| 3016 | snapshot.value, | |
| 3017 | trigger.rangeEnd, | |
| 3018 | replacement, | |
| 3019 | ); | |
| 3020 | const applied = applyPromptReplacement( | |
| 3021 | trigger.rangeStart, | |
| 3022 | replacementRangeEnd, | |
| 3023 | replacement, | |
| 3024 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | |
| 3025 | ); | |
| 2985 | 3026 | if (applied) { |
| 2986 | 3027 | setComposerHighlightedItemId(null); |
| 2987 | 3028 | } |
| ... | ... | @@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2989 | 3030 | } |
| 2990 | 3031 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); |
| 2991 | 3032 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 2992 | expectedText: expectedToken, | |
| 3033 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 2993 | 3034 | }); |
| 2994 | 3035 | if (applied) { |
| 2995 | 3036 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2998 | 3039 | } |
| 2999 | 3040 | onProviderModelSelect(item.provider, item.model); |
| 3000 | 3041 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 3001 | expectedText: expectedToken, | |
| 3042 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | |
| 3002 | 3043 | }); |
| 3003 | 3044 | if (applied) { |
| 3004 | 3045 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3039 | 3080 | workspaceEntriesQuery.isFetching); |
| 3040 | 3081 | |
| 3041 | 3082 | const onPromptChange = useCallback( |
| 3042 | (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { | |
| 3083 | ( | |
| 3084 | nextPrompt: string, | |
| 3085 | nextCursor: number, | |
| 3086 | expandedCursor: number, | |
| 3087 | cursorAdjacentToMention: boolean, | |
| 3088 | ) => { | |
| 3043 | 3089 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { |
| 3044 | 3090 | onChangeActivePendingUserInputCustomAnswer( |
| 3045 | 3091 | activePendingProgress.activeQuestion.id, |
| 3046 | 3092 | nextPrompt, |
| 3047 | 3093 | nextCursor, |
| 3094 | expandedCursor, | |
| 3048 | 3095 | cursorAdjacentToMention, |
| 3049 | 3096 | ); |
| 3050 | 3097 | return; |
| ... | ... | @@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3053 | 3100 | setPrompt(nextPrompt); |
| 3054 | 3101 | setComposerCursor(nextCursor); |
| 3055 | 3102 | setComposerTrigger( |
| 3056 | cursorAdjacentToMention | |
| 3057 | ? null | |
| 3058 | : detectComposerTrigger( | |
| 3059 | nextPrompt, | |
| 3060 | expandCollapsedComposerCursor(nextPrompt, nextCursor), | |
| 3061 | ), | |
| 3103 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | |
| 3062 | 3104 | ); |
| 3063 | 3105 | }, |
| 3064 | 3106 | [ |
apps/web/src/components/ComposerPromptEditor.tsx+143-17| ... | ... | @@ -47,7 +47,12 @@ import { |
| 47 | 47 | type Ref, |
| 48 | 48 | } from "react"; |
| 49 | 49 | |
| 50 | import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; | |
| 50 | import { | |
| 51 | clampCollapsedComposerCursor, | |
| 52 | collapseExpandedComposerCursor, | |
| 53 | expandCollapsedComposerCursor, | |
| 54 | isCollapsedCursorAdjacentToMention, | |
| 55 | } from "~/composer-logic"; | |
| 51 | 56 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 52 | 57 | import { cn } from "~/lib/utils"; |
| 53 | 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; |
| ... | ... | @@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { |
| 171 | 176 | container.append(icon, label); |
| 172 | 177 | } |
| 173 | 178 | |
| 174 | function clampCursor(value: string, cursor: number): number { | |
| 179 | function clampExpandedCursor(value: string, cursor: number): number { | |
| 175 | 180 | if (!Number.isFinite(cursor)) return value.length; |
| 176 | 181 | return Math.max(0, Math.min(value.length, Math.floor(cursor))); |
| 177 | 182 | } |
| ... | ... | @@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number { |
| 192 | 197 | return 0; |
| 193 | 198 | } |
| 194 | 199 | |
| 200 | function getComposerNodeExpandedTextLength(node: LexicalNode): number { | |
| 201 | if ($isTextNode(node)) { | |
| 202 | return node.getTextContentSize(); | |
| 203 | } | |
| 204 | if ($isLineBreakNode(node)) { | |
| 205 | return 1; | |
| 206 | } | |
| 207 | if ($isElementNode(node)) { | |
| 208 | return node | |
| 209 | .getChildren() | |
| 210 | .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); | |
| 211 | } | |
| 212 | return 0; | |
| 213 | } | |
| 214 | ||
| 195 | 215 | function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { |
| 196 | 216 | let offset = 0; |
| 197 | 217 | let current: LexicalNode | null = node; |
| ... | ... | @@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb |
| 236 | 256 | return offset; |
| 237 | 257 | } |
| 238 | 258 | |
| 259 | function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { | |
| 260 | let offset = 0; | |
| 261 | let current: LexicalNode | null = node; | |
| 262 | ||
| 263 | while (current) { | |
| 264 | const nextParent = current.getParent() as LexicalNode | null; | |
| 265 | if (!nextParent || !$isElementNode(nextParent)) { | |
| 266 | break; | |
| 267 | } | |
| 268 | const siblings = nextParent.getChildren(); | |
| 269 | const index = current.getIndexWithinParent(); | |
| 270 | for (let i = 0; i < index; i += 1) { | |
| 271 | const sibling = siblings[i]; | |
| 272 | if (!sibling) continue; | |
| 273 | offset += getComposerNodeExpandedTextLength(sibling); | |
| 274 | } | |
| 275 | current = nextParent; | |
| 276 | } | |
| 277 | ||
| 278 | if ($isTextNode(node)) { | |
| 279 | if (node instanceof ComposerMentionNode) { | |
| 280 | return offset + (pointOffset > 0 ? node.getTextContentSize() : 0); | |
| 281 | } | |
| 282 | return offset + Math.min(pointOffset, node.getTextContentSize()); | |
| 283 | } | |
| 284 | ||
| 285 | if ($isLineBreakNode(node)) { | |
| 286 | return offset + Math.min(pointOffset, 1); | |
| 287 | } | |
| 288 | ||
| 289 | if ($isElementNode(node)) { | |
| 290 | const children = node.getChildren(); | |
| 291 | const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); | |
| 292 | for (let i = 0; i < clampedOffset; i += 1) { | |
| 293 | const child = children[i]; | |
| 294 | if (!child) continue; | |
| 295 | offset += getComposerNodeExpandedTextLength(child); | |
| 296 | } | |
| 297 | return offset; | |
| 298 | } | |
| 299 | ||
| 300 | return offset; | |
| 301 | } | |
| 302 | ||
| 239 | 303 | function findSelectionPointAtOffset( |
| 240 | 304 | node: LexicalNode, |
| 241 | 305 | remainingRef: { value: number }, |
| ... | ... | @@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { |
| 350 | 414 | return Math.max(0, Math.min(offset, composerLength)); |
| 351 | 415 | } |
| 352 | 416 | |
| 417 | function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { | |
| 418 | const selection = $getSelection(); | |
| 419 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { | |
| 420 | return fallback; | |
| 421 | } | |
| 422 | const anchorNode = selection.anchor.getNode(); | |
| 423 | const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); | |
| 424 | const expandedLength = $getRoot().getTextContent().length; | |
| 425 | return Math.max(0, Math.min(offset, expandedLength)); | |
| 426 | } | |
| 427 | ||
| 353 | 428 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { |
| 354 | 429 | const lines = text.split("\n"); |
| 355 | 430 | for (let index = 0; index < lines.length; index += 1) { |
| ... | ... | @@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle { |
| 383 | 458 | focus: () => void; |
| 384 | 459 | focusAt: (cursor: number) => void; |
| 385 | 460 | focusAtEnd: () => void; |
| 386 | readSnapshot: () => { value: string; cursor: number }; | |
| 461 | readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; | |
| 387 | 462 | } |
| 388 | 463 | |
| 389 | 464 | interface ComposerPromptEditorProps { |
| ... | ... | @@ -392,7 +467,12 @@ interface ComposerPromptEditorProps { |
| 392 | 467 | disabled: boolean; |
| 393 | 468 | placeholder: string; |
| 394 | 469 | className?: string; |
| 395 | onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void; | |
| 470 | onChange: ( | |
| 471 | nextValue: string, | |
| 472 | nextCursor: number, | |
| 473 | expandedCursor: number, | |
| 474 | cursorAdjacentToMention: boolean, | |
| 475 | ) => void; | |
| 396 | 476 | onCommandKeyDown?: ( |
| 397 | 477 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| 398 | 478 | event: KeyboardEvent, |
| ... | ... | @@ -628,7 +708,12 @@ function ComposerPromptEditorInner({ |
| 628 | 708 | }: ComposerPromptEditorInnerProps) { |
| 629 | 709 | const [editor] = useLexicalComposerContext(); |
| 630 | 710 | const onChangeRef = useRef(onChange); |
| 631 | const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); | |
| 711 | const initialCursor = clampCollapsedComposerCursor(value, cursor); | |
| 712 | const snapshotRef = useRef({ | |
| 713 | value, | |
| 714 | cursor: initialCursor, | |
| 715 | expandedCursor: expandCollapsedComposerCursor(value, initialCursor), | |
| 716 | }); | |
| 632 | 717 | const isApplyingControlledUpdateRef = useRef(false); |
| 633 | 718 | |
| 634 | 719 | useEffect(() => { |
| ... | ... | @@ -640,13 +725,17 @@ function ComposerPromptEditorInner({ |
| 640 | 725 | }, [disabled, editor]); |
| 641 | 726 | |
| 642 | 727 | useLayoutEffect(() => { |
| 643 | const normalizedCursor = clampCursor(value, cursor); | |
| 728 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); | |
| 644 | 729 | const previousSnapshot = snapshotRef.current; |
| 645 | 730 | if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { |
| 646 | 731 | return; |
| 647 | 732 | } |
| 648 | 733 | |
| 649 | snapshotRef.current = { value, cursor: normalizedCursor }; | |
| 734 | snapshotRef.current = { | |
| 735 | value, | |
| 736 | cursor: normalizedCursor, | |
| 737 | expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), | |
| 738 | }; | |
| 650 | 739 | |
| 651 | 740 | const rootElement = editor.getRootElement(); |
| 652 | 741 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); |
| ... | ... | @@ -673,7 +762,7 @@ function ComposerPromptEditorInner({ |
| 673 | 762 | (nextCursor: number) => { |
| 674 | 763 | const rootElement = editor.getRootElement(); |
| 675 | 764 | if (!rootElement) return; |
| 676 | const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor); | |
| 765 | const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); | |
| 677 | 766 | rootElement.focus(); |
| 678 | 767 | editor.update(() => { |
| 679 | 768 | $setSelectionAtComposerOffset(boundedCursor); |
| ... | ... | @@ -681,24 +770,43 @@ function ComposerPromptEditorInner({ |
| 681 | 770 | snapshotRef.current = { |
| 682 | 771 | value: snapshotRef.current.value, |
| 683 | 772 | cursor: boundedCursor, |
| 773 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), | |
| 684 | 774 | }; |
| 685 | onChangeRef.current(snapshotRef.current.value, boundedCursor, false); | |
| 775 | onChangeRef.current( | |
| 776 | snapshotRef.current.value, | |
| 777 | boundedCursor, | |
| 778 | snapshotRef.current.expandedCursor, | |
| 779 | false, | |
| 780 | ); | |
| 686 | 781 | }, |
| 687 | 782 | [editor], |
| 688 | 783 | ); |
| 689 | 784 | |
| 690 | const readSnapshot = useCallback((): { value: string; cursor: number } => { | |
| 785 | const readSnapshot = useCallback((): { | |
| 786 | value: string; | |
| 787 | cursor: number; | |
| 788 | expandedCursor: number; | |
| 789 | } => { | |
| 691 | 790 | let snapshot = snapshotRef.current; |
| 692 | 791 | editor.getEditorState().read(() => { |
| 693 | 792 | const nextValue = $getRoot().getTextContent(); |
| 694 | const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); | |
| 695 | const nextCursor = clampCursor( | |
| 793 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); | |
| 794 | const nextCursor = clampCollapsedComposerCursor( | |
| 696 | 795 | nextValue, |
| 697 | 796 | $readSelectionOffsetFromEditorState(fallbackCursor), |
| 698 | 797 | ); |
| 798 | const fallbackExpandedCursor = clampExpandedCursor( | |
| 799 | nextValue, | |
| 800 | snapshotRef.current.expandedCursor, | |
| 801 | ); | |
| 802 | const nextExpandedCursor = clampExpandedCursor( | |
| 803 | nextValue, | |
| 804 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), | |
| 805 | ); | |
| 699 | 806 | snapshot = { |
| 700 | 807 | value: nextValue, |
| 701 | 808 | cursor: nextCursor, |
| 809 | expandedCursor: nextExpandedCursor, | |
| 702 | 810 | }; |
| 703 | 811 | }); |
| 704 | 812 | snapshotRef.current = snapshot; |
| ... | ... | @@ -713,7 +821,12 @@ function ComposerPromptEditorInner({ |
| 713 | 821 | }, |
| 714 | 822 | focusAt, |
| 715 | 823 | focusAtEnd: () => { |
| 716 | focusAt(snapshotRef.current.value.length); | |
| 824 | focusAt( | |
| 825 | collapseExpandedComposerCursor( | |
| 826 | snapshotRef.current.value, | |
| 827 | snapshotRef.current.value.length, | |
| 828 | ), | |
| 829 | ); | |
| 717 | 830 | }, |
| 718 | 831 | readSnapshot, |
| 719 | 832 | }), |
| ... | ... | @@ -723,13 +836,25 @@ function ComposerPromptEditorInner({ |
| 723 | 836 | const handleEditorChange = useCallback((editorState: EditorState) => { |
| 724 | 837 | editorState.read(() => { |
| 725 | 838 | const nextValue = $getRoot().getTextContent(); |
| 726 | const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); | |
| 727 | const nextCursor = clampCursor( | |
| 839 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); | |
| 840 | const nextCursor = clampCollapsedComposerCursor( | |
| 728 | 841 | nextValue, |
| 729 | 842 | $readSelectionOffsetFromEditorState(fallbackCursor), |
| 730 | 843 | ); |
| 844 | const fallbackExpandedCursor = clampExpandedCursor( | |
| 845 | nextValue, | |
| 846 | snapshotRef.current.expandedCursor, | |
| 847 | ); | |
| 848 | const nextExpandedCursor = clampExpandedCursor( | |
| 849 | nextValue, | |
| 850 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), | |
| 851 | ); | |
| 731 | 852 | const previousSnapshot = snapshotRef.current; |
| 732 | if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { | |
| 853 | if ( | |
| 854 | previousSnapshot.value === nextValue && | |
| 855 | previousSnapshot.cursor === nextCursor && | |
| 856 | previousSnapshot.expandedCursor === nextExpandedCursor | |
| 857 | ) { | |
| 733 | 858 | return; |
| 734 | 859 | } |
| 735 | 860 | if (isApplyingControlledUpdateRef.current) { |
| ... | ... | @@ -738,11 +863,12 @@ function ComposerPromptEditorInner({ |
| 738 | 863 | snapshotRef.current = { |
| 739 | 864 | value: nextValue, |
| 740 | 865 | cursor: nextCursor, |
| 866 | expandedCursor: nextExpandedCursor, | |
| 741 | 867 | }; |
| 742 | 868 | const cursorAdjacentToMention = |
| 743 | 869 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || |
| 744 | 870 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); |
| 745 | onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention); | |
| 871 | onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); | |
| 746 | 872 | }); |
| 747 | 873 | }, []); |
| 748 | 874 |
apps/web/src/composer-logic.test.ts+100| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | clampCollapsedComposerCursor, | |
| 5 | collapseExpandedComposerCursor, | |
| 4 | 6 | detectComposerTrigger, |
| 5 | 7 | expandCollapsedComposerCursor, |
| 6 | 8 | isCollapsedCursorAdjacentToMention, |
| ... | ... | @@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => { |
| 56 | 58 | rangeEnd: text.length, |
| 57 | 59 | }); |
| 58 | 60 | }); |
| 61 | ||
| 62 | it("detects @path trigger in the middle of existing text", () => { | |
| 63 | // User typed @ between "inspect " and "in this sentence" | |
| 64 | const text = "Please inspect @in this sentence"; | |
| 65 | const cursorAfterAt = "Please inspect @".length; | |
| 66 | ||
| 67 | const trigger = detectComposerTrigger(text, cursorAfterAt); | |
| 68 | expect(trigger).toEqual({ | |
| 69 | kind: "path", | |
| 70 | query: "", | |
| 71 | rangeStart: "Please inspect ".length, | |
| 72 | rangeEnd: cursorAfterAt, | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | it("detects @path trigger with query typed mid-text", () => { | |
| 77 | // User typed @sr between "inspect " and "in this sentence" | |
| 78 | const text = "Please inspect @srin this sentence"; | |
| 79 | const cursorAfterQuery = "Please inspect @sr".length; | |
| 80 | ||
| 81 | const trigger = detectComposerTrigger(text, cursorAfterQuery); | |
| 82 | expect(trigger).toEqual({ | |
| 83 | kind: "path", | |
| 84 | query: "sr", | |
| 85 | rangeStart: "Please inspect ".length, | |
| 86 | rangeEnd: cursorAfterQuery, | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | it("detects trigger with true cursor even when regex-based mention detection would false-match", () => { | |
| 91 | // MENTION_TOKEN_REGEX can false-match plain text like "@in" as a mention. | |
| 92 | // The fix bypasses it by computing the expanded cursor from the Lexical node tree. | |
| 93 | const text = "Please inspect @in this sentence"; | |
| 94 | const cursorAfterAt = "Please inspect @".length; | |
| 95 | ||
| 96 | const trigger = detectComposerTrigger(text, cursorAfterAt); | |
| 97 | expect(trigger).not.toBeNull(); | |
| 98 | expect(trigger?.kind).toBe("path"); | |
| 99 | expect(trigger?.query).toBe(""); | |
| 100 | }); | |
| 59 | 101 | }); |
| 60 | 102 | |
| 61 | 103 | describe("replaceTextRange", () => { |
| ... | ... | @@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => { |
| 92 | 134 | }); |
| 93 | 135 | }); |
| 94 | 136 | |
| 137 | describe("collapseExpandedComposerCursor", () => { | |
| 138 | it("keeps cursor unchanged when no mention segment is present", () => { | |
| 139 | expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); | |
| 140 | }); | |
| 141 | ||
| 142 | it("maps expanded mention cursor back to collapsed cursor", () => { | |
| 143 | const text = "what's in my @AGENTS.md fsfdas"; | |
| 144 | const collapsedCursorAfterMention = "what's in my ".length + 2; | |
| 145 | const expandedCursorAfterMention = "what's in my @AGENTS.md ".length; | |
| 146 | ||
| 147 | expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe( | |
| 148 | collapsedCursorAfterMention, | |
| 149 | ); | |
| 150 | }); | |
| 151 | ||
| 152 | it("keeps replacement cursors aligned when another mention already exists earlier", () => { | |
| 153 | const text = "open @AGENTS.md then @src/index.ts "; | |
| 154 | const expandedCursor = text.length; | |
| 155 | const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); | |
| 156 | ||
| 157 | expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); | |
| 158 | expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("clampCollapsedComposerCursor", () => { | |
| 163 | it("clamps to collapsed prompt length when mentions are present", () => { | |
| 164 | const text = "open @AGENTS.md then "; | |
| 165 | ||
| 166 | expect(clampCollapsedComposerCursor(text, text.length)).toBe( | |
| 167 | "open ".length + 1 + " then ".length, | |
| 168 | ); | |
| 169 | expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe( | |
| 170 | "open ".length + 1 + " then ".length, | |
| 171 | ); | |
| 172 | }); | |
| 173 | }); | |
| 174 | ||
| 175 | describe("replaceTextRange trailing space consumption", () => { | |
| 176 | it("double space after insertion when replacement ends with space", () => { | |
| 177 | // Simulates: "and then |@AG| summarize" where | marks replacement range | |
| 178 | // The replacement is "@AGENTS.md " (with trailing space) | |
| 179 | // But if we don't extend rangeEnd, the existing space stays | |
| 180 | const text = "and then @AG summarize"; | |
| 181 | const rangeStart = "and then ".length; | |
| 182 | const rangeEnd = "and then @AG".length; | |
| 183 | ||
| 184 | // Without consuming trailing space: double space | |
| 185 | const withoutConsume = replaceTextRange(text, rangeStart, rangeEnd, "@AGENTS.md "); | |
| 186 | expect(withoutConsume.text).toBe("and then @AGENTS.md summarize"); | |
| 187 | ||
| 188 | // With consuming trailing space: single space | |
| 189 | const extendedEnd = text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; | |
| 190 | const withConsume = replaceTextRange(text, rangeStart, extendedEnd, "@AGENTS.md "); | |
| 191 | expect(withConsume.text).toBe("and then @AGENTS.md summarize"); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 95 | 195 | describe("isCollapsedCursorAdjacentToMention", () => { |
| 96 | 196 | it("returns false when no mention exists", () => { |
| 97 | 197 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); |
apps/web/src/composer-logic.ts+44-2| ... | ... | @@ -67,7 +67,7 @@ function collapsedSegmentLength( |
| 67 | 67 | return segment.type === "mention" ? 1 : segment.text.length; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | function clampCollapsedComposerCursor( | |
| 70 | function clampCollapsedComposerCursorForSegments( | |
| 71 | 71 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, |
| 72 | 72 | cursorInput: number, |
| 73 | 73 | ): number { |
| ... | ... | @@ -81,6 +81,48 @@ function clampCollapsedComposerCursor( |
| 81 | 81 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { | |
| 85 | return clampCollapsedComposerCursorForSegments( | |
| 86 | splitPromptIntoComposerSegments(text), | |
| 87 | cursorInput, | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { | |
| 92 | const expandedCursor = clampCursor(text, cursorInput); | |
| 93 | const segments = splitPromptIntoComposerSegments(text); | |
| 94 | if (segments.length === 0) { | |
| 95 | return expandedCursor; | |
| 96 | } | |
| 97 | ||
| 98 | let remaining = expandedCursor; | |
| 99 | let collapsedCursor = 0; | |
| 100 | ||
| 101 | for (const segment of segments) { | |
| 102 | if (segment.type === "mention") { | |
| 103 | const expandedLength = segment.path.length + 1; | |
| 104 | if (remaining === 0) { | |
| 105 | return collapsedCursor; | |
| 106 | } | |
| 107 | if (remaining <= expandedLength) { | |
| 108 | return collapsedCursor + 1; | |
| 109 | } | |
| 110 | remaining -= expandedLength; | |
| 111 | collapsedCursor += 1; | |
| 112 | continue; | |
| 113 | } | |
| 114 | ||
| 115 | const segmentLength = segment.text.length; | |
| 116 | if (remaining <= segmentLength) { | |
| 117 | return collapsedCursor + remaining; | |
| 118 | } | |
| 119 | remaining -= segmentLength; | |
| 120 | collapsedCursor += segmentLength; | |
| 121 | } | |
| 122 | ||
| 123 | return collapsedCursor; | |
| 124 | } | |
| 125 | ||
| 84 | 126 | export function isCollapsedCursorAdjacentToMention( |
| 85 | 127 | text: string, |
| 86 | 128 | cursorInput: number, |
| ... | ... | @@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention( |
| 91 | 133 | return false; |
| 92 | 134 | } |
| 93 | 135 | |
| 94 | const cursor = clampCollapsedComposerCursor(segments, cursorInput); | |
| 136 | const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput); | |
| 95 | 137 | let collapsedOffset = 0; |
| 96 | 138 | |
| 97 | 139 | for (const segment of segments) { |