diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3..d8d88e2ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,7 +38,9 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { + clampCollapsedComposerCursor, type ComposerTrigger, + collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, @@ -263,7 +265,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); - const [composerCursor, setComposerCursor] = useState(() => prompt.length); + const [composerCursor, setComposerCursor] = useState(() => + collapseExpandedComposerCursor(prompt, prompt.length), + ); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); @@ -635,14 +639,15 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } promptRef.current = activePendingProgress.customAnswer; - setComposerCursor(activePendingProgress.customAnswer.length); + const nextCursor = collapseExpandedComposerCursor( + activePendingProgress.customAnswer, + activePendingProgress.customAnswer.length, + ); + setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( activePendingProgress.customAnswer, - expandCollapsedComposerCursor( - activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, - ), + expandCollapsedComposerCursor(activePendingProgress.customAnswer, nextCursor), ), ); setComposerHighlightedItemId(null); @@ -1720,7 +1725,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); useEffect(() => { @@ -1733,7 +1738,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("idle"); setSendStartedAt(null); setComposerHighlightedItemId(null); - setComposerCursor(promptRef.current.length); + setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); @@ -2418,7 +2423,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); promptRef.current = trimmed; setPrompt(trimmed); - setComposerCursor(trimmed.length); + setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } @@ -2536,7 +2541,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (!activePendingUserInput) { return; } @@ -2553,9 +2564,7 @@ export default function ChatView({ threadId }: ChatViewProps) { })); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), ); }, [activePendingUserInput], @@ -2891,6 +2900,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -2907,10 +2917,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { setPrompt(next.text); } - setComposerCursor(next.cursor); - setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + ); window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(next.cursor); + composerEditorRef.current?.focusAt(nextCursor); }); return true; }, @@ -2920,23 +2932,38 @@ export default function ChatView({ threadId }: ChatViewProps) { const readComposerSnapshot = useCallback((): { value: string; cursor: number; + expandedCursor: number; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; } - return { value: promptRef.current, cursor: composerCursor }; + return { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + }; }, [composerCursor]); + const extendReplacementRangeForTrailingSpace = ( + text: string, + rangeEnd: number, + replacement: string, + ): number => { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; + }; + const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number }; + snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, expandedCursor), + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; }, [readComposerSnapshot]); @@ -2949,13 +2976,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); if (item.type === "path") { + const replacement = `@${item.path} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); const applied = applyPromptReplacement( trigger.rangeStart, - trigger.rangeEnd, - `@${item.path} `, - { expectedText: expectedToken }, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); @@ -2964,9 +2996,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (item.type === "slash-command") { if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { - expectedText: expectedToken, - }); + const replacement = "/model "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); if (applied) { setComposerHighlightedItemId(null); } @@ -2974,7 +3015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); @@ -2983,7 +3024,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); @@ -3024,12 +3065,18 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, + expandedCursor, cursorAdjacentToMention, ); return; @@ -3038,12 +3085,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger( - nextPrompt, - expandCollapsedComposerCursor(nextPrompt, nextCursor), - ), + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 96efc0fbf..deaf567ac 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -47,7 +47,12 @@ import { type Ref, } from "react"; -import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; +import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + isCollapsedCursorAdjacentToMention, +} from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; @@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { container.append(icon, label); } -function clampCursor(value: string, cursor: number): number { +function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); } @@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number { return 0; } +function getComposerNodeExpandedTextLength(node: LexicalNode): number { + if ($isTextNode(node)) { + return node.getTextContentSize(); + } + if ($isLineBreakNode(node)) { + return 1; + } + if ($isElementNode(node)) { + return node + .getChildren() + .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); + } + return 0; +} + function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; @@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb return offset; } +function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { + let offset = 0; + let current: LexicalNode | null = node; + + while (current) { + const nextParent = current.getParent() as LexicalNode | null; + if (!nextParent || !$isElementNode(nextParent)) { + break; + } + const siblings = nextParent.getChildren(); + const index = current.getIndexWithinParent(); + for (let i = 0; i < index; i += 1) { + const sibling = siblings[i]; + if (!sibling) continue; + offset += getComposerNodeExpandedTextLength(sibling); + } + current = nextParent; + } + + if ($isTextNode(node)) { + if (node instanceof ComposerMentionNode) { + return offset + (pointOffset > 0 ? node.getTextContentSize() : 0); + } + return offset + Math.min(pointOffset, node.getTextContentSize()); + } + + if ($isLineBreakNode(node)) { + return offset + Math.min(pointOffset, 1); + } + + if ($isElementNode(node)) { + const children = node.getChildren(); + const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); + for (let i = 0; i < clampedOffset; i += 1) { + const child = children[i]; + if (!child) continue; + offset += getComposerNodeExpandedTextLength(child); + } + return offset; + } + + return offset; +} + function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, @@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { return Math.max(0, Math.min(offset, composerLength)); } +function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return fallback; + } + const anchorNode = selection.anchor.getNode(); + const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const expandedLength = $getRoot().getTextContent().length; + return Math.max(0, Math.min(offset, expandedLength)); +} + function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { @@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; - readSnapshot: () => { value: string; cursor: number }; + readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; } interface ComposerPromptEditorProps { @@ -392,7 +467,12 @@ interface ComposerPromptEditorProps { disabled: boolean; placeholder: string; className?: string; - onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void; + onChange: ( + nextValue: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, @@ -628,7 +708,12 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); + const initialCursor = clampCollapsedComposerCursor(value, cursor); + const snapshotRef = useRef({ + value, + cursor: initialCursor, + expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + }); useEffect(() => { onChangeRef.current = onChange; @@ -639,7 +724,7 @@ function ComposerPromptEditorInner({ }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCursor(value, cursor); + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { return; @@ -651,7 +736,11 @@ function ComposerPromptEditorInner({ }); } - snapshotRef.current = { value, cursor: normalizedCursor }; + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + }; const rootElement = editor.getRootElement(); if (!rootElement || document.activeElement !== rootElement) { @@ -667,7 +756,7 @@ function ComposerPromptEditorInner({ (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); @@ -675,24 +764,43 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), }; - onChangeRef.current(snapshotRef.current.value, boundedCursor, false); + onChangeRef.current( + snapshotRef.current.value, + boundedCursor, + snapshotRef.current.expandedCursor, + false, + ); }, [editor], ); - const readSnapshot = useCallback((): { value: string; cursor: number } => { + const readSnapshot = useCallback((): { + value: string; + cursor: number; + expandedCursor: number; + } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCursor( + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); + const fallbackExpandedCursor = clampExpandedCursor( + nextValue, + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), + ); snapshot = { value: nextValue, cursor: nextCursor, + expandedCursor: nextExpandedCursor, }; }); snapshotRef.current = snapshot; @@ -709,7 +817,12 @@ function ComposerPromptEditorInner({ focusAt(nextCursor); }, focusAtEnd: () => { - focusAt(snapshotRef.current.value.length); + focusAt( + collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), + ); }, readSnapshot, }), @@ -719,23 +832,36 @@ function ComposerPromptEditorInner({ const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCursor( + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); + const fallbackExpandedCursor = clampExpandedCursor( + nextValue, + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), + ); const previousSnapshot = snapshotRef.current; - if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { + if ( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor + ) { return; } snapshotRef.current = { value: nextValue, cursor: nextCursor, + expandedCursor: nextExpandedCursor, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); }); }, []); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 7e6805c96..36532e904 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, @@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => { rangeEnd: text.length, }); }); + + it("detects @path trigger in the middle of existing text", () => { + // User typed @ between "inspect " and "in this sentence" + const text = "Please inspect @in this sentence"; + const cursorAfterAt = "Please inspect @".length; + + const trigger = detectComposerTrigger(text, cursorAfterAt); + expect(trigger).toEqual({ + kind: "path", + query: "", + rangeStart: "Please inspect ".length, + rangeEnd: cursorAfterAt, + }); + }); + + it("detects @path trigger with query typed mid-text", () => { + // User typed @sr between "inspect " and "in this sentence" + const text = "Please inspect @srin this sentence"; + const cursorAfterQuery = "Please inspect @sr".length; + + const trigger = detectComposerTrigger(text, cursorAfterQuery); + expect(trigger).toEqual({ + kind: "path", + query: "sr", + rangeStart: "Please inspect ".length, + rangeEnd: cursorAfterQuery, + }); + }); + + it("detects trigger with true cursor even when regex-based mention detection would false-match", () => { + // MENTION_TOKEN_REGEX can false-match plain text like "@in" as a mention. + // The fix bypasses it by computing the expanded cursor from the Lexical node tree. + const text = "Please inspect @in this sentence"; + const cursorAfterAt = "Please inspect @".length; + + const trigger = detectComposerTrigger(text, cursorAfterAt); + expect(trigger).not.toBeNull(); + expect(trigger?.kind).toBe("path"); + expect(trigger?.query).toBe(""); + }); }); describe("replaceTextRange", () => { @@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => { }); }); +describe("collapseExpandedComposerCursor", () => { + it("keeps cursor unchanged when no mention segment is present", () => { + expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); + }); + + it("maps expanded mention cursor back to collapsed cursor", () => { + const text = "what's in my @AGENTS.md fsfdas"; + const collapsedCursorAfterMention = "what's in my ".length + 2; + const expandedCursorAfterMention = "what's in my @AGENTS.md ".length; + + expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe( + collapsedCursorAfterMention, + ); + }); + + it("keeps replacement cursors aligned when another mention already exists earlier", () => { + const text = "open @AGENTS.md then @src/index.ts "; + const expandedCursor = text.length; + const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); + + expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); + expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); + }); +}); + +describe("clampCollapsedComposerCursor", () => { + it("clamps to collapsed prompt length when mentions are present", () => { + const text = "open @AGENTS.md then "; + + expect(clampCollapsedComposerCursor(text, text.length)).toBe( + "open ".length + 1 + " then ".length, + ); + expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe( + "open ".length + 1 + " then ".length, + ); + }); +}); + +describe("replaceTextRange trailing space consumption", () => { + it("double space after insertion when replacement ends with space", () => { + // Simulates: "and then |@AG| summarize" where | marks replacement range + // The replacement is "@AGENTS.md " (with trailing space) + // But if we don't extend rangeEnd, the existing space stays + const text = "and then @AG summarize"; + const rangeStart = "and then ".length; + const rangeEnd = "and then @AG".length; + + // Without consuming trailing space: double space + const withoutConsume = replaceTextRange(text, rangeStart, rangeEnd, "@AGENTS.md "); + expect(withoutConsume.text).toBe("and then @AGENTS.md summarize"); + + // With consuming trailing space: single space + const extendedEnd = text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; + const withConsume = replaceTextRange(text, rangeStart, extendedEnd, "@AGENTS.md "); + expect(withConsume.text).toBe("and then @AGENTS.md summarize"); + }); +}); + describe("isCollapsedCursorAdjacentToMention", () => { it("returns false when no mention exists", () => { expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 70b3567c3..b696d8038 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -67,7 +67,7 @@ function collapsedSegmentLength( return segment.type === "mention" ? 1 : segment.text.length; } -function clampCollapsedComposerCursor( +function clampCollapsedComposerCursorForSegments( segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, cursorInput: number, ): number { @@ -81,6 +81,48 @@ function clampCollapsedComposerCursor( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } +export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { + return clampCollapsedComposerCursorForSegments( + splitPromptIntoComposerSegments(text), + cursorInput, + ); +} + +export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { + const expandedCursor = clampCursor(text, cursorInput); + const segments = splitPromptIntoComposerSegments(text); + if (segments.length === 0) { + return expandedCursor; + } + + let remaining = expandedCursor; + let collapsedCursor = 0; + + for (const segment of segments) { + if (segment.type === "mention") { + const expandedLength = segment.path.length + 1; + if (remaining === 0) { + return collapsedCursor; + } + if (remaining <= expandedLength) { + return collapsedCursor + 1; + } + remaining -= expandedLength; + collapsedCursor += 1; + continue; + } + + const segmentLength = segment.text.length; + if (remaining <= segmentLength) { + return collapsedCursor + remaining; + } + remaining -= segmentLength; + collapsedCursor += segmentLength; + } + + return collapsedCursor; +} + export function isCollapsedCursorAdjacentToMention( text: string, cursorInput: number, @@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention( return false; } - const cursor = clampCollapsedComposerCursor(segments, cursorInput); + const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput); let collapsedOffset = 0; for (const segment of segments) {