From 9f35647e32605b62e7b030d45e6b064d39319360 Mon Sep 17 00:00:00 2001 From: Patrik Simms Date: Fri, 21 Aug 2026 16:26:15 +0200 Subject: [PATCH 1/3] fix(web): answer agent questions with a plain text composer Summary: - Add a plainText mode to ComposerPromptEditor: the value renders as raw text with no mention/skill/terminal-context chips and none of the inline-token plugins mounted. With no token nodes, collapsed and expanded cursor offsets coincide, so cursor mapping reduces to a raw clamp. The Lexical editor remounts when the mode flips, which also keeps undo history from leaking between an answer and the main draft. - Enable plainText in ChatComposer while a pending question is active: trigger menus (skills, slash commands, file paths) stay closed over answers, cursor state uses raw offsets, and composer state is re-derived from the regular draft when the question ends. Rationale: - Pending question responses only send plain answer strings; nothing is invoked. Rendering `$skill` syntax as tokens desynced Lexical cursor state (recursive update crashes), left the picker open after submit, and leaked answers into the next question. Treating the whole answer as plain text, as mobile already does, removes the bug class instead of special-casing skill tokens, and leaves the shared tokenizer and cursor logic untouched. - Alternative to the includeSkillTokens flag threading in PR #7812. Tests: - vp test run on composer-logic, composer-editor-mentions, ComposerPromptEditor, composerSubmission, and ComposerPendingUserInputPanel suites (77 passed) - web typecheck (tsgo) and vp lint on both changed files Closes #7805 AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-fable-5 --- .../src/components/ComposerPromptEditor.tsx | 201 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 55 +++-- 2 files changed, 165 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..fcdc93d490d8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -822,12 +822,18 @@ function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, skillMetadata: ReadonlyMap, + plainText: boolean, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); + if (plainText) { + $appendTextWithLineBreaks(paragraph, prompt); + return; + } + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { if (segment.type === "mention") { @@ -882,6 +888,13 @@ interface ComposerPromptEditorProps { cursor: number; terminalContexts: ReadonlyArray; skills: ReadonlyArray; + /** + * Renders the value as raw text: no mention/skill/terminal-context tokens + * and no token plugins. Used for pending-question answers, which only ever + * submit plain strings. With no token nodes, collapsed and expanded cursor + * offsets coincide, so all cursor mapping reduces to a raw clamp. + */ + plainText?: boolean; disabled: boolean; placeholder: string; className?: string; @@ -1330,7 +1343,13 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + // This plugin only renders in rich (tokenized) mode. + $setComposerEditorPrompt( + nextValue, + terminalContextsRef.current, + skillMetadataRef.current, + false, + ); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, @@ -1531,6 +1550,7 @@ function ComposerPromptEditorInner({ cursor, terminalContexts, skills, + plainText = false, disabled, placeholder, className, @@ -1541,8 +1561,14 @@ function ComposerPromptEditorInner({ editorRef, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); + // Plain mode has no token nodes, so every collapsed<->expanded cursor + // mapping is a raw clamp. The editor remounts when the mode flips (the + // LexicalComposer key includes it), so these stay stable per mount. + const clampComposerCursor = plainText ? clampExpandedCursor : clampCollapsedComposerCursor; + const expandComposerCursor = plainText ? clampExpandedCursor : expandCollapsedComposerCursor; + const collapseComposerCursor = plainText ? clampExpandedCursor : collapseExpandedComposerCursor; const onChangeRef = useRef(onChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); + const initialCursor = clampComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); @@ -1551,7 +1577,7 @@ function ComposerPromptEditorInner({ const snapshotRef = useRef({ value, cursor: initialCursor, - expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + expandedCursor: expandComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); @@ -1573,7 +1599,7 @@ function ComposerPromptEditorInner({ }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const normalizedCursor = clampComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; const skillsChanged = skillsSignatureRef.current !== skillsSignature; @@ -1589,7 +1615,7 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value, cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + expandedCursor: expandComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; @@ -1606,7 +1632,7 @@ function ComposerPromptEditorInner({ const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current, plainText); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1615,13 +1641,23 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); + }, [ + clampComposerCursor, + cursor, + editor, + expandComposerCursor, + plainText, + skillsSignature, + terminalContexts, + terminalContextsSignature, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus({ preventScroll: true }); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); @@ -1629,7 +1665,7 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + expandedCursor: expandComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( @@ -1640,7 +1676,7 @@ function ComposerPromptEditorInner({ snapshotRef.current.terminalContextIds, ); }, - [editor], + [clampComposerCursor, editor, expandComposerCursor], ); const readSnapshot = useCallback((): { @@ -1652,8 +1688,8 @@ function ComposerPromptEditorInner({ let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( + const fallbackCursor = clampComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); @@ -1675,7 +1711,7 @@ function ComposerPromptEditorInner({ }); snapshotRef.current = snapshot; return snapshot; - }, [editor]); + }, [clampComposerCursor, editor]); useImperativeHandle( editorRef, @@ -1686,65 +1722,66 @@ function ComposerPromptEditorInner({ focusAt, focusAtEnd: () => { focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), + collapseComposerCursor(snapshotRef.current.value, snapshotRef.current.value.length), ); }, readSnapshot, }), - [focusAt, readSnapshot], + [collapseComposerCursor, focusAt, readSnapshot], ); - const handleEditorChange = useCallback((editorState: EditorState) => { - editorState.read(() => { - const nextValue = $getRoot().getTextContent(); - 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 terminalContextIds = collectTerminalContextIds($getRoot()); - const previousSnapshot = snapshotRef.current; - if ( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) - ) { - return; - } - if (isApplyingControlledUpdateRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - terminalContextIds, - }; - const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - terminalContextIds, - ); - }); - }, []); + const handleEditorChange = useCallback( + (editorState: EditorState) => { + editorState.read(() => { + const nextValue = $getRoot().getTextContent(); + const fallbackCursor = clampComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampComposerCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackCursor), + ); + const fallbackExpandedCursor = clampExpandedCursor( + nextValue, + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), + ); + const terminalContextIds = collectTerminalContextIds($getRoot()); + const previousSnapshot = snapshotRef.current; + if ( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.terminalContextIds.length === terminalContextIds.length && + previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + ) { + return; + } + if (isApplyingControlledUpdateRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + terminalContextIds, + }; + const cursorAdjacentToMention = + !plainText && + (isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right")); + onChangeRef.current( + nextValue, + nextCursor, + nextExpandedCursor, + cursorAdjacentToMention, + terminalContextIds, + ); + }); + }, + [clampComposerCursor, plainText], + ); return ( @@ -1774,13 +1811,17 @@ function ComposerPromptEditorInner({ /> - + {plainText ? null : ( + <> + + + + + + + + )} - - - - - @@ -1792,6 +1833,7 @@ export function ComposerPromptEditor({ cursor, terminalContexts, skills, + plainText = false, disabled, placeholder, className, @@ -1801,9 +1843,17 @@ export function ComposerPromptEditor({ onPaste, editorRef, }: ComposerPromptEditorProps) { + // The editor remounts when plainText flips (see the LexicalComposer key), so + // these refs must track the latest props for the initial editorState to seed + // the remounted editor with the current value, not the first-ever one. const initialValueRef = useRef(value); + initialValueRef.current = value; const initialTerminalContextsRef = useRef(terminalContexts); + initialTerminalContextsRef.current = terminalContexts; const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); + initialSkillMetadataRef.current = skillMetadataByName(skills); + const initialPlainTextRef = useRef(plainText); + initialPlainTextRef.current = plainText; const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", @@ -1814,6 +1864,7 @@ export function ComposerPromptEditor({ initialValueRef.current, initialTerminalContextsRef.current, initialSkillMetadataRef.current, + initialPlainTextRef.current, ); }, onError: (error) => { @@ -1824,12 +1875,16 @@ export function ComposerPromptEditor({ ); return ( - + collapseExpandedComposerCursor(prompt, prompt.length), ); @@ -1161,7 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) workspaceEntries.entries, ]); - const composerMenuOpen = Boolean(composerTrigger); + const composerMenuOpen = Boolean(composerTrigger) && !plainAnswerMode; const composerMenuSearchKey = composerTrigger ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; @@ -1434,7 +1439,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + const pendingInputEnded = lastSyncedPendingInputRef.current !== null; lastSyncedPendingInputRef.current = null; + if (pendingInputEnded) { + // The question is gone; hand the composer back to the regular draft + // with token-aware cursor state. + promptRef.current = prompt; + setComposerCursor(collapseExpandedComposerCursor(prompt, prompt.length)); + setComposerTrigger(detectComposerTrigger(prompt, prompt.length)); + setComposerHighlightedItemId(null); + } return; } @@ -1455,19 +1469,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); + // Plain answer mode: cursor offsets are raw because the editor renders no + // inline tokens, and trigger menus never open. + setComposerCursor(nextCustomAnswer.length); + setComposerTrigger(null); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); @@ -1478,10 +1489,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setComposerCursor( + plainAnswerMode + ? promptRef.current.length + : collapseExpandedComposerCursor(promptRef.current, promptRef.current.length), + ); + setComposerTrigger( + plainAnswerMode ? null : detectComposerTrigger(promptRef.current, promptRef.current.length), + ); setIsDragOverComposer(false); - }, [draftId, activeThreadId, promptRef]); + }, [draftId, activeThreadId, plainAnswerMode, promptRef]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -1610,9 +1627,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) => { if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); + // Plain answer mode: no trigger menus over question answers. + setComposerTrigger(null); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, @@ -1725,9 +1741,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: plainAnswerMode + ? null + : detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; - }, [readComposerSnapshot]); + }, [plainAnswerMode, readComposerSnapshot]); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { @@ -3220,7 +3238,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={plainAnswerMode ? [] : (selectedProviderStatus?.skills ?? [])} + plainText={plainAnswerMode} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} From fdea833aa568f68daf2549c9b6a03dd900389d02 Mon Sep 17 00:00:00 2001 From: Patrik Simms Date: Fri, 21 Aug 2026 17:26:12 +0200 Subject: [PATCH 2/3] fix(web): keep answer caret and focus stable while a question is open Summary: - Add a draftPromptRef in ChatComposer that always tracks the draft prompt; promptRef keeps meaning "what the editor shows" (the answer text while a question is open). - Ref-sync and pending-sync effects no longer clamp or reset the answer caret when the draft changes underneath an open question; the question-ended handoff reads the draft from the ref instead of a `prompt` effect dep. - Trait toggles and stash restore now write the parked draft without moving the answer caret or stealing focus; stash restore also appends to the actual draft instead of the visible answer text. - ComposerPromptEditor records focus ownership when the plainText flip unmounts the editor and refocuses the remounted instance, so a question opening or resolving mid-typing no longer drops focus. Rationale: - PR #7818 review bots (Cursor Bugbot, Macroscope) flagged two real regressions: draft writes yanked the caret to the end of the answer, and the keyed LexicalComposer remount silently dropped focus (closing the keyboard on mobile). - Kept the deliberate remount and restored focus across it instead of rewriting editor state in place; smaller change, same behavior. Tests: - vp test run src/components/ComposerPromptEditor.test.ts (5 passed) - tsc --noEmit on apps/web clean; oxlint on both files clean - Focus behavior not covered by jsdom tests (needs real browser focus semantics); not verified in a browser AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-fable-5 --- .../src/components/ComposerPromptEditor.tsx | 27 +++++++- apps/web/src/components/chat/ChatComposer.tsx | 61 +++++++++++++++---- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index fcdc93d490d8..5557c3a55fc9 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1559,7 +1559,10 @@ function ComposerPromptEditorInner({ onCommandKeyDown, onPaste, editorRef, -}: ComposerPromptEditorProps) { + restoreFocusOnRemountRef, +}: ComposerPromptEditorProps & { + restoreFocusOnRemountRef: React.RefObject; +}) { const [editor] = useLexicalComposerContext(); // Plain mode has no token nodes, so every collapsed<->expanded cursor // mapping is a raw clamp. The editor remounts when the mode flips (the @@ -1679,6 +1682,24 @@ function ComposerPromptEditorInner({ [clampComposerCursor, editor, expandComposerCursor], ); + // The plainText flip remounts the editor (see the LexicalComposer key), + // replacing the focused contenteditable and silently dropping focus. Record + // focus ownership when this instance unmounts and take it back on the next + // mount, so a question opening or resolving mid-typing doesn't eat + // keystrokes. Every dep is stable for the lifetime of a mount, so this runs + // once per editor instance. + useLayoutEffect(() => { + if (restoreFocusOnRemountRef.current) { + restoreFocusOnRemountRef.current = false; + focusAt(snapshotRef.current.cursor); + } + return () => { + // Layout cleanup runs before the old DOM node is detached, so + // activeElement still points at it here. + restoreFocusOnRemountRef.current = editor.getRootElement() === document.activeElement; + }; + }, [editor, focusAt, restoreFocusOnRemountRef]); + const readSnapshot = useCallback((): { value: string; cursor: number; @@ -1854,6 +1875,9 @@ export function ComposerPromptEditor({ initialSkillMetadataRef.current = skillMetadataByName(skills); const initialPlainTextRef = useRef(plainText); initialPlainTextRef.current = plainText; + // Survives the plainText remount so the new editor instance knows whether + // its predecessor owned focus and should take it back. + const restoreFocusOnRemountRef = useRef(false); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", @@ -1891,6 +1915,7 @@ export function ComposerPromptEditor({ onChange={onChange} onPaste={onPaste} editorRef={editorRef} + restoreFocusOnRemountRef={restoreFocusOnRemountRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fece869c859b..1ad7defb47c1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -973,6 +973,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // menus stay closed. Mobile answers questions with a plain input for the // same reason. const plainAnswerMode = activePendingProgress !== null; + // While a question is open, promptRef tracks the answer text the editor is + // showing, not the draft. Draft-oriented writers (traits, stash restore) + // and the question-ended handoff read and write this ref instead so they + // never disturb the answer's caret. + const draftPromptRef = useRef(prompt); const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); @@ -1243,18 +1248,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const setPromptFromTraits = useCallback( (nextPrompt: string) => { + if (plainAnswerMode) { + // A trait toggle while a question is open edits the parked draft; the + // editor is showing the answer, so leave its caret and focus alone. + if (nextPrompt !== draftPromptRef.current) { + draftPromptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } + return; + } if (nextPrompt === promptRef.current) { scheduleComposerFocus(); return; } promptRef.current = nextPrompt; + draftPromptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + plainAnswerMode, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1365,9 +1386,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Sync refs back to parent // ------------------------------------------------------------------ useEffect(() => { + draftPromptRef.current = prompt; + // In plain answer mode the editor shows the question answer, so a draft + // write (trait toggle, stash restore, another device syncing the draft) + // must not clobber the answer text or clamp its caret against the draft. + // promptRef is restored from the draft when the question resolves. + if (plainAnswerMode) return; promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + }, [plainAnswerMode, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -1443,10 +1470,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) lastSyncedPendingInputRef.current = null; if (pendingInputEnded) { // The question is gone; hand the composer back to the regular draft - // with token-aware cursor state. - promptRef.current = prompt; - setComposerCursor(collapseExpandedComposerCursor(prompt, prompt.length)); - setComposerTrigger(detectComposerTrigger(prompt, prompt.length)); + // with token-aware cursor state. The draft is read from a ref instead + // of depending on `prompt` so draft writes while a question is open + // don't re-run this effect and yank the answer's caret to the end. + const draftPrompt = draftPromptRef.current; + promptRef.current = draftPrompt; + setComposerCursor(collapseExpandedComposerCursor(draftPrompt, draftPrompt.length)); + setComposerTrigger(detectComposerTrigger(draftPrompt, draftPrompt.length)); setComposerHighlightedItemId(null); } return; @@ -1478,7 +1508,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, - prompt, promptRef, ]); @@ -2062,7 +2091,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsStashMenuOpen(false); - const currentPrompt = promptRef.current; + // The restore always targets the draft, which is not what promptRef + // holds while a question is open (the answer text is showing then). + const currentPrompt = draftPromptRef.current; // An image-only stash must not append blank lines to whatever is // already in the composer. const nextPrompt = @@ -2073,10 +2104,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : entry.prompt; const promptChanged = nextPrompt !== currentPrompt; if (promptChanged) { - promptRef.current = nextPrompt; + draftPromptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); + // While a question is open the editor keeps showing the answer, so + // its caret must stay where the user left it. + if (!plainAnswerMode) { + promptRef.current = nextPrompt; + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } } let unrestoredImageNames: string[] = []; @@ -2145,7 +2181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Only yank the caret to the end when text was actually inserted; // restoring images alone should leave the user where they were typing. - if (promptChanged) { + if (promptChanged && !plainAnswerMode) { window.requestAnimationFrame(() => { composerEditorRef.current?.focusAtEnd(); }); @@ -2155,6 +2191,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftImages, composerDraftTarget, composerImagesRef, + plainAnswerMode, promptRef, setComposerDraftPrompt, takeStashEntry, From 96746625340bb96fb33f605e187459a67cc081ce Mon Sep 17 00:00:00 2001 From: Patrik Simms Date: Fri, 21 Aug 2026 17:45:22 +0200 Subject: [PATCH 3/3] fix(web): settle remount caret at end and keep surround-typing in answers Summary: - The focus restore after a plainText remount now places the caret at the end of the current value instead of the snapshot cursor, which was seeded from the previous mode's stale cursor prop. Both mode transitions settle the caret at the end anyway, so this removes the brief window where a keystroke could land at a stale offset. - ComposerSurroundSelectionPlugin is mounted in both modes: wrapping a selection by typing brackets/quotes is plain typing behavior, not token behavior. The plugin takes a plainText prop and uses raw cursor clamps in plain mode; the mention-boundary guard is skipped there so answer text that merely looks like a mention still wraps. Rationale: - Follow-up to PR #7818 bot re-review: Cursor Bugbot flagged the stale caret on mode remount, Macroscope flagged the surround-typing regression for question answers introduced by the plugin gate. Tests: - vp test run src/components/ComposerPromptEditor.test.ts (5 passed) - tsc --noEmit on apps/web clean; oxlint on both touched files clean AI-Assisted-By: Codex AI-Assisted: true AI-Agent: claude-code AI-Model: anthropic/claude-fable-5 --- .../src/components/ComposerPromptEditor.tsx | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 5557c3a55fc9..9a9855c99977 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1276,8 +1276,15 @@ function ComposerInlineTokenPastePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; + plainText: boolean; }) { const [editor] = useLexicalComposerContext(); + // Surround-typing is not token behavior, so this plugin renders in both + // modes. In plain mode there are no token nodes: cursor mapping is a raw + // clamp and the mention-boundary guard would false-positive on answer text + // that merely looks like a mention, so it is skipped. + const collapseCursor = props.plainText ? clampExpandedCursor : collapseExpandedComposerCursor; + const touchesMentionBoundary = props.plainText ? () => false : selectionTouchesMentionBoundary; const terminalContextsRef = useRef(props.terminalContexts); const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ @@ -1324,7 +1331,7 @@ function ComposerSurroundSelectionPlugin(props: { return null; } const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + if (touchesMentionBoundary(value, range.start, range.end)) { return null; } return { @@ -1343,17 +1350,13 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - // This plugin only renders in rich (tokenized) mode. $setComposerEditorPrompt( nextValue, terminalContextsRef.current, skillMetadataRef.current, - false, - ); - const selectionStart = collapseExpandedComposerCursor( - nextValue, - selectionSnapshot.expandedStart, + props.plainText, ); + const selectionStart = collapseCursor(nextValue, selectionSnapshot.expandedStart); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, selectionStart + inputData.length + selectedText.length, @@ -1399,7 +1402,7 @@ function ComposerSurroundSelectionPlugin(props: { return; } const value = $getRoot().getTextContent(); - if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + if (touchesMentionBoundary(value, range.start, range.end)) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; @@ -1480,7 +1483,7 @@ function ComposerSurroundSelectionPlugin(props: { pendingDeadKeySelection.expandedStart, pendingDeadKeySelection.expandedEnd, ); - const replacementStart = collapseExpandedComposerCursor( + const replacementStart = collapseCursor( currentValue, pendingDeadKeySelection.expandedStart, ); @@ -1691,14 +1694,19 @@ function ComposerPromptEditorInner({ useLayoutEffect(() => { if (restoreFocusOnRemountRef.current) { restoreFocusOnRemountRef.current = false; - focusAt(snapshotRef.current.cursor); + // Focus at the end: the snapshot cursor was seeded from the previous + // mode's stale cursor prop, while both mode transitions settle the + // caret at the end (answer end on question open, draft end on resolve). + // Placing it there directly leaves no window for keystrokes to land at + // a stale offset before the parent's effects run. + focusAt(collapseComposerCursor(snapshotRef.current.value, snapshotRef.current.value.length)); } return () => { // Layout cleanup runs before the old DOM node is detached, so // activeElement still points at it here. restoreFocusOnRemountRef.current = editor.getRootElement() === document.activeElement; }; - }, [editor, focusAt, restoreFocusOnRemountRef]); + }, [collapseComposerCursor, editor, focusAt, restoreFocusOnRemountRef]); const readSnapshot = useCallback((): { value: string; @@ -1832,9 +1840,16 @@ function ComposerPromptEditorInner({ /> + {/* Surround-typing (wrap a selection in brackets/quotes) is plain + typing behavior, not token behavior, so it stays mounted in both + modes; only the token-dependent plugins are mode-gated. */} + {plainText ? null : ( <> -