Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 27 additions & 42 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TiptapEditor | null>(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));
Expand Down Expand Up @@ -1074,7 +1054,7 @@ function ComposerPromptEditorTiptapInner(
if (isApplyingControlledUpdateRef.current) return;
if (!snapshotChanged) {
if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
onVisibleSelectionChangeRef.current?.();
readCallbacks().onVisibleSelectionChange?.();
}
return;
}
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1390,7 +1376,7 @@ function ComposerPromptEditorTiptapInner(
);
return true;
}
const importFragment = importFragmentRef.current;
const importFragment = readCallbacks().importContextFragment;
let text =
!literalText && importFragment
? importPastedComposerText(clipboardData, importFragment)
Expand All @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
88 changes: 48 additions & 40 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1096,7 +1096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
null,
);
const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3653,7 +3654,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : null}

<div
ref={setComposerMenuAnchor}
ref={(node) => {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -258,7 +254,5 @@ export function useComposerCommandKey(input: {
return false;
};

return {
onComposerCommandKey,
};
return onComposerCommandKey;
}
16 changes: 9 additions & 7 deletions apps/web/src/components/chat/useComposerMultilinePrompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useState } from "react";
import { useState } from "react";
import { observeResize } from "../../lib/observeResize";

function measureComposerMultilinePrompt(body: HTMLElement): boolean | null {
Expand All @@ -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);
Expand All @@ -37,7 +40,6 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean {
stopObserving();
mutationObserver.disconnect();
};
}, [body]);

return isMultiline;
};
return [isMultiline, observeBody] as const;
}
Loading