diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 58c5b107644fb..4b0ad2daa7dc5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -7,6 +7,7 @@ import { Node, wrappingInputRule, type JSONContent, + type TiptapEditorHTMLElement, } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; @@ -976,36 +977,15 @@ function ComposerPromptEditorTiptapInner( const expandEditorCursor = (text: string, position: number) => literalText ? position : expandCollapsedComposerCursor(text, position); - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const onCommandKeyDownRef = useRef(onCommandKeyDown); - const isPathQueryActiveRef = useRef(isPathQueryActive); - const buildFragmentRef = useRef(buildContextClipboardFragment); - const importFragmentRef = useRef(importContextFragment); const latestValueRef = useRef(value); - const editorHolder = useRef(null); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - useEffect(() => { - onCommandKeyDownRef.current = onCommandKeyDown; - }, [onCommandKeyDown]); - useEffect(() => { - isPathQueryActiveRef.current = isPathQueryActive; - }, [isPathQueryActive]); - useEffect(() => { - buildFragmentRef.current = buildContextClipboardFragment; - }, [buildContextClipboardFragment]); - useEffect(() => { - importFragmentRef.current = importContextFragment; - }, [importContextFragment]); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); + // The editor config is built once, so its handlers reach the current callbacks through here. + const readCallbacks = useStableCallback(() => ({ + onChange, + onVisibleSelectionChange, + onCommandKeyDown, + isPathQueryActive, + importContextFragment, + })); // Stable so a skills refresh does not rerun the controlled-value effect. const skillLabelFor = useStableCallback((name: string) => composerSkillMeta(skills, name)); @@ -1074,7 +1054,7 @@ function ComposerPromptEditorTiptapInner( if (isApplyingControlledUpdateRef.current) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); + readCallbacks().onVisibleSelectionChange?.(); } return; } @@ -1096,7 +1076,13 @@ function ComposerPromptEditorTiptapInner( [updated.state.selection.$from.nodeBefore, updated.state.selection.$from.nodeAfter].some( (node) => node?.isInline && node.isAtom && !node.isText && node.type.name !== "hardBreak", ); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); + readCallbacks().onChange( + nextValue, + nextCursor, + nextExpandedCursor, + suppressTrigger, + contextIds, + ); }; const editorAttributes = { @@ -1301,9 +1287,9 @@ function ComposerPromptEditorTiptapInner( return true; } } - const handler = onCommandKeyDownRef.current; + const handler = readCallbacks().onCommandKeyDown; if (event.key === "Enter") { - const instance = editorHolder.current; + const instance = (view.dom as TiptapEditorHTMLElement).editor; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); const isListItem = richText && (instance?.isActive("listItem") ?? false); const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; @@ -1390,7 +1376,7 @@ function ComposerPromptEditorTiptapInner( ); return true; } - const importFragment = importFragmentRef.current; + const importFragment = readCallbacks().importContextFragment; let text = !literalText && importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1412,8 +1398,8 @@ function ComposerPromptEditorTiptapInner( const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); const literalLength = literalText ? 0 - : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); - const editorInstance = editorHolder.current; + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; if (editorInstance) { const $paste = view.state.selection.$from; const nested = ["listItem", "taskItem", "blockquote"].some((name) => @@ -1476,10 +1462,6 @@ function ComposerPromptEditorTiptapInner( editor?.setEditable(!disabled); }, [disabled, editor]); - useEffect(() => { - editorHolder.current = editor; - }, [editor]); - // Push attributes before layout measurements; Tiptap updates them later in a passive effect. useLayoutEffect(() => { if (!editor?.isInitialized) return; @@ -1539,7 +1521,10 @@ function ComposerPromptEditorTiptapInner( onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value); }); + // The one place the controlled value reaches the editor. Imperative handlers compare against + // the committed value, so it is recorded here even before the editor exists. useLayoutEffect(() => { + latestValueRef.current = value; if (!editor) return; const initialSelection = !hasAppliedControlledSelectionRef.current; hasAppliedControlledSelectionRef.current = true; @@ -1665,7 +1650,7 @@ function ComposerPromptEditorTiptapInner( start: snapshotRef.current.expandedCursor, end: snapshotRef.current.expandedCursor, }; - onChangeRef.current( + readCallbacks().onChange( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, @@ -1756,7 +1741,7 @@ function ComposerPromptEditorTiptapInner( ); const handleCopyCut = (event: React.ClipboardEvent, cut: boolean) => { - const build = buildFragmentRef.current; + const build = buildContextClipboardFragment; if (!editor || (cut && !editor.isEditable)) return; const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e8c8256467bf8..9932df357a194 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -295,7 +295,7 @@ import { usePreparingComposerImages, } from "./preparingComposerImages"; import { ComposerControlsBar } from "./ComposerControlsBar"; -import { useComposerCommandKey } from "./useComposerCommandKey"; +import { composerCommandKeyHandler } from "./composerCommandKey"; function consumePasteAsTextShortcut(deadline: { current: number }): boolean { const armed = Date.now() <= deadline.current; @@ -1096,7 +1096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); - const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); + const [hasWrappedPrompt, observeWrappedPrompt] = useComposerMultilinePrompt(); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); // A pending question stays expanded: its card moves into the banner dock, @@ -2234,43 +2234,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); - const { onComposerCommandKey } = useComposerCommandKey({ - keybindings, - sendShortcut: settings.sendShortcut, - routeKind, - phase, - isCompacting, - followUpBehavior, - isSoftwareKeyboardOpen, - planModeUiEnabled, - isLiteralPendingAnswer, - pendingUserInputs, - promptHistoryMessages, - promptHistoryPositionRef, - composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, - composerPreviewAnnotations, - composerReviewComments, - composerEditorRef, - composerMenuOpenRef, - composerMenuItemsRef, - activeComposerMenuItemRef, - setComposerDraftPrompt, - setComposerCursor, - setComposerTrigger, - setComposerHighlightedItemId, - dismissComposerTrigger, - toggleInteractionMode, - nudgeComposerMenuHighlight, - onSelectComposerItem, - resolveActiveComposerTrigger, - readComposerSnapshot, - applyPromptReplacement, - submitComposer, - }); + const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem?: boolean) => + composerCommandKeyHandler({ + keybindings, + sendShortcut: settings.sendShortcut, + routeKind, + phase, + isCompacting, + followUpBehavior, + isSoftwareKeyboardOpen, + planModeUiEnabled, + isLiteralPendingAnswer, + pendingUserInputs, + promptHistoryMessages, + promptHistoryPositionRef, + composerDraftTarget, + promptRef, + composerImagesRef, + composerFilesRef, + composerTerminalContextsRef, + composerPreviewAnnotations, + composerReviewComments, + composerEditorRef, + composerMenuOpenRef, + composerMenuItemsRef, + activeComposerMenuItemRef, + setComposerDraftPrompt, + setComposerCursor, + setComposerTrigger, + setComposerHighlightedItemId, + dismissComposerTrigger, + toggleInteractionMode, + nudgeComposerMenuHighlight, + onSelectComposerItem, + resolveActiveComposerTrigger, + readComposerSnapshot, + applyPromptReplacement, + submitComposer, + })(key, event, isTaskItem); const { stashQueue, @@ -3653,7 +3654,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null}
{ + setComposerMenuAnchor(node); + const stopObserving = observeWrappedPrompt(node); + return () => { + setComposerMenuAnchor(null); + stopObserving?.(); + }; + }} data-chat-composer-body="true" className={cn( "relative px-3 pb-2 sm:px-4", diff --git a/apps/web/src/components/chat/useComposerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts similarity index 95% rename from apps/web/src/components/chat/useComposerCommandKey.ts rename to apps/web/src/components/chat/composerCommandKey.ts index c4166b4370530..477485472914d 100644 --- a/apps/web/src/components/chat/useComposerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -21,13 +21,14 @@ import { type PreviewAnnotationPayload } from "@t3tools/contracts"; import { type ReviewCommentContext } from "../../reviewCommentContext"; import { composerTargetKey } from "../../composerDraftStore"; import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; -import { type Dispatch, type RefObject, type SetStateAction, useLayoutEffect, useRef } from "react"; +import type { Dispatch, RefObject, SetStateAction } from "react"; /** * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. + * Returns the editor's key handler; it reads the inputs captured by the render that built it. */ -export function useComposerCommandKey(input: { +export function composerCommandKeyHandler(input: { readonly keybindings: ChatComposerProps["keybindings"]; readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; readonly routeKind: ChatComposerProps["routeKind"]; @@ -116,12 +117,6 @@ export function useComposerCommandKey(input: { applyPromptReplacement, submitComposer, } = input; - // Entries are built on the keypress, not per render: the timeline changes - // on every streamed delta and ArrowUp is rare. - const promptHistoryMessagesRef = useRef(promptHistoryMessages); - useLayoutEffect(() => { - promptHistoryMessagesRef.current = promptHistoryMessages; - }); // The composer persists across threads. A recall from thread A must not // be treated as active in thread B, where the text-match fallback could @@ -164,7 +159,8 @@ export function useComposerCommandKey(input: { } const step = stepComposerPromptHistory({ direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + // Built on the keypress, not per render: the timeline changes on every streamed delta. + entries: buildComposerPromptHistoryEntries(promptHistoryMessages), position, currentPrompt: promptRef.current, }); @@ -258,7 +254,5 @@ export function useComposerCommandKey(input: { return false; }; - return { - onComposerCommandKey, - }; + return onComposerCommandKey; } diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index b5025b1becf9b..c09c371aa997a 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from "react"; +import { useState } from "react"; import { observeResize } from "../../lib/observeResize"; function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { @@ -19,10 +19,13 @@ function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { return bounds.height > lineHeight + 1 || bounds.width > expandedWidth + 1; } -export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { +/** Whether the prompt wraps, and the ref callback that measures it on the composer body. */ +export function useComposerMultilinePrompt(): readonly [ + boolean, + (body: HTMLElement | null) => (() => void) | undefined, +] { const [isMultiline, setIsMultiline] = useState(false); - - useLayoutEffect(() => { + const observeBody = (body: HTMLElement | null) => { if (!body) return; const measure = () => { const next = measureComposerMultilinePrompt(body); @@ -37,7 +40,6 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { stopObserving(); mutationObserver.disconnect(); }; - }, [body]); - - return isMultiline; + }; + return [isMultiline, observeBody] as const; }