diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index c46b6ca8fc..f572c70aa6 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2791,6 +2791,120 @@ describe("PromptBoxInternal mention triggers", () => { replacement: "#42 Fix login bug", }; + it("applies the first result with Enter for a multiword mention query", async () => { + const { changes, onMentionQueryChange, onSubmit, promptBoxRef } = + renderPromptBox("Ask @fix login", { + mentionSuggestions: [githubIssueSuggestion], + }); + + await focusPromptEnd(promptBoxRef); + await waitFor(() => + expect(onMentionQueryChange).toHaveBeenCalledWith("fix login", "@"), + ); + await screen.findByRole("button", { name: /Fix login bug/u }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + + await waitFor(() => + expect(latestValue(changes)).toBe("Ask @#42 Fix login bug "), + ); + expect(latestChange(changes)?.mentions).toHaveLength(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("keeps a dismissed multiword occurrence closed as its query extends", async () => { + const { changes, promptBoxRef } = renderPromptBox("@asdf qwe", { + mentionSuggestions: [githubIssueSuggestion], + }); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: /Fix login bug/u }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); + await act(async () => promptBoxRef.current?.insertTextAtCursor("rt")); + + await waitFor(() => expect(latestValue(changes)).toBe("@asdf qwe rt")); + expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); + }); + + it("dismisses a coarse-pointer occurrence from a 44px close target", async () => { + const restorePointer = mockPointerCoarse(true); + try { + const { onMentionQueryChange } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + + const closeButton = await screen.findByRole("button", { + name: "Close suggestions", + }); + expect(closeButton.classList).toContain("size-11"); + expect(closeButton.parentElement?.classList).toContain("h-11"); + expect(closeButton.parentElement?.classList).not.toContain("absolute"); + + fireEvent.click(closeButton); + + await waitFor(() => + expect( + screen.queryByRole("button", { name: /Fix login bug/u }), + ).toBeNull(), + ); + expect(getPromptEditorElement().textContent).toBe("@fix"); + expect(onMentionQueryChange).toHaveBeenLastCalledWith(null, null); + } finally { + restorePointer(); + } + }); + + it("reopens after a touch-dismissed occurrence is removed and retyped", async () => { + const restorePointer = mockPointerCoarse(true); + const promptBoxRef = createRef(); + + function RetriggerHarness() { + const [value, setValue] = useState("@fix"); + return ( + <> + + + setValue(nextValue), + typeahead: buildTypeaheadConfig({ + mentionSuggestions: [githubIssueSuggestion], + }), + })} + promptBoxRef={promptBoxRef} + /> + + ); + } + + try { + render(); + await screen.findByRole("button", { name: /Fix login bug/u }); + fireEvent.click( + screen.getByRole("button", { name: "Close suggestions" }), + ); + + fireEvent.click( + screen.getByRole("button", { name: "Remove occurrence" }), + ); + await waitFor(() => + expect(getPromptEditorElement().textContent).toBe(""), + ); + fireEvent.click( + screen.getByRole("button", { name: "Retype occurrence" }), + ); + + await screen.findByRole("button", { name: /Fix login bug/u }); + } finally { + restorePointer(); + } + }); + it("reports the queued editor typeahead's open state and measured height", async () => { const layouts: Array<{ height: number; isOpen: boolean }> = []; const nativeGetBoundingClientRect = diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 6f05f7de03..3880fd3ba2 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1619,11 +1619,23 @@ export function PromptBoxInternal({ const syncTriggerState = useCallback( (editor: Editor) => { const caretPosition = editor.state.selection.from; - const dismissedTrigger = dismissedTriggerRef.current; + let dismissedTrigger = dismissedTriggerRef.current; const isRestoringAppliedMention = isRestoringAppliedMentionRef.current && dismissedTrigger !== null; - + const detectedTrigger = findActiveTrigger(editor, triggers); if (dismissedTrigger && !isRestoringAppliedMention) { + if ( + !dismissedTrigger.hasLeftRange && + detectedTrigger?.from === dismissedTrigger.start + ) { + // Continuing to type extends the same trigger occurrence. Grow the + // dismissed range before checking whether the caret left it. + dismissedTrigger = { + ...dismissedTrigger, + end: Math.max(dismissedTrigger.end, caretPosition), + }; + dismissedTriggerRef.current = dismissedTrigger; + } const isWithinDismissedRange = caretPosition >= dismissedTrigger.start && caretPosition <= dismissedTrigger.end; @@ -1646,9 +1658,7 @@ export function PromptBoxInternal({ caretPosition <= dismissedTriggerRef.current.end)), ); - const nextTrigger = shouldSuppressTrigger - ? null - : findActiveTrigger(editor, triggers); + const nextTrigger = shouldSuppressTrigger ? null : detectedTrigger; const nextKey = nextTrigger ? `${nextTrigger.kind}:${nextTrigger.from}:${nextTrigger.to}:${nextTrigger.query}` : ""; @@ -1896,6 +1906,24 @@ export function PromptBoxInternal({ }, onUpdate({ editor: updatedEditor, transaction }) { if (skipEditorChangeRef.current) return; + const dismissedTrigger = dismissedTriggerRef.current; + if ( + dismissedTrigger !== null && + transaction.docChanged && + !isRestoringAppliedMentionRef.current + ) { + const mappedStart = transaction.mapping.mapResult( + dismissedTrigger.start, + 1, + ); + dismissedTriggerRef.current = mappedStart.deleted + ? null + : { + ...dismissedTrigger, + start: mappedStart.pos, + end: transaction.mapping.map(dismissedTrigger.end, -1), + }; + } const nextValue = promptEditorValueFromDoc(updatedEditor.state.doc); lastSyncedEditorValueRef.current = nextValue; onChangeRef.current(nextValue.text, nextValue.mentions); @@ -2009,6 +2037,11 @@ export function PromptBoxInternal({ return; } + // A controlled replacement is a new occurrence. Parent echoes of editor + // updates return above because `lastSyncedEditorValueRef` already matches. + dismissedTriggerRef.current = null; + triggerKeyRef.current = ""; + try { skipEditorChangeRef.current = true; editor.commands.setContent( @@ -2437,6 +2470,20 @@ export function PromptBoxInternal({ [applyCommandSuggestion, applyMentionSuggestion], ); + const dismissActiveTrigger = useCallback(() => { + triggerKeyRef.current = ""; + if (activeTrigger) { + dismissedTriggerRef.current = { + start: activeTrigger.from, + end: activeTrigger.to, + hasLeftRange: false, + }; + } + setActiveTrigger(null); + onMentionQueryChange(null, null); + onCommandQueryChange(null); + }, [activeTrigger, onCommandQueryChange, onMentionQueryChange]); + const focusEnd = useCallback(() => { if (isPointerCoarse) { pendingFocusEndRef.current = false; @@ -2931,19 +2978,7 @@ export function PromptBoxInternal({ } if (event.key === "Escape") { event.preventDefault(); - triggerKeyRef.current = ""; - if (activeTrigger) { - // Escape dismisses the typed token span for both kinds — re-trigger - // stays suppressed while the caret remains inside `[from, to]`. - dismissedTriggerRef.current = { - start: activeTrigger.from, - end: activeTrigger.to, - hasLeftRange: false, - }; - } - setActiveTrigger(null); - onMentionQueryChange(null, null); - onCommandQueryChange(null); + dismissActiveTrigger(); return true; } } @@ -3076,7 +3111,6 @@ export function PromptBoxInternal({ [ activeHistoryIndex, activeSuggestions, - activeTrigger, activeTriggerKind, applyHistoryDraft, applyTrigger, @@ -3085,12 +3119,11 @@ export function PromptBoxInternal({ commandHasMore, commandIsLoadingMore, dispatchAppCommandKey, + dismissActiveTrigger, history, isPointerCoarse, loadMoreCommands, - onCommandQueryChange, onEscape, - onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, resetHistorySession, @@ -3248,6 +3281,7 @@ export function PromptBoxInternal({ state={typeaheadMenuState} selectedIndex={selectedIndex} onApply={applyTrigger} + onDismiss={isPointerCoarse ? dismissActiveTrigger : undefined} onCommandLoadMore={ canLoadMoreCommands ? loadMoreCommands : undefined } diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index bcb59aee17..36e6d5f5ab 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -43,6 +43,7 @@ interface MentionMenuProps { /** Currently-highlighted index in the results list (for keyboard nav). */ selectedIndex: number; onApply: (item: TypeaheadSuggestion) => void; + onDismiss?: () => void; onCommandLoadMore?: () => void; } @@ -352,15 +353,78 @@ function SuggestionRow({ ); } +function CloseSuggestionsButton({ onDismiss }: { onDismiss: () => void }) { + return ( + + ); +} + +function MenuStatusRow({ + children, + onDismiss, + className, +}: { + children: ReactNode; + onDismiss?: () => void; + className?: string; +}) { + return ( +
+
{children}
+ {onDismiss ? : null} +
+ ); +} + +function MenuSectionHeader({ + label, + onDismiss, +}: { + label: string; + onDismiss?: () => void; +}) { + return ( +
+ {label} + {onDismiss ? : null} +
+ ); +} + function MentionResults({ suggestions, selectedIndex, onApply, + onDismiss, itemRefs, }: { suggestions: readonly PromptMentionSuggestion[]; selectedIndex: number; onApply: (item: TypeaheadSuggestion) => void; + onDismiss?: () => void; itemRefs: React.MutableRefObject>; }) { const sections = useMemo(() => { @@ -374,19 +438,20 @@ function MentionResults({ if (sections.length === 0) { return ( -
+ No matching mentions -
+ ); } return (
- {sections.map((section) => ( + {sections.map((section, sectionIndex) => (
-
- {section.label} -
+
{section.items.map(({ item, index }) => { let primary: string; @@ -447,11 +512,13 @@ function CommandResults({ suggestions, selectedIndex, onApply, + onDismiss, itemRefs, }: { suggestions: readonly ComposerCommandSuggestion[]; selectedIndex: number; onApply: (item: TypeaheadSuggestion) => void; + onDismiss?: () => void; itemRefs: React.MutableRefObject>; }) { const sections = useMemo( @@ -472,11 +539,12 @@ function CommandResults({ return (
- {sections.map((section) => ( + {sections.map((section, sectionIndex) => (
-
- {section.label} -
+
{section.items.map(({ item, index }) => ( >([]); @@ -560,24 +629,30 @@ export function MentionMenu({
{innerState.kind === "hint" ? ( -
+ Type to search mentions -
+ ) : innerState.kind === "loading" ? ( -
+ {state.trigger === "command" ? "Searching commands…" : "Searching mentions…"} -
+ ) : innerState.kind === "error" ? ( -
+ {state.trigger === "command" ? "Failed to load commands" : "Failed to load suggestions"} -
+ ) : state.trigger === "command" ? ( ) : ( )}
diff --git a/packages/client-core/src/prompt/mentions/find-active-trigger.ts b/packages/client-core/src/prompt/mentions/find-active-trigger.ts index 00f5c26791..3be3c58c27 100644 --- a/packages/client-core/src/prompt/mentions/find-active-trigger.ts +++ b/packages/client-core/src/prompt/mentions/find-active-trigger.ts @@ -27,11 +27,12 @@ export interface ActiveTriggerEditor { * fires at the start of input or after whitespace / an opening bracket, so a * mid-word `a/b` or `foo@bar` never opens a menu. * - * - mention triggers keep a per-char self-exclusion query class, so a second - * trigger char ends the current query rather than extending it (`##` stays a - * markdown heading, not a `#` mention query). - * - command triggers (`/`) capture the whole token up to whitespace - * (`\S*`), so a namespaced name like `frontend:component` is captured whole. + * - mention triggers keep a per-char self-exclusion query pattern while + * allowing ordinary spaces between words. Other whitespace still ends the + * query, and a second trigger char ends it rather than extending it (`##` + * stays a markdown heading, not a `#` mention query). + * - command triggers (`/`) capture the whole token up to whitespace (`\S*`), + * so a namespaced name like `frontend:component` is captured whole. */ function escapeRegexLiteral(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); @@ -42,14 +43,14 @@ function triggerPattern( options: { windowed: boolean }, ): RegExp { const escapedChar = escapeRegexLiteral(trigger.char); - const queryClass = - trigger.kind === "mention" ? `[^\\s${escapedChar}]*` : "\\S*"; + const queryPattern = + trigger.kind === "mention" ? `(?:[^\\s${escapedChar}]| )*` : "\\S*"; // In a windowed scan the window start is not the start of input, so the // `^` alternative must not fire there; a real trigger inside the window // always carries its boundary char (the window includes one extra char // beyond the longest recognizable query). const boundary = options.windowed ? "([\\s([{])" : "(^|[\\s([{])"; - return new RegExp(`${boundary}${escapedChar}(${queryClass})$`, "u"); + return new RegExp(`${boundary}${escapedChar}(${queryPattern})$`, "u"); } /** diff --git a/packages/client-core/test/find-active-trigger.test.ts b/packages/client-core/test/find-active-trigger.test.ts index 41c5b48388..17195dcbce 100644 --- a/packages/client-core/test/find-active-trigger.test.ts +++ b/packages/client-core/test/find-active-trigger.test.ts @@ -86,6 +86,39 @@ describe("findActiveTrigger", () => { ).toBeNull(); }); + it("keeps spaces verbatim in a multiword mention query", () => { + const query = "prompt mention "; + const text = `Ask @${query}`; + expect( + findActiveTrigger(editorWithText(text), [{ char: "@", kind: "mention" }]), + ).toEqual({ + char: "@", + kind: "mention", + query, + from: "Ask ".length, + to: text.length, + }); + }); + + it.each(["\t", "\n"])( + "keeps existing non-space whitespace termination for %j", + (whitespace) => { + expect( + findActiveTrigger(editorWithText(`Ask @prompt${whitespace}`), [ + { char: "@", kind: "mention" }, + ]), + ).toBeNull(); + }, + ); + + it("keeps punctuation inside a mention query", () => { + expect( + findActiveTrigger(editorWithText("Ask @prompt!"), [ + { char: "@", kind: "mention" }, + ]), + ).toMatchObject({ query: "prompt!" }); + }); + it("does not treat dollar as an active command trigger", () => { expect( findActiveTrigger(editorWithText("$openai-docs"), [