diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..9a9855c99977 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; @@ -1263,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<{ @@ -1311,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 { @@ -1330,11 +1350,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); - const selectionStart = collapseExpandedComposerCursor( + $setComposerEditorPrompt( nextValue, - selectionSnapshot.expandedStart, + terminalContextsRef.current, + skillMetadataRef.current, + props.plainText, ); + const selectionStart = collapseCursor(nextValue, selectionSnapshot.expandedStart); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, selectionStart + inputData.length + selectedText.length, @@ -1380,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; @@ -1461,7 +1483,7 @@ function ComposerSurroundSelectionPlugin(props: { pendingDeadKeySelection.expandedStart, pendingDeadKeySelection.expandedEnd, ); - const replacementStart = collapseExpandedComposerCursor( + const replacementStart = collapseCursor( currentValue, pendingDeadKeySelection.expandedStart, ); @@ -1531,6 +1553,7 @@ function ComposerPromptEditorInner({ cursor, terminalContexts, skills, + plainText = false, disabled, placeholder, className, @@ -1539,10 +1562,19 @@ 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 + // 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 +1583,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 +1605,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 +1621,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 +1638,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 +1647,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 +1671,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,9 +1682,32 @@ function ComposerPromptEditorInner({ snapshotRef.current.terminalContextIds, ); }, - [editor], + [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; + // 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; + }; + }, [collapseComposerCursor, editor, focusAt, restoreFocusOnRemountRef]); + const readSnapshot = useCallback((): { value: string; cursor: number; @@ -1652,8 +1717,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 +1740,7 @@ function ComposerPromptEditorInner({ }); snapshotRef.current = snapshot; return snapshot; - }, [editor]); + }, [clampComposerCursor, editor]); useImperativeHandle( editorRef, @@ -1686,65 +1751,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 +1840,24 @@ 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 : ( + <> + + + + + + + )} - - - - - @@ -1792,6 +1869,7 @@ export function ComposerPromptEditor({ cursor, terminalContexts, skills, + plainText = false, disabled, placeholder, className, @@ -1801,9 +1879,20 @@ 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; + // 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", @@ -1814,6 +1903,7 @@ export function ComposerPromptEditor({ initialValueRef.current, initialTerminalContextsRef.current, initialSkillMetadataRef.current, + initialPlainTextRef.current, ); }, onError: (error) => { @@ -1824,18 +1914,23 @@ export function ComposerPromptEditor({ ); return ( - + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0518bdabef2..1ad7defb47c1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -968,6 +968,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ + // Pending-question answers submit plain strings, so while a question is + // active the editor renders raw text (no inline tokens) and the trigger + // 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), ); @@ -1161,7 +1171,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; @@ -1238,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({ @@ -1360,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; @@ -1434,7 +1466,19 @@ 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. 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; } @@ -1455,14 +1499,10 @@ 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, @@ -1478,10 +1518,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 +1656,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 +1770,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) => { @@ -2044,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 = @@ -2055,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[] = []; @@ -2127,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(); }); @@ -2137,6 +2191,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftImages, composerDraftTarget, composerImagesRef, + plainAnswerMode, promptRef, setComposerDraftPrompt, takeStashEntry, @@ -3220,7 +3275,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}