diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index becc6beafdda..b74e691ddd13 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -64,16 +64,15 @@ import { type ComposerSubmissionIntent, collapseExpandedComposerCursor, composerRequiresModifier, - composerSubmissionIntentForKey, composerStateAtPromptEnd, detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, isPathSuggestionCurrent, + extendReplacementRangeForTrailingSpace, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -259,11 +258,7 @@ import { type Thread, type ThreadShell, } from "../../types"; -import { - buildComposerPromptHistoryEntries, - stepComposerPromptHistory, - type ComposerPromptHistoryPosition, -} from "./composerPromptHistory"; +import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { LatestProposedPlanState, @@ -296,6 +291,7 @@ import { useComposerStash } from "./useComposerStash"; import { useComposerContextImport } from "./useComposerContextImport"; import { useComposerAttachmentIntake } from "./useComposerAttachmentIntake"; import { ComposerControlsBar } from "./ComposerControlsBar"; +import { useComposerCommandKey } from "./useComposerCommandKey"; /** Reads and disarms a paste-as-text deadline: the shortcut applies to one paste only. */ function consumePasteAsTextShortcut(deadline: { current: number }): boolean { @@ -304,17 +300,6 @@ function consumePasteAsTextShortcut(deadline: { current: number }): boolean { return armed; } -const extendReplacementRangeForTrailingSpace = ( - text: string, - rangeEnd: number, - replacement: string, -): number => { - if (!replacement.endsWith(" ")) { - return rangeEnd; - } - return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; -}; - const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); export interface ChatComposerHandle { @@ -2258,148 +2243,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); - // 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; + 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, }); - // 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 - // otherwise turn B's own draft into a browsing position. - const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); - - const replacePromptFromHistory = (nextPrompt: string) => { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - setComposerHighlightedItemId(null); - }; - - const navigatePromptHistory = ( - direction: "backward" | "forward", - event: KeyboardEvent, - ): boolean => { - if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - if (pendingUserInputs.length > 0) return false; - if ( - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerPreviewAnnotations.length > 0 || - composerReviewComments.length > 0 - ) { - return false; - } - const recalled = promptHistoryPositionRef.current; - const position = recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null; - if (position === null && promptRef.current.length > 0) { - return false; - } - const editor = composerEditorRef.current; - if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { - return false; - } - const step = stepComposerPromptHistory({ - direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), - position, - currentPrompt: promptRef.current, - }); - if (!step) return false; - promptHistoryPositionRef.current = step.position - ? { targetKey: promptHistoryTargetKey, position: step.position } - : null; - replacePromptFromHistory(step.prompt); - return true; - }; - - const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => { - const submissionIntent = composerSubmissionIntentForKey({ - event, - keybindings, - isSoftwareKeyboardOpen, - isDraftThread: routeKind === "draft", - isRunning: phase === "running", - sendShortcut: settings.sendShortcut, - prompt: promptRef.current, - }); - if (key === "Tab" && event.shiftKey && submissionIntent === null) { - if (!planModeUiEnabled) return false; - toggleInteractionMode(); - return true; - } - const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = - !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); - if (key === "Escape") { - if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; - dismissComposerTrigger(trigger); - composerMenuOpenRef.current = false; - return true; - } - if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { - const currentItems = composerMenuItemsRef.current; - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; - if (key === "ArrowDown" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowDown"); - return true; - } - if (key === "ArrowUp" && currentItems.length > 0) { - nudgeComposerMenuHighlight("ArrowUp"); - return true; - } - if ((key === "Enter" || key === "Tab") && selectedItem) { - onSelectComposerItem(selectedItem); - return true; - } - } - if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { - return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); - } - if (submissionIntent) { - submitComposer( - undefined, - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: submissionIntent === "alternate", - activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, - }), - submissionIntent, - ); - return true; - } - if (key === "Enter" && isTaskItem) return false; - if ( - !isLiteralPendingAnswer && - !event.isComposing && - (key === "Enter" || (key === "Tab" && !event.shiftKey)) - ) { - const selection = composerEditorRef.current?.readSelectionRange(); - const snapshot = readComposerSnapshot(); - if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { - const edit = - key === "Enter" - ? listContinuationForEnter(snapshot.value, selection.start) - : listIndentForTab(snapshot.value, selection.start, selection.end); - if ( - edit && - applyPromptReplacement(edit.start, edit.end, edit.replacement, { - expandedCursorAfterReplace: edit.cursorAfter, - }) - ) { - return true; - } - } - } - return false; - }; - const { stashQueue, isStashMenuOpen, diff --git a/apps/web/src/components/chat/useComposerCommandKey.ts b/apps/web/src/components/chat/useComposerCommandKey.ts new file mode 100644 index 000000000000..c4166b437053 --- /dev/null +++ b/apps/web/src/components/chat/useComposerCommandKey.ts @@ -0,0 +1,264 @@ +import type { ChatComposerProps } from "./ChatComposer"; +import { type ComposerCommandItem } from "./ComposerCommandMenu"; +import { + type ComposerDispatchMode, + resolveComposerDispatchMode, +} from "@t3tools/client-runtime/state/composer-dispatch"; +import { type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; +import { + type ComposerPromptHistoryPosition, + buildComposerPromptHistoryEntries, + stepComposerPromptHistory, +} from "./composerPromptHistory"; +import { + type ComposerSubmissionIntent, + type ComposerTrigger, + collapseExpandedComposerCursor, + composerSubmissionIntentForKey, +} from "../../composer-logic"; +import { type PendingUserInput } from "../../session-logic"; +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"; + +/** + * 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. + */ +export function useComposerCommandKey(input: { + readonly keybindings: ChatComposerProps["keybindings"]; + readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; + readonly routeKind: ChatComposerProps["routeKind"]; + readonly phase: ChatComposerProps["phase"]; + readonly isCompacting: boolean; + readonly followUpBehavior: ChatComposerProps["settings"]["followUpBehavior"]; + readonly isSoftwareKeyboardOpen: boolean; + readonly planModeUiEnabled: boolean; + readonly isLiteralPendingAnswer: boolean; + readonly pendingUserInputs: ReadonlyArray; + readonly promptHistoryMessages: ChatComposerProps["promptHistoryMessages"]; + readonly promptHistoryPositionRef: RefObject<{ + targetKey: string; + position: ComposerPromptHistoryPosition; + } | null>; + readonly composerDraftTarget: ChatComposerProps["composerDraftTarget"]; + readonly promptRef: ChatComposerProps["promptRef"]; + readonly composerImagesRef: ChatComposerProps["composerImagesRef"]; + readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; + readonly composerTerminalContextsRef: ChatComposerProps["composerTerminalContextsRef"]; + readonly composerPreviewAnnotations: ReadonlyArray; + readonly composerReviewComments: ReadonlyArray; + readonly composerEditorRef: RefObject; + readonly composerMenuOpenRef: RefObject; + readonly composerMenuItemsRef: RefObject; + readonly activeComposerMenuItemRef: RefObject; + readonly setComposerDraftPrompt: ( + target: ChatComposerProps["composerDraftTarget"], + prompt: string, + ) => void; + readonly setComposerCursor: Dispatch>; + readonly setComposerTrigger: (trigger: ComposerTrigger | null) => void; + readonly setComposerHighlightedItemId: Dispatch>; + readonly dismissComposerTrigger: (trigger: ComposerTrigger | null) => void; + readonly toggleInteractionMode: () => void; + readonly nudgeComposerMenuHighlight: (key: "ArrowDown" | "ArrowUp") => void; + readonly onSelectComposerItem: (item: ComposerCommandItem) => void; + readonly resolveActiveComposerTrigger: () => { trigger: ComposerTrigger | null }; + readonly readComposerSnapshot: () => { value: string }; + readonly applyPromptReplacement: ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { expandedCursorAfterReplace?: number }, + ) => boolean; + readonly submitComposer: ( + event: undefined, + dispatchMode: ComposerDispatchMode, + submissionIntent: ComposerSubmissionIntent, + ) => void; +}) { + const { + keybindings, + 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, + } = 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 + // otherwise turn B's own draft into a browsing position. + const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); + + const replacePromptFromHistory = (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }; + + const navigatePromptHistory = ( + direction: "backward" | "forward", + event: KeyboardEvent, + ): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (pendingUserInputs.length > 0) return false; + if ( + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + const recalled = promptHistoryPositionRef.current; + const position = recalled?.targetKey === promptHistoryTargetKey ? recalled.position : null; + if (position === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position + ? { targetKey: promptHistoryTargetKey, position: step.position } + : null; + replacePromptFromHistory(step.prompt); + return true; + }; + + const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => { + const submissionIntent = composerSubmissionIntentForKey({ + event, + keybindings, + isSoftwareKeyboardOpen, + isDraftThread: routeKind === "draft", + isRunning: phase === "running", + sendShortcut, + prompt: promptRef.current, + }); + if (key === "Tab" && event.shiftKey && submissionIntent === null) { + if (!planModeUiEnabled) return false; + toggleInteractionMode(); + return true; + } + const { trigger } = resolveActiveComposerTrigger(); + const menuIsActive = + !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); + if (key === "Escape") { + if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + dismissComposerTrigger(trigger); + composerMenuOpenRef.current = false; + return true; + } + if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { + const currentItems = composerMenuItemsRef.current; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + if (key === "ArrowDown" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowDown"); + return true; + } + if (key === "ArrowUp" && currentItems.length > 0) { + nudgeComposerMenuHighlight("ArrowUp"); + return true; + } + if ((key === "Enter" || key === "Tab") && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } + } + if ((key === "ArrowUp" || key === "ArrowDown") && submissionIntent === null) { + return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); + } + if (submissionIntent) { + submitComposer( + undefined, + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: submissionIntent === "alternate", + activeTurnDefault: followUpBehavior, + canSteer: !isCompacting, + }), + submissionIntent, + ); + return true; + } + if (key === "Enter" && isTaskItem) return false; + if ( + !isLiteralPendingAnswer && + !event.isComposing && + (key === "Enter" || (key === "Tab" && !event.shiftKey)) + ) { + const selection = composerEditorRef.current?.readSelectionRange(); + const snapshot = readComposerSnapshot(); + if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { + const edit = + key === "Enter" + ? listContinuationForEnter(snapshot.value, selection.start) + : listIndentForTab(snapshot.value, selection.start, selection.end); + if ( + edit && + applyPromptReplacement(edit.start, edit.end, edit.replacement, { + expandedCursorAfterReplace: edit.cursorAfter, + }) + ) { + return true; + } + } + } + return false; + }; + + return { + onComposerCommandKey, + }; +} diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 816d1144fee6..69d02eb3bf8d 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -19,6 +19,7 @@ import { composerStateAtPromptEnd, detectComposerTrigger, expandCollapsedComposerCursor, + extendReplacementRangeForTrailingSpace, formatAssistantCitationForComposer, isCollapsedCursorAdjacentToInlineToken, isPathSuggestionCurrent, @@ -551,3 +552,12 @@ describe("literal editor answer cursor state", () => { expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); }); }); + +describe("extendReplacementRangeForTrailingSpace", () => { + it("swallows one existing space after the range when the replacement ends with one", () => { + expect(extendReplacementRangeForTrailingSpace("@fi rest", 3, "@file ")).toBe(4); + expect(extendReplacementRangeForTrailingSpace("@firest", 3, "@file ")).toBe(3); + expect(extendReplacementRangeForTrailingSpace("@fi rest", 3, "@file")).toBe(3); + expect(extendReplacementRangeForTrailingSpace("@fi", 3, "@file ")).toBe(3); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index fa21194ba7af..7046abd6ed2f 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -359,6 +359,21 @@ export function parseStandaloneComposerSlashCommand( return "default"; } +/** + * A replacement ending in a space swallows one space already after the range, so inserting + * `@file ` before existing text does not leave a double space. + */ +export function extendReplacementRangeForTrailingSpace( + text: string, + rangeEnd: number, + replacement: string, +): number { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +} + export function replaceTextRange( text: string, rangeStart: number,