diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index df13d76c2a4f..0e452fd4ac26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -137,7 +137,7 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem?: boolean, ) => boolean; @@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ? ("ArrowDown" as const) : event.key === "ArrowUp" ? ("ArrowUp" as const) - : null; + : event.key === "Escape" + ? ("Escape" as const) + : null; if (!key) return false; const handled = handler(key, event); if (handled) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3fe6e740fb9f..7b460e44e4ca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -124,6 +124,7 @@ import { import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; +import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { @@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); + const { + trigger: composerTrigger, + setTrigger: setComposerTrigger, + resolveTrigger: resolveComposerTrigger, + dismissTrigger: dismissComposerTrigger, + resetTrigger: resetComposerTrigger, + } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); // Active ArrowUp recall. Cleared on edit and on thread switch. const promptHistoryPositionRef = useRef(null); @@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + setComposerTrigger, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = prompt; const { cursor, trigger } = composerStateAtPromptEnd(prompt); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); } lastSyncedPendingInputRef.current = null; return; @@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = nextCustomAnswer; const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); setComposerCursor(cursor); - setComposerTrigger(trigger); + resetComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, @@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingUserInput?.requestId, prompt, promptRef, + resetComposerTrigger, ]); // ------------------------------------------------------------------ @@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerSubmissionError(null); setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); + }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, composerDraftTarget, composerTerminalContexts, setComposerDraftTerminalContexts, @@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, + setComposerTrigger, ], ); @@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: resolveComposerTrigger( + detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + ), }; - }, [readComposerSnapshot]); + }, [readComposerSnapshot, resolveComposerTrigger]); const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( @@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(null); setComposerHighlightedItemId(null); }, - [composerDraftTarget, promptRef, setComposerDraftPrompt], + [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], ); const navigatePromptHistory = useCallback( @@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, isTaskItem = false, ) => { @@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = 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) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; @@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, promptRef, setComposerDraftPrompt, + setComposerTrigger, takeStashEntry, importContextRecords, ], @@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) clearComposerDraftPromptAndImages, clearComposerDraftTerminalContexts, setComposerDraftPrompt, + setComposerTrigger, composerDraftTarget, composerFilesRef, composerImagesRef, @@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); setComposerHighlightedItemId(null); setComposerCursor(cursor); - setComposerTrigger( + resetComposerTrigger( options?.detectTrigger ? detectComposerTrigger( promptForState, @@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerModelPickerOpen, openModelPicker, readComposerSnapshot, + resetComposerTrigger, + setComposerTrigger, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx new file mode 100644 index 000000000000..6daf88bfbd67 --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx @@ -0,0 +1,145 @@ +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { detectComposerTrigger } from "../../composer-logic"; +import { useComposerTriggerState } from "./useComposerTriggerState"; + +const command = "pnpm install -g @openai/codex@latest"; +const initialPrompt = "pnpm install -g @openai"; +let root: Root; +let composer: ReturnType; + +function ComposerProbe() { + const state = useComposerTriggerState(() => + detectComposerTrigger(initialPrompt, initialPrompt.length), + ); + useLayoutEffect(() => { + composer = state; + }); + return null; +} + +async function updatePrompt(text: string, cursor = text.length) { + await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); +} + +beforeEach(async () => { + // The probe renders no DOM nodes, but ReactDOM still needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer suggestion dismissal", () => { + it("closes suggestions and rejects keyboard selection before the next render", async () => { + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + expect(composer.trigger).toEqual(candidate); + + await act(() => { + composer.dismissTrigger(candidate); + expect(composer.resolveTrigger(candidate)).toBeNull(); + }); + expect(composer.trigger).toBeNull(); + }); + + it("stays dismissed while typing a scoped package, including its second @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { + const text = command.slice(0, cursor); + await updatePrompt(text); + expect(composer.trigger).toBeNull(); + expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull(); + } + }); + + it("stays dismissed while deleting characters or moving within the same word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + + for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { + await updatePrompt(initialPrompt, cursor); + expect(composer.trigger).toBeNull(); + await updatePrompt(initialPrompt.slice(0, cursor)); + expect(composer.trigger).toBeNull(); + } + }); + + it("opens suggestions for a new @ word after a space", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} `); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + expect(composer.trigger?.rangeStart).toBe(command.length + 1); + }); + + it("opens a different token when the caret moves directly to it", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${command} @src`); + + expect(composer.trigger?.query).toBe("src"); + }); + + it("can reopen after the caret leaves the dismissed word", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(initialPrompt, 0); + await updatePrompt(initialPrompt); + + expect(composer.trigger?.query).toBe("openai"); + }); + + it("can reopen at the same position after deleting and retyping @", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); + await updatePrompt(prefix); + await updatePrompt(`${prefix}@`); + + expect(composer.trigger?.query).toBe(""); + }); + + it("clears dismissal when switching drafts or pending questions", async () => { + await act(() => composer.dismissTrigger(composer.trigger)); + const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); + await act(() => composer.resetTrigger(candidate)); + + expect(composer.trigger).toEqual(candidate); + expect(composer.resolveTrigger(candidate)).toEqual(candidate); + }); + + it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await updatePrompt(text); + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt(`${text}x`); + + expect(composer.trigger).toBeNull(); + await updatePrompt("@src"); + expect(composer.trigger?.kind).toBe("path"); + }); +}); diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts new file mode 100644 index 000000000000..255a8c43735c --- /dev/null +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -0,0 +1,42 @@ +import { useCallback, useRef, useState } from "react"; + +import type { ComposerTrigger } from "../../composer-logic"; + +/** Keep a dismissed suggestion closed until the caret leaves its token. */ +export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { + const [trigger, setActiveTrigger] = useState(initialTrigger); + const dismissedTriggerRef = useRef(null); + + const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => { + const dismissed = dismissedTriggerRef.current; + return candidate && + dismissed && + candidate.kind === dismissed.kind && + candidate.rangeStart === dismissed.rangeStart + ? null + : candidate; + }, []); + + const setTrigger = useCallback( + (candidate: ComposerTrigger | null) => { + const activeTrigger = resolveTrigger(candidate); + if (candidate === null || activeTrigger !== null) { + dismissedTriggerRef.current = null; + } + setActiveTrigger(activeTrigger); + }, + [resolveTrigger], + ); + + const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = candidate; + setActiveTrigger(null); + }, []); + + const resetTrigger = useCallback((candidate: ComposerTrigger | null) => { + dismissedTriggerRef.current = null; + setActiveTrigger(candidate); + }, []); + + return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; +}