| 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,7 +38,9 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 38 | import { isElectron } from "../env"; | 38 | import { isElectron } from "../env"; |
| 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 40 | import { | 40 | import { |
| 41 | clampCollapsedComposerCursor, | ||
| 41 | type ComposerTrigger, | 42 | type ComposerTrigger, |
| 43 | collapseExpandedComposerCursor, | ||
| 42 | detectComposerTrigger, | 44 | detectComposerTrigger, |
| 43 | expandCollapsedComposerCursor, | 45 | expandCollapsedComposerCursor, |
| 44 | parseStandaloneComposerSlashCommand, | 46 | parseStandaloneComposerSlashCommand, |
| ... | @@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 266 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< | 268 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 267 | Record<string, string[]> | 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 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | 274 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => |
| 271 | detectComposerTrigger(prompt, prompt.length), | 275 | detectComposerTrigger(prompt, prompt.length), |
| 272 | ); | 276 | ); |
| ... | @@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 662 | } | 666 | } |
| 663 | 667 | ||
| 664 | promptRef.current = nextCustomAnswer; | 668 | promptRef.current = nextCustomAnswer; |
| 665 | setComposerCursor(nextCustomAnswer.length); | 669 | const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); |
| 670 | setComposerCursor(nextCursor); | ||
| 666 | setComposerTrigger( | 671 | setComposerTrigger( |
| 667 | detectComposerTrigger( | 672 | detectComposerTrigger( |
| 668 | nextCustomAnswer, | 673 | nextCustomAnswer, |
| 669 | expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length), | 674 | expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), |
| 670 | ), | 675 | ), |
| 671 | ); | 676 | ); |
| 672 | setComposerHighlightedItemId(null); | 677 | setComposerHighlightedItemId(null); |
| ... | @@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1742 | 1747 | ||
| 1743 | useEffect(() => { | 1748 | useEffect(() => { |
| 1744 | promptRef.current = prompt; | 1749 | promptRef.current = prompt; |
| 1745 | setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); | 1750 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); |
| 1746 | }, [prompt]); | 1751 | }, [prompt]); |
| 1747 | 1752 | ||
| 1748 | useEffect(() => { | 1753 | useEffect(() => { |
| ... | @@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1755 | setSendPhase("idle"); | 1760 | setSendPhase("idle"); |
| 1756 | setSendStartedAt(null); | 1761 | setSendStartedAt(null); |
| 1757 | setComposerHighlightedItemId(null); | 1762 | setComposerHighlightedItemId(null); |
| 1758 | setComposerCursor(promptRef.current.length); | 1763 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 1759 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | 1764 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| 1760 | dragDepthRef.current = 0; | 1765 | dragDepthRef.current = 0; |
| 1761 | setIsDragOverComposer(false); | 1766 | setIsDragOverComposer(false); |
| ... | @@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2433 | }); | 2438 | }); |
| 2434 | promptRef.current = trimmed; | 2439 | promptRef.current = trimmed; |
| 2435 | setPrompt(trimmed); | 2440 | setPrompt(trimmed); |
| 2436 | setComposerCursor(trimmed.length); | 2441 | setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); |
| 2437 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | 2442 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); |
| 2438 | setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); | 2443 | setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); |
| 2439 | } | 2444 | } |
| ... | @@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2551 | ); | 2556 | ); |
| 2552 | 2557 | ||
| 2553 | const onChangeActivePendingUserInputCustomAnswer = useCallback( | 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 | if (!activePendingUserInput) { | 2566 | if (!activePendingUserInput) { |
| 2556 | return; | 2567 | return; |
| 2557 | } | 2568 | } |
| ... | @@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2568 | })); | 2579 | })); |
| 2569 | setComposerCursor(nextCursor); | 2580 | setComposerCursor(nextCursor); |
| 2570 | setComposerTrigger( | 2581 | setComposerTrigger( |
| 2571 | cursorAdjacentToMention | 2582 | cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), |
| 2572 | ? null | ||
| 2573 | : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), | ||
| 2574 | ); | 2583 | ); |
| 2575 | }, | 2584 | }, |
| 2576 | [activePendingUserInput], | 2585 | [activePendingUserInput], |
| ... | @@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2906 | return false; | 2915 | return false; |
| 2907 | } | 2916 | } |
| 2908 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | 2917 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 2918 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | ||
| 2909 | promptRef.current = next.text; | 2919 | promptRef.current = next.text; |
| 2910 | const activePendingQuestion = activePendingProgress?.activeQuestion; | 2920 | const activePendingQuestion = activePendingProgress?.activeQuestion; |
| 2911 | if (activePendingQuestion && activePendingUserInput) { | 2921 | if (activePendingQuestion && activePendingUserInput) { |
| ... | @@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2922 | } else { | 2932 | } else { |
| 2923 | setPrompt(next.text); | 2933 | setPrompt(next.text); |
| 2924 | } | 2934 | } |
| 2925 | setComposerCursor(next.cursor); | 2935 | setComposerCursor(nextCursor); |
| 2926 | setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); | 2936 | setComposerTrigger( |
| 2937 | detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), | ||
| 2938 | ); | ||
| 2927 | window.requestAnimationFrame(() => { | 2939 | window.requestAnimationFrame(() => { |
| 2928 | composerEditorRef.current?.focusAt(next.cursor); | 2940 | composerEditorRef.current?.focusAt(nextCursor); |
| 2929 | }); | 2941 | }); |
| 2930 | return true; | 2942 | return true; |
| 2931 | }, | 2943 | }, |
| ... | @@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2935 | const readComposerSnapshot = useCallback((): { | 2947 | const readComposerSnapshot = useCallback((): { |
| 2936 | value: string; | 2948 | value: string; |
| 2937 | cursor: number; | 2949 | cursor: number; |
| 2950 | expandedCursor: number; | ||
| 2938 | } => { | 2951 | } => { |
| 2939 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); | 2952 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); |
| 2940 | if (editorSnapshot) { | 2953 | if (editorSnapshot) { |
| 2941 | return editorSnapshot; | 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 | }, [composerCursor]); | 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 | const resolveActiveComposerTrigger = useCallback((): { | 2974 | const resolveActiveComposerTrigger = useCallback((): { |
| 2947 | snapshot: { value: string; cursor: number }; | 2975 | snapshot: { value: string; cursor: number; expandedCursor: number }; |
| 2948 | trigger: ComposerTrigger | null; | 2976 | trigger: ComposerTrigger | null; |
| 2949 | } => { | 2977 | } => { |
| 2950 | const snapshot = readComposerSnapshot(); | 2978 | const snapshot = readComposerSnapshot(); |
| 2951 | const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); | ||
| 2952 | return { | 2979 | return { |
| 2953 | snapshot, | 2980 | snapshot, |
| 2954 | trigger: detectComposerTrigger(snapshot.value, expandedCursor), | 2981 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), |
| 2955 | }; | 2982 | }; |
| 2956 | }, [readComposerSnapshot]); | 2983 | }, [readComposerSnapshot]); |
| 2957 | 2984 | ||
| ... | @@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2964 | }); | 2991 | }); |
| 2965 | const { snapshot, trigger } = resolveActiveComposerTrigger(); | 2992 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| 2966 | if (!trigger) return; | 2993 | if (!trigger) return; |
| 2967 | const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); | ||
| 2968 | if (item.type === "path") { | 2994 | if (item.type === "path") { |
| 2995 | const replacement = `@${item.path} `; | ||
| 2996 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | ||
| 2997 | snapshot.value, | ||
| 2998 | trigger.rangeEnd, | ||
| 2999 | replacement, | ||
| 3000 | ); | ||
| 2969 | const applied = applyPromptReplacement( | 3001 | const applied = applyPromptReplacement( |
| 2970 | trigger.rangeStart, | 3002 | trigger.rangeStart, |
| 2971 | trigger.rangeEnd, | 3003 | replacementRangeEnd, |
| 2972 | `@${item.path} `, | 3004 | replacement, |
| 2973 | { expectedText: expectedToken }, | 3005 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, |
| 2974 | ); | 3006 | ); |
| 2975 | if (applied) { | 3007 | if (applied) { |
| 2976 | setComposerHighlightedItemId(null); | 3008 | setComposerHighlightedItemId(null); |
| ... | @@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2979 | } | 3011 | } |
| 2980 | if (item.type === "slash-command") { | 3012 | if (item.type === "slash-command") { |
| 2981 | if (item.command === "model") { | 3013 | if (item.command === "model") { |
| 2982 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { | 3014 | const replacement = "/model "; |
| 2983 | expectedText: expectedToken, | 3015 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| 2984 | }); | 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 | if (applied) { | 3026 | if (applied) { |
| 2986 | setComposerHighlightedItemId(null); | 3027 | setComposerHighlightedItemId(null); |
| 2987 | } | 3028 | } |
| ... | @@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2989 | } | 3030 | } |
| 2990 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); | 3031 | void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); |
| 2991 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 3032 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 2992 | expectedText: expectedToken, | 3033 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), |
| 2993 | }); | 3034 | }); |
| 2994 | if (applied) { | 3035 | if (applied) { |
| 2995 | setComposerHighlightedItemId(null); | 3036 | setComposerHighlightedItemId(null); |
| ... | @@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2998 | } | 3039 | } |
| 2999 | onProviderModelSelect(item.provider, item.model); | 3040 | onProviderModelSelect(item.provider, item.model); |
| 3000 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | 3041 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 3001 | expectedText: expectedToken, | 3042 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), |
| 3002 | }); | 3043 | }); |
| 3003 | if (applied) { | 3044 | if (applied) { |
| 3004 | setComposerHighlightedItemId(null); | 3045 | setComposerHighlightedItemId(null); |
| ... | @@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3039 | workspaceEntriesQuery.isFetching); | 3080 | workspaceEntriesQuery.isFetching); |
| 3040 | 3081 | ||
| 3041 | const onPromptChange = useCallback( | 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 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { | 3089 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { |
| 3044 | onChangeActivePendingUserInputCustomAnswer( | 3090 | onChangeActivePendingUserInputCustomAnswer( |
| 3045 | activePendingProgress.activeQuestion.id, | 3091 | activePendingProgress.activeQuestion.id, |
| 3046 | nextPrompt, | 3092 | nextPrompt, |
| 3047 | nextCursor, | 3093 | nextCursor, |
| 3094 | expandedCursor, | ||
| 3048 | cursorAdjacentToMention, | 3095 | cursorAdjacentToMention, |
| 3049 | ); | 3096 | ); |
| 3050 | return; | 3097 | return; |
| ... | @@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3053 | setPrompt(nextPrompt); | 3100 | setPrompt(nextPrompt); |
| 3054 | setComposerCursor(nextCursor); | 3101 | setComposerCursor(nextCursor); |
| 3055 | setComposerTrigger( | 3102 | setComposerTrigger( |
| 3056 | cursorAdjacentToMention | 3103 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| 3057 | ? null | ||
| 3058 | : detectComposerTrigger( | ||
| 3059 | nextPrompt, | ||
| 3060 | expandCollapsedComposerCursor(nextPrompt, nextCursor), | ||
| 3061 | ), | ||
| 3062 | ); | 3104 | ); |
| 3063 | }, | 3105 | }, |
| 3064 | [ | 3106 | [ |
apps/web/src/components/ComposerPromptEditor.tsx+143-17| ... | @@ -47,7 +47,12 @@ import { | ... | @@ -47,7 +47,12 @@ import { |
| 47 | type Ref, | 47 | type Ref, |
| 48 | } from "react"; | 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 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | 56 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 52 | import { cn } from "~/lib/utils"; | 57 | import { cn } from "~/lib/utils"; |
| 53 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; | 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; |
| ... | @@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { | ... | @@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { |
| 171 | container.append(icon, label); | 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 | if (!Number.isFinite(cursor)) return value.length; | 180 | if (!Number.isFinite(cursor)) return value.length; |
| 176 | return Math.max(0, Math.min(value.length, Math.floor(cursor))); | 181 | return Math.max(0, Math.min(value.length, Math.floor(cursor))); |
| 177 | } | 182 | } |
| ... | @@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number { | ... | @@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number { |
| 192 | return 0; | 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 | function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { | 215 | function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { |
| 196 | let offset = 0; | 216 | let offset = 0; |
| 197 | let current: LexicalNode | null = node; | 217 | let current: LexicalNode | null = node; |
| ... | @@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb | ... | @@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb |
| 236 | return offset; | 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 | function findSelectionPointAtOffset( | 303 | function findSelectionPointAtOffset( |
| 240 | node: LexicalNode, | 304 | node: LexicalNode, |
| 241 | remainingRef: { value: number }, | 305 | remainingRef: { value: number }, |
| ... | @@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { | ... | @@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { |
| 350 | return Math.max(0, Math.min(offset, composerLength)); | 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 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { | 428 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { |
| 354 | const lines = text.split("\n"); | 429 | const lines = text.split("\n"); |
| 355 | for (let index = 0; index < lines.length; index += 1) { | 430 | for (let index = 0; index < lines.length; index += 1) { |
| ... | @@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle { | ... | @@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle { |
| 383 | focus: () => void; | 458 | focus: () => void; |
| 384 | focusAt: (cursor: number) => void; | 459 | focusAt: (cursor: number) => void; |
| 385 | focusAtEnd: () => void; | 460 | focusAtEnd: () => void; |
| 386 | readSnapshot: () => { value: string; cursor: number }; | 461 | readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; |
| 387 | } | 462 | } |
| 388 | 463 | ||
| 389 | interface ComposerPromptEditorProps { | 464 | interface ComposerPromptEditorProps { |
| ... | @@ -392,7 +467,12 @@ interface ComposerPromptEditorProps { | ... | @@ -392,7 +467,12 @@ interface ComposerPromptEditorProps { |
| 392 | disabled: boolean; | 467 | disabled: boolean; |
| 393 | placeholder: string; | 468 | placeholder: string; |
| 394 | className?: string; | 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 | onCommandKeyDown?: ( | 476 | onCommandKeyDown?: ( |
| 397 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | 477 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| 398 | event: KeyboardEvent, | 478 | event: KeyboardEvent, |
| ... | @@ -628,7 +708,12 @@ function ComposerPromptEditorInner({ | ... | @@ -628,7 +708,12 @@ function ComposerPromptEditorInner({ |
| 628 | }: ComposerPromptEditorInnerProps) { | 708 | }: ComposerPromptEditorInnerProps) { |
| 629 | const [editor] = useLexicalComposerContext(); | 709 | const [editor] = useLexicalComposerContext(); |
| 630 | const onChangeRef = useRef(onChange); | 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 | const isApplyingControlledUpdateRef = useRef(false); | 717 | const isApplyingControlledUpdateRef = useRef(false); |
| 633 | 718 | ||
| 634 | useEffect(() => { | 719 | useEffect(() => { |
| ... | @@ -640,13 +725,17 @@ function ComposerPromptEditorInner({ | ... | @@ -640,13 +725,17 @@ function ComposerPromptEditorInner({ |
| 640 | }, [disabled, editor]); | 725 | }, [disabled, editor]); |
| 641 | 726 | ||
| 642 | useLayoutEffect(() => { | 727 | useLayoutEffect(() => { |
| 643 | const normalizedCursor = clampCursor(value, cursor); | 728 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); |
| 644 | const previousSnapshot = snapshotRef.current; | 729 | const previousSnapshot = snapshotRef.current; |
| 645 | if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { | 730 | if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { |
| 646 | return; | 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 | const rootElement = editor.getRootElement(); | 740 | const rootElement = editor.getRootElement(); |
| 652 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); | 741 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); |
| ... | @@ -673,7 +762,7 @@ function ComposerPromptEditorInner({ | ... | @@ -673,7 +762,7 @@ function ComposerPromptEditorInner({ |
| 673 | (nextCursor: number) => { | 762 | (nextCursor: number) => { |
| 674 | const rootElement = editor.getRootElement(); | 763 | const rootElement = editor.getRootElement(); |
| 675 | if (!rootElement) return; | 764 | if (!rootElement) return; |
| 676 | const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor); | 765 | const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); |
| 677 | rootElement.focus(); | 766 | rootElement.focus(); |
| 678 | editor.update(() => { | 767 | editor.update(() => { |
| 679 | $setSelectionAtComposerOffset(boundedCursor); | 768 | $setSelectionAtComposerOffset(boundedCursor); |
| ... | @@ -681,24 +770,43 @@ function ComposerPromptEditorInner({ | ... | @@ -681,24 +770,43 @@ function ComposerPromptEditorInner({ |
| 681 | snapshotRef.current = { | 770 | snapshotRef.current = { |
| 682 | value: snapshotRef.current.value, | 771 | value: snapshotRef.current.value, |
| 683 | cursor: boundedCursor, | 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 | [editor], | 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 | let snapshot = snapshotRef.current; | 790 | let snapshot = snapshotRef.current; |
| 692 | editor.getEditorState().read(() => { | 791 | editor.getEditorState().read(() => { |
| 693 | const nextValue = $getRoot().getTextContent(); | 792 | const nextValue = $getRoot().getTextContent(); |
| 694 | const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); | 793 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); |
| 695 | const nextCursor = clampCursor( | 794 | const nextCursor = clampCollapsedComposerCursor( |
| 696 | nextValue, | 795 | nextValue, |
| 697 | $readSelectionOffsetFromEditorState(fallbackCursor), | 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 | snapshot = { | 806 | snapshot = { |
| 700 | value: nextValue, | 807 | value: nextValue, |
| 701 | cursor: nextCursor, | 808 | cursor: nextCursor, |
| 809 | expandedCursor: nextExpandedCursor, | ||
| 702 | }; | 810 | }; |
| 703 | }); | 811 | }); |
| 704 | snapshotRef.current = snapshot; | 812 | snapshotRef.current = snapshot; |
| ... | @@ -713,7 +821,12 @@ function ComposerPromptEditorInner({ | ... | @@ -713,7 +821,12 @@ function ComposerPromptEditorInner({ |
| 713 | }, | 821 | }, |
| 714 | focusAt, | 822 | focusAt, |
| 715 | focusAtEnd: () => { | 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 | readSnapshot, | 831 | readSnapshot, |
| 719 | }), | 832 | }), |
| ... | @@ -723,13 +836,25 @@ function ComposerPromptEditorInner({ | ... | @@ -723,13 +836,25 @@ function ComposerPromptEditorInner({ |
| 723 | const handleEditorChange = useCallback((editorState: EditorState) => { | 836 | const handleEditorChange = useCallback((editorState: EditorState) => { |
| 724 | editorState.read(() => { | 837 | editorState.read(() => { |
| 725 | const nextValue = $getRoot().getTextContent(); | 838 | const nextValue = $getRoot().getTextContent(); |
| 726 | const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); | 839 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); |
| 727 | const nextCursor = clampCursor( | 840 | const nextCursor = clampCollapsedComposerCursor( |
| 728 | nextValue, | 841 | nextValue, |
| 729 | $readSelectionOffsetFromEditorState(fallbackCursor), | 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 | const previousSnapshot = snapshotRef.current; | 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 | return; | 858 | return; |
| 734 | } | 859 | } |
| 735 | if (isApplyingControlledUpdateRef.current) { | 860 | if (isApplyingControlledUpdateRef.current) { |
| ... | @@ -738,11 +863,12 @@ function ComposerPromptEditorInner({ | ... | @@ -738,11 +863,12 @@ function ComposerPromptEditorInner({ |
| 738 | snapshotRef.current = { | 863 | snapshotRef.current = { |
| 739 | value: nextValue, | 864 | value: nextValue, |
| 740 | cursor: nextCursor, | 865 | cursor: nextCursor, |
| 866 | expandedCursor: nextExpandedCursor, | ||
| 741 | }; | 867 | }; |
| 742 | const cursorAdjacentToMention = | 868 | const cursorAdjacentToMention = |
| 743 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || | 869 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || |
| 744 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); | 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,6 +1,8 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | clampCollapsedComposerCursor, | ||
| 5 | collapseExpandedComposerCursor, | ||
| 4 | detectComposerTrigger, | 6 | detectComposerTrigger, |
| 5 | expandCollapsedComposerCursor, | 7 | expandCollapsedComposerCursor, |
| 6 | isCollapsedCursorAdjacentToMention, | 8 | isCollapsedCursorAdjacentToMention, |
| ... | @@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => { | ... | @@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => { |
| 56 | rangeEnd: text.length, | 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 | describe("replaceTextRange", () => { | 103 | describe("replaceTextRange", () => { |
| ... | @@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => { | ... | @@ -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 | describe("isCollapsedCursorAdjacentToMention", () => { | 195 | describe("isCollapsedCursorAdjacentToMention", () => { |
| 96 | it("returns false when no mention exists", () => { | 196 | it("returns false when no mention exists", () => { |
| 97 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); | 197 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); |
apps/web/src/composer-logic.ts+44-2| ... | @@ -67,7 +67,7 @@ function collapsedSegmentLength( | ... | @@ -67,7 +67,7 @@ function collapsedSegmentLength( |
| 67 | return segment.type === "mention" ? 1 : segment.text.length; | 67 | return segment.type === "mention" ? 1 : segment.text.length; |
| 68 | } | 68 | } |
| 69 | 69 | ||
| 70 | function clampCollapsedComposerCursor( | 70 | function clampCollapsedComposerCursorForSegments( |
| 71 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, | 71 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, |
| 72 | cursorInput: number, | 72 | cursorInput: number, |
| 73 | ): number { | 73 | ): number { |
| ... | @@ -81,6 +81,48 @@ function clampCollapsedComposerCursor( | ... | @@ -81,6 +81,48 @@ function clampCollapsedComposerCursor( |
| 81 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); | 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 | export function isCollapsedCursorAdjacentToMention( | 126 | export function isCollapsedCursorAdjacentToMention( |
| 85 | text: string, | 127 | text: string, |
| 86 | cursorInput: number, | 128 | cursorInput: number, |
| ... | @@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention( | ... | @@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention( |
| 91 | return false; | 133 | return false; |
| 92 | } | 134 | } |
| 93 | 135 | ||
| 94 | const cursor = clampCollapsedComposerCursor(segments, cursorInput); | 136 | const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput); |
| 95 | let collapsedOffset = 0; | 137 | let collapsedOffset = 0; |
| 96 | 138 | ||
| 97 | for (const segment of segments) { | 139 | for (const segment of segments) { |