From c910b3e3adc576e68c684085a1a1f724d7aceeb1 Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Mon, 24 Aug 2026 19:07:03 +0000 Subject: [PATCH 1/8] Support multiword mention autocomplete sessions --- .../promptbox/PromptBoxInternal.test.tsx | 186 ++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 85 +++++--- .../promptbox/mentions/MentionMenu.tsx | 15 +- .../hooks/threadMentionSuggestions.test.ts | 42 +++- .../app/src/hooks/threadMentionSuggestions.ts | 22 +++ .../prompt/mentions/find-active-trigger.ts | 4 +- .../test/find-active-trigger.test.ts | 24 +++ 7 files changed, 346 insertions(+), 32 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index c46b6ca8fc..c84a915e37 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2791,6 +2791,188 @@ describe("PromptBoxInternal mention triggers", () => { replacement: "#42 Fix login bug", }; + it("searches multiword queries without mutating or decorating typed text", async () => { + const typed = "Ask @fix login "; + const { changes, onMentionQueryChange, promptBoxRef } = renderPromptBox( + typed, + { mentionSuggestions: [githubIssueSuggestion] }, + ); + + await focusPromptEnd(promptBoxRef); + await waitFor(() => + expect(onMentionQueryChange).toHaveBeenCalledWith("fix login ", "@"), + ); + + const editor = getPromptEditorElement(); + expect(editor.textContent).toBe(typed); + expect(editor.querySelector('[data-type="mention"]')).toBeNull(); + expect(changes).toHaveLength(0); + + const range = document.createRange(); + range.selectNodeContents(editor); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + expect(selection?.toString()).toBe(typed); + selection?.removeAllRanges(); + }); + + it("silently closes settled no-match results", async () => { + const { onMentionQueryChange, promptBoxRef } = renderPromptBox("@missing"); + + await focusPromptEnd(promptBoxRef); + await waitFor(() => + expect(onMentionQueryChange).toHaveBeenCalledWith("missing", "@"), + ); + + expect(screen.queryByText("No matching mentions")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Close suggestions" }), + ).toBeNull(); + }); + + it("reopens a silent no-match session when backspace restores a viable query", async () => { + const promptBoxRef = createRef(); + + function BackspaceHarness() { + const [value, setValue] = useState("@fixx"); + const [query, setQuery] = useState(null); + return ( + <> + + setValue(nextValue), + typeahead: buildTypeaheadConfig({ + mentionSuggestions: + query === "fix" ? [githubIssueSuggestion] : [], + onMentionQueryChange: (nextQuery) => setQuery(nextQuery), + }), + })} + promptBoxRef={promptBoxRef} + /> + + ); + } + + render(); + await focusPromptEnd(promptBoxRef); + expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Backspace" })); + + await screen.findByRole("button", { name: /Fix login bug/u }); + expect(getPromptEditorElement().textContent).toBe("@fix"); + }); + + it("keeps an explicitly dismissed occurrence closed as typing extends it", async () => { + const { changes, promptBoxRef } = renderPromptBox("@fix", { + 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("more")); + + await waitFor(() => expect(latestValue(changes)).toBe("@fix more")); + expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); + }); + + it("does not let passive mention results hijack desktop Enter", async () => { + const { changes, onSubmit, promptBoxRef } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: /Fix login bug/u }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + + expect(onSubmit).toHaveBeenCalledOnce(); + expect(changes).toHaveLength(0); + }); + + it("applies a mention with Enter only after keyboard navigation", async () => { + const { changes, promptBoxRef } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: /Fix login bug/u }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + + await waitFor(() => + expect(latestValue(changes)).toBe("@#42 Fix login bug "), + ); + expect(latestChange(changes)?.mentions).toHaveLength(1); + }); + + it("keeps coarse-pointer Enter passive", async () => { + const restorePointer = mockPointerCoarse(true); + try { + const { changes, onSubmit, promptBoxRef } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { + name: /Fix login bug/u, + }); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(latestChange(changes)?.mentions).toEqual([]); + expect(latestValue(changes)).toBe("@fix\n"); + } finally { + restorePointer(); + } + }); + + it("applies a coarse-pointer mention by tapping the result", async () => { + const restorePointer = mockPointerCoarse(true); + try { + const { changes } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + fireEvent.mouseDown( + await screen.findByRole("button", { name: /Fix login bug/u }), + { button: 0 }, + ); + + await waitFor(() => + expect(latestValue(changes)).toBe("@#42 Fix login bug "), + ); + } finally { + restorePointer(); + } + }); + + it("dismisses the current occurrence from the touch-accessible close button", async () => { + const restorePointer = mockPointerCoarse(true); + try { + const { onMentionQueryChange } = renderPromptBox("@fix", { + mentionSuggestions: [githubIssueSuggestion], + }); + + fireEvent.click( + await screen.findByRole("button", { name: "Close suggestions" }), + ); + + 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("reports the queued editor typeahead's open state and measured height", async () => { const layouts: Array<{ height: number; isOpen: boolean }> = []; const nativeGetBoundingClientRect = @@ -3632,6 +3814,7 @@ describe("PromptBoxInternal command typeahead submit", () => { await openCommandMenu(promptBoxRef, "/compact", "compact"); await act(async () => { + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); }); await act(async () => {}); @@ -3663,6 +3846,7 @@ describe("PromptBoxInternal command typeahead submit", () => { await openCommandMenu(promptBoxRef, "/review", "review"); await act(async () => { + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); }); await act(async () => {}); @@ -3845,6 +4029,8 @@ describe("PromptBoxInternal command typeahead navigation", () => { ).toContain("bg-state-active"), ); + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowUp" }); + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); await waitFor(() => expect(latestValue(changes)).toBe("/plan ")); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 6f05f7de03..53ab487ec9 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1302,6 +1302,7 @@ export function PromptBoxInternal({ const skipEditorChangeRef = useRef(false); const lastSyncedEditorValueRef = useRef(null); const triggerKeyRef = useRef(""); + const hasNavigatedTypeaheadRef = useRef(false); const handleEditorKeyDownRef = useRef< (event: KeyboardEvent, isOriginalIPadHardwareEnter?: boolean) => boolean >(() => false); @@ -1622,8 +1623,17 @@ export function PromptBoxInternal({ const dismissedTrigger = dismissedTriggerRef.current; const isRestoringAppliedMention = isRestoringAppliedMentionRef.current && dismissedTrigger !== null; + const detectedTrigger = findActiveTrigger(editor, triggers); if (dismissedTrigger && !isRestoringAppliedMention) { + if ( + !dismissedTrigger.hasLeftRange && + detectedTrigger?.from === dismissedTrigger.start + ) { + // Typing extends the same dismissed occurrence. Keep its session + // closed rather than interpreting the new caret position as a leave. + dismissedTrigger.end = caretPosition; + } const isWithinDismissedRange = caretPosition >= dismissedTrigger.start && caretPosition <= dismissedTrigger.end; @@ -1646,14 +1656,13 @@ 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}` : ""; if (nextKey !== triggerKeyRef.current) { triggerKeyRef.current = nextKey; + hasNavigatedTypeaheadRef.current = false; setSelectedIndex(0); } setActiveTrigger(nextTrigger); @@ -1890,7 +1899,7 @@ export function PromptBoxInternal({ // handle that transaction once. The browser already reveals the caret // for native contenteditable edits; measuring it here with coordsAtPos // forces layout on every keystroke. - if (transaction.docChanged) return; + if (transaction.docChanged || updatedEditor.view.composing) return; syncTriggerStateRef.current(updatedEditor); scheduleRevealEditorSelection(); }, @@ -1899,7 +1908,9 @@ export function PromptBoxInternal({ const nextValue = promptEditorValueFromDoc(updatedEditor.state.doc); lastSyncedEditorValueRef.current = nextValue; onChangeRef.current(nextValue.text, nextValue.mentions); - syncTriggerStateRef.current(updatedEditor); + if (!updatedEditor.view.composing) { + syncTriggerStateRef.current(updatedEditor); + } // Native typing already asks ProseMirror to scroll the selection into // view. Clipboard and drop transactions still need the prompt's custom // scroll-container reveal that originally fixed multiline paste. @@ -2226,7 +2237,14 @@ export function PromptBoxInternal({ !isVoiceBusy && activeTrigger !== null && !isCommandTriggerLiteral && - !isBareNonDefaultMentionTrigger; + !isBareNonDefaultMentionTrigger && + !( + activeTriggerKind === "mention" && + activeMentionQuery.length > 0 && + !mentionLoading && + !mentionError && + mentionSuggestions.length === 0 + ); const typeaheadMenuState: TypeaheadMenuState = activeTriggerKind === "command" @@ -2437,6 +2455,21 @@ export function PromptBoxInternal({ [applyCommandSuggestion, applyMentionSuggestion], ); + const dismissActiveTrigger = useCallback(() => { + triggerKeyRef.current = ""; + hasNavigatedTypeaheadRef.current = false; + 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; @@ -2705,11 +2738,17 @@ export function PromptBoxInternal({ const shouldBlurAfterSubmit = blurAfterPointerSubmitRef.current; blurAfterPointerSubmitRef.current = false; if (!canSubmit) return; + triggerKeyRef.current = ""; + hasNavigatedTypeaheadRef.current = false; + dismissedTriggerRef.current = null; + setActiveTrigger(null); + onMentionQueryChange(null, null); + onCommandQueryChange(null); onSubmit(); if (shouldBlurAfterSubmit) { blurPromptEditor(editorRef.current); } - }, [canSubmit, onSubmit]); + }, [canSubmit, onCommandQueryChange, onMentionQueryChange, onSubmit]); const handleSubmitClick = useCallback( (event: ReactMouseEvent) => { @@ -2881,6 +2920,7 @@ export function PromptBoxInternal({ activeSuggestions.length > 0 ) { event.preventDefault(); + hasNavigatedTypeaheadRef.current = true; if ( activeTriggerKind === "command" && !commandError && @@ -2901,16 +2941,18 @@ export function PromptBoxInternal({ activeSuggestions.length > 0 ) { event.preventDefault(); + hasNavigatedTypeaheadRef.current = true; setSelectedIndex( (prev) => (prev + activeSuggestions.length - 1) % activeSuggestions.length, ); return true; } - if ( - (event.key === "Enter" || event.key === "Tab") && - activeSuggestions.length > 0 - ) { + const canApplyTypeaheadFromKeyboard = + !isPointerCoarse && + (event.key === "Tab" || + (event.key === "Enter" && hasNavigatedTypeaheadRef.current)); + if (canApplyTypeaheadFromKeyboard && activeSuggestions.length > 0) { event.preventDefault(); const selected = activeSuggestions[selectedIndex] ?? activeSuggestions[0]; @@ -2929,21 +2971,9 @@ export function PromptBoxInternal({ } return true; } - if (event.key === "Escape") { + if (event.key === "Escape" && !isPointerCoarse) { 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 +3106,6 @@ export function PromptBoxInternal({ [ activeHistoryIndex, activeSuggestions, - activeTrigger, activeTriggerKind, applyHistoryDraft, applyTrigger, @@ -3085,12 +3114,11 @@ export function PromptBoxInternal({ commandHasMore, commandIsLoadingMore, dispatchAppCommandKey, + dismissActiveTrigger, history, isPointerCoarse, loadMoreCommands, - onCommandQueryChange, onEscape, - onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, resetHistorySession, @@ -3248,6 +3276,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..2bb1e89be0 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; } @@ -516,6 +517,7 @@ export function MentionMenu({ state, selectedIndex, onApply, + onDismiss, onCommandLoadMore, }: MentionMenuProps) { const itemRefs = useRef>([]); @@ -557,7 +559,18 @@ export function MentionMenu({ }, [resultsLength, selectedIndex]); return ( -
+
+ {onDismiss ? ( + + ) : null}
{innerState.kind === "hint" ? (
diff --git a/apps/app/src/hooks/threadMentionSuggestions.test.ts b/apps/app/src/hooks/threadMentionSuggestions.test.ts index 3dab95815a..d323a4ed2e 100644 --- a/apps/app/src/hooks/threadMentionSuggestions.test.ts +++ b/apps/app/src/hooks/threadMentionSuggestions.test.ts @@ -9,6 +9,8 @@ interface ThreadFixtureOptions { title: string | null; titleFallback?: string | null; visibility?: Thread["visibility"]; + status?: Thread["status"]; + latestAttentionAt?: number; } interface BuildSuggestionFixtureArgs { @@ -28,7 +30,7 @@ function makeThread(options: ThreadFixtureOptions): Thread { title: options.title, titleFallback: options.titleFallback ?? null, sectionId: null, - status: "idle", + status: options.status ?? "idle", parentThreadId: options.parentThreadId ?? null, sourceThreadId: null, originKind: null, @@ -38,7 +40,7 @@ function makeThread(options: ThreadFixtureOptions): Thread { pinnedAt: null, deletedAt: null, lastReadAt: null, - latestAttentionAt: 1, + latestAttentionAt: options.latestAttentionAt ?? 1, createdAt: 1, updatedAt: 1, }; @@ -167,6 +169,42 @@ describe("buildThreadMentionSuggestions", () => { ).toEqual(["thr_earlier", "thr_later"]); }); + it("uses activity and coarse attention recency after match and relationship quality", () => { + const hour = 60 * 60 * 1000; + const threads = [ + makeThread({ + id: "thr_idle_recent", + title: "Shared context", + latestAttentionAt: 20 * hour, + }), + makeThread({ + id: "thr_active_old", + title: "Shared context", + status: "active", + latestAttentionAt: 2 * hour, + }), + makeThread({ + id: "thr_active_recent", + title: "Shared context", + status: "stopping", + latestAttentionAt: 10 * hour, + }), + makeThread({ + id: "thr_active_same_bucket_z", + title: "Shared context", + status: "starting", + latestAttentionAt: 10 * hour + 20, + }), + ]; + + expect(getSuggestionThreadIds({ threads, query: "shared" })).toEqual([ + "thr_active_recent", + "thr_active_same_bucket_z", + "thr_active_old", + "thr_idle_recent", + ]); + }); + it("ranks directly related, same-parent, and same-project thread matches together", () => { const threads = [ makeThread({ diff --git a/apps/app/src/hooks/threadMentionSuggestions.ts b/apps/app/src/hooks/threadMentionSuggestions.ts index 2384f30a8e..cac7bf5d66 100644 --- a/apps/app/src/hooks/threadMentionSuggestions.ts +++ b/apps/app/src/hooks/threadMentionSuggestions.ts @@ -20,6 +20,8 @@ interface BuildThreadMentionSuggestionsArgs { interface RankedThreadMentionSuggestion { suggestion: ThreadMentionSuggestion; relationRank: number; + activityRank: number; + attentionBucket: number; score: number; } @@ -36,6 +38,16 @@ const THREAD_RELATION_RANK = { unrelated: 3, }; +const ATTENTION_BUCKET_MS = 60 * 60 * 1000; + +function getThreadActivityRank(thread: Thread): number { + return thread.status === "active" || + thread.status === "starting" || + thread.status === "stopping" + ? 0 + : 1; +} + function getThreadDisplayTitle(thread: Thread): string | undefined { const title = thread.title?.trim(); if (title) { @@ -147,6 +159,12 @@ function compareRankedThreadMentionSuggestions( if (left.relationRank !== right.relationRank) { return left.relationRank - right.relationRank; } + if (left.activityRank !== right.activityRank) { + return left.activityRank - right.activityRank; + } + if (left.attentionBucket !== right.attentionBucket) { + return right.attentionBucket - left.attentionBucket; + } const leftTitle = left.suggestion.title ?? ""; const rightTitle = right.suggestion.title ?? ""; return ( @@ -182,6 +200,10 @@ export function buildThreadMentionSuggestions( args.projectNamesById, ), relationRank: getThreadRelationRank(match.item, context), + activityRank: getThreadActivityRank(match.item), + attentionBucket: Math.floor( + match.item.latestAttentionAt / ATTENTION_BUCKET_MS, + ), score: match.score, })) .sort(compareRankedThreadMentionSuggestions) 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..323b15c74a 100644 --- a/packages/client-core/src/prompt/mentions/find-active-trigger.ts +++ b/packages/client-core/src/prompt/mentions/find-active-trigger.ts @@ -43,7 +43,9 @@ function triggerPattern( ): RegExp { const escapedChar = escapeRegexLiteral(trigger.char); const queryClass = - trigger.kind === "mention" ? `[^\\s${escapedChar}]*` : "\\S*"; + trigger.kind === "mention" + ? `[^\\r\\n${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 diff --git a/packages/client-core/test/find-active-trigger.test.ts b/packages/client-core/test/find-active-trigger.test.ts index 41c5b48388..bc621cac5e 100644 --- a/packages/client-core/test/find-active-trigger.test.ts +++ b/packages/client-core/test/find-active-trigger.test.ts @@ -86,6 +86,30 @@ describe("findActiveTrigger", () => { ).toBeNull(); }); + it("keeps spaces and whitespace verbatim in a multiword mention query", () => { + const text = "Ask @prompt mention "; + expect( + findActiveTrigger(editorWithText(text), [{ char: "@", kind: "mention" }]), + ).toEqual({ + char: "@", + kind: "mention", + query: "prompt mention ", + from: "Ask ".length, + to: text.length, + }); + }); + + it.each(["!", "?", ",", ";", ")", "]", "}", "\n"])( + "ends a mention query at %j", + (punctuation) => { + expect( + findActiveTrigger(editorWithText(`Ask @prompt${punctuation}`), [ + { char: "@", kind: "mention" }, + ]), + ).toBeNull(); + }, + ); + it("does not treat dollar as an active command trigger", () => { expect( findActiveTrigger(editorWithText("$openai-docs"), [ From 4ae0559e9b638a13405d84651e58ad32577bfb5a Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Mon, 24 Aug 2026 19:09:02 +0000 Subject: [PATCH 2/8] Make mention menu close target touch accessible --- .../promptbox/PromptBoxInternal.test.tsx | 11 +++++++--- .../promptbox/mentions/MentionMenu.tsx | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index c84a915e37..7db33617aa 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2957,9 +2957,14 @@ describe("PromptBoxInternal mention triggers", () => { mentionSuggestions: [githubIssueSuggestion], }); - fireEvent.click( - await screen.findByRole("button", { name: "Close suggestions" }), - ); + 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( diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index 2bb1e89be0..d2111a6be3 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -559,17 +559,19 @@ export function MentionMenu({ }, [resultsLength, selectedIndex]); return ( -
+
{onDismiss ? ( - +
+ +
) : null}
{innerState.kind === "hint" ? ( From f5468b3c2ab8a98711f8e414f2ba7866037bcaa4 Mon Sep 17 00:00:00 2001 From: Joshua Pham Date: Tue, 25 Aug 2026 00:08:01 +0000 Subject: [PATCH 3/8] Preserve intentional iPad typeahead selection --- .../components/promptbox/PromptBoxInternal.ipados.test.tsx | 5 +++++ apps/app/src/components/promptbox/PromptBoxInternal.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx index f585b7fb27..568a490928 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx @@ -130,6 +130,11 @@ describe("PromptBoxInternal on a real iPadOS ProseMirror build", () => { await act(async () => {}); expect(screen.getByRole("button", { name: "review" })).toBeTruthy(); + fireEvent.keyDown(getPromptEditorElement(), { + key: "ArrowDown", + code: "ArrowDown", + keyCode: 40, + }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter", code: "Enter", diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 53ab487ec9..8803a33f91 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -2949,7 +2949,7 @@ export function PromptBoxInternal({ return true; } const canApplyTypeaheadFromKeyboard = - !isPointerCoarse && + (!isPointerCoarse || isOriginalIPadHardwareEnter) && (event.key === "Tab" || (event.key === "Enter" && hasNavigatedTypeaheadRef.current)); if (canApplyTypeaheadFromKeyboard && activeSuggestions.length > 0) { From d0f914a64d4f1b8a3b7909b7694e376dda9ea472 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 11:43:24 -0700 Subject: [PATCH 4/8] fix: harden mention autocomplete sessions --- .../promptbox/PromptBoxInternal.test.tsx | 51 +++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 64 +++++++++++++++++-- .../src/composer/model/suggestions.test.ts | 45 +++++++++++++ apps/mobile/src/composer/model/suggestions.ts | 16 +++++ .../test/find-active-trigger.test.ts | 7 +- 5 files changed, 175 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 7db33617aa..8a9418cec8 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2817,6 +2817,22 @@ describe("PromptBoxInternal mention triggers", () => { selection?.removeAllRanges(); }); + it("resynchronizes mention suggestions after IME composition ends", async () => { + const { promptBoxRef } = renderPromptBox("", { + mentionSuggestions: [githubIssueSuggestion], + }); + await focusPromptEnd(promptBoxRef); + + const editor = getPromptEditorElement(); + fireEvent.compositionStart(editor, { data: "@fix" }); + await act(async () => promptBoxRef.current?.insertTextAtCursor("@fix")); + expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); + + fireEvent.compositionEnd(editor, { data: "@fix" }); + + await screen.findByRole("button", { name: /Fix login bug/u }); + }); + it("silently closes settled no-match results", async () => { const { onMentionQueryChange, promptBoxRef } = renderPromptBox("@missing"); @@ -2882,6 +2898,41 @@ describe("PromptBoxInternal mention triggers", () => { expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); }); + it("opens a controlled replacement mention occurrence at the same position", async () => { + const promptBoxRef = createRef(); + + function ReplacementHarness() { + const [value, setValue] = useState("@fix"); + return ( + <> + + setValue(nextValue), + typeahead: buildTypeaheadConfig({ + mentionSuggestions: [githubIssueSuggestion], + }), + })} + promptBoxRef={promptBoxRef} + /> + + ); + } + + render(); + await focusPromptEnd(promptBoxRef); + await screen.findByRole("button", { name: /Fix login bug/u }); + fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); + + fireEvent.click(screen.getByRole("button", { name: "Replace occurrence" })); + + expect(getPromptEditorElement().textContent).toBe("@different title"); + await screen.findByRole("button", { name: /Fix login bug/u }); + }); + it("does not let passive mention results hijack desktop Enter", async () => { const { changes, onSubmit, promptBoxRef } = renderPromptBox("@fix", { mentionSuggestions: [githubIssueSuggestion], diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 8803a33f91..55c97a7c52 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1307,6 +1307,7 @@ export function PromptBoxInternal({ (event: KeyboardEvent, isOriginalIPadHardwareEnter?: boolean) => boolean >(() => false); const compositionEndedAtRef = useRef(Number.NEGATIVE_INFINITY); + const compositionNeedsTriggerSyncRef = useRef(false); const postCompositionKeyDownEvents = usePostCompositionKeyDownEvents(); const dispatchAppCommandKey = useAppCommandKeyDispatch(); // The TipTap editor is created once; its `onUpdate`/`onSelectionUpdate`/click @@ -1769,6 +1770,26 @@ export function PromptBoxInternal({ // Magic Keyboard Enter for the next 500 ms. if (!_view.composing) return false; compositionEndedAtRef.current = event.timeStamp; + // Custom DOM handlers run before ProseMirror settles composition + // and flushes any pending DOM change. Two microtasks put this + // fallback after that flush; an ordinary post-composition update + // clears the flag and avoids dispatching the query twice. + queueMicrotask(() => { + queueMicrotask(() => { + if (!compositionNeedsTriggerSyncRef.current) return; + const currentEditor = editorRef.current; + if ( + !currentEditor || + currentEditor.isDestroyed || + currentEditor.view !== _view || + currentEditor.view.composing + ) { + return; + } + compositionNeedsTriggerSyncRef.current = false; + syncTriggerStateRef.current(currentEditor); + }); + }); return false; }, keydown: (_view, event) => { @@ -1899,16 +1920,42 @@ export function PromptBoxInternal({ // handle that transaction once. The browser already reveals the caret // for native contenteditable edits; measuring it here with coordsAtPos // forces layout on every keystroke. - if (transaction.docChanged || updatedEditor.view.composing) return; + if (transaction.docChanged) return; + if (updatedEditor.view.composing) { + compositionNeedsTriggerSyncRef.current = true; + return; + } + compositionNeedsTriggerSyncRef.current = false; syncTriggerStateRef.current(updatedEditor); scheduleRevealEditorSelection(); }, 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); - if (!updatedEditor.view.composing) { + if (updatedEditor.view.composing) { + compositionNeedsTriggerSyncRef.current = true; + } else { + compositionNeedsTriggerSyncRef.current = false; syncTriggerStateRef.current(updatedEditor); } // Native typing already asks ProseMirror to scroll the selection into @@ -2020,6 +2067,13 @@ export function PromptBoxInternal({ return; } + // A controlled replacement is a new draft occurrence, not continued + // typing in an explicitly dismissed typeahead session. Parent echoes of + // editor updates return above because `lastSyncedEditorValueRef` matches. + dismissedTriggerRef.current = null; + hasNavigatedTypeaheadRef.current = false; + triggerKeyRef.current = ""; + try { skipEditorChangeRef.current = true; editor.commands.setContent( @@ -2221,9 +2275,9 @@ export function PromptBoxInternal({ ? { kind: "error" } : { kind: "results", suggestions: orderedCommandSuggestions }; - // Loaded-empty suppression (§6): a command trigger with zero loaded results - // (not loading, not error) is literal text — never open the menu. Mention - // triggers always open (they have a hint / "no matches" state). + // Loaded-empty suppression: command triggers and non-empty mention queries + // with zero loaded results stay literal text and close silently. A bare + // default `@` still opens the mention hint. const isCommandTriggerLiteral = activeTriggerKind === "command" && !commandLoading && diff --git a/apps/mobile/src/composer/model/suggestions.test.ts b/apps/mobile/src/composer/model/suggestions.test.ts index 07c99da391..2019952c69 100644 --- a/apps/mobile/src/composer/model/suggestions.test.ts +++ b/apps/mobile/src/composer/model/suggestions.test.ts @@ -16,6 +16,8 @@ function thread(overrides: Partial & { id: string }): Thread { projectId: "proj_1", parentThreadId: null, visibility: "visible", + status: "idle", + latestAttentionAt: 1, ...overrides, } as Thread; } @@ -52,6 +54,49 @@ describe("buildThreadMentionSuggestions", () => { expect(result[1]?.projectName).toBe("Other project"); }); + it("uses activity and coarse attention recency after match and relationship quality", () => { + const hour = 60 * 60 * 1000; + const threads = [ + thread({ + id: "thr_idle_recent", + title: "Shared context", + latestAttentionAt: 20 * hour, + }), + thread({ + id: "thr_active_old", + title: "Shared context", + status: "active", + latestAttentionAt: 2 * hour, + }), + thread({ + id: "thr_active_recent", + title: "Shared context", + status: "stopping", + latestAttentionAt: 10 * hour, + }), + thread({ + id: "thr_active_same_bucket_z", + title: "Shared context", + status: "starting", + latestAttentionAt: 10 * hour + 20, + }), + ]; + + const result = buildThreadMentionSuggestions({ + threads, + query: "shared", + projectNamesById: new Map(), + limit: 8, + }); + + expect(result.map((entry) => entry.threadId)).toEqual([ + "thr_active_recent", + "thr_active_same_bucket_z", + "thr_active_old", + "thr_idle_recent", + ]); + }); + it("returns nothing for an empty query", () => { expect( buildThreadMentionSuggestions({ diff --git a/apps/mobile/src/composer/model/suggestions.ts b/apps/mobile/src/composer/model/suggestions.ts index d0f14d7f55..14d1903b30 100644 --- a/apps/mobile/src/composer/model/suggestions.ts +++ b/apps/mobile/src/composer/model/suggestions.ts @@ -48,12 +48,22 @@ const THREAD_RELATION_RANK = { unrelated: 3, }; +const ATTENTION_BUCKET_MS = 60 * 60 * 1000; + interface ThreadMentionContext { currentParentThreadId: string | null; currentProjectId?: string; currentThreadId?: string; } +function threadActivityRank(thread: Thread): number { + return thread.status === "active" || + thread.status === "starting" || + thread.status === "stopping" + ? 0 + : 1; +} + function threadDisplayTitle(thread: Thread): string | undefined { const title = thread.title?.trim(); if (title) return title; @@ -140,6 +150,10 @@ export function buildThreadMentionSuggestions( return { suggestion, relationRank: threadRelationRank(thread, context), + activityRank: threadActivityRank(thread), + attentionBucket: Math.floor( + thread.latestAttentionAt / ATTENTION_BUCKET_MS, + ), score: match.score, }; }) @@ -147,6 +161,8 @@ export function buildThreadMentionSuggestions( (left, right) => right.score - left.score || left.relationRank - right.relationRank || + left.activityRank - right.activityRank || + right.attentionBucket - left.attentionBucket || (left.suggestion.title ?? "").localeCompare( right.suggestion.title ?? "", ) || diff --git a/packages/client-core/test/find-active-trigger.test.ts b/packages/client-core/test/find-active-trigger.test.ts index bc621cac5e..e89810fb4b 100644 --- a/packages/client-core/test/find-active-trigger.test.ts +++ b/packages/client-core/test/find-active-trigger.test.ts @@ -86,14 +86,15 @@ describe("findActiveTrigger", () => { ).toBeNull(); }); - it("keeps spaces and whitespace verbatim in a multiword mention query", () => { - const text = "Ask @prompt mention "; + it("keeps spaces and tabs verbatim in a multiword mention query", () => { + const query = "prompt \t mention "; + const text = `Ask @${query}`; expect( findActiveTrigger(editorWithText(text), [{ char: "@", kind: "mention" }]), ).toEqual({ char: "@", kind: "mention", - query: "prompt mention ", + query, from: "Ask ".length, to: text.length, }); From be70b1504998e974feb70e9cf6189d43d8994855 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 12:54:32 -0700 Subject: [PATCH 5/8] fix: narrow mention autocomplete to spaces --- .../PromptBoxInternal.ipados.test.tsx | 5 - .../promptbox/PromptBoxInternal.test.tsx | 237 +----------------- .../promptbox/PromptBoxInternal.tsx | 143 +++-------- .../promptbox/mentions/MentionMenu.tsx | 15 -- .../hooks/threadMentionSuggestions.test.ts | 42 +--- .../app/src/hooks/threadMentionSuggestions.ts | 22 -- .../src/composer/model/suggestions.test.ts | 45 ---- apps/mobile/src/composer/model/suggestions.ts | 16 -- .../prompt/mentions/find-active-trigger.ts | 19 +- .../test/find-active-trigger.test.ts | 20 +- 10 files changed, 63 insertions(+), 501 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx index 568a490928..f585b7fb27 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx @@ -130,11 +130,6 @@ describe("PromptBoxInternal on a real iPadOS ProseMirror build", () => { await act(async () => {}); expect(screen.getByRole("button", { name: "review" })).toBeTruthy(); - fireEvent.keyDown(getPromptEditorElement(), { - key: "ArrowDown", - code: "ArrowDown", - keyCode: 40, - }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter", code: "Enter", diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 8a9418cec8..3e0894a3a4 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2791,242 +2791,25 @@ describe("PromptBoxInternal mention triggers", () => { replacement: "#42 Fix login bug", }; - it("searches multiword queries without mutating or decorating typed text", async () => { - const typed = "Ask @fix login "; - const { changes, onMentionQueryChange, promptBoxRef } = renderPromptBox( - typed, - { mentionSuggestions: [githubIssueSuggestion] }, - ); + 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 ", "@"), + expect(onMentionQueryChange).toHaveBeenCalledWith("fix login", "@"), ); - - const editor = getPromptEditorElement(); - expect(editor.textContent).toBe(typed); - expect(editor.querySelector('[data-type="mention"]')).toBeNull(); - expect(changes).toHaveLength(0); - - const range = document.createRange(); - range.selectNodeContents(editor); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - expect(selection?.toString()).toBe(typed); - selection?.removeAllRanges(); - }); - - it("resynchronizes mention suggestions after IME composition ends", async () => { - const { promptBoxRef } = renderPromptBox("", { - mentionSuggestions: [githubIssueSuggestion], - }); - await focusPromptEnd(promptBoxRef); - - const editor = getPromptEditorElement(); - fireEvent.compositionStart(editor, { data: "@fix" }); - await act(async () => promptBoxRef.current?.insertTextAtCursor("@fix")); - expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); - - fireEvent.compositionEnd(editor, { data: "@fix" }); - await screen.findByRole("button", { name: /Fix login bug/u }); - }); - - it("silently closes settled no-match results", async () => { - const { onMentionQueryChange, promptBoxRef } = renderPromptBox("@missing"); - - await focusPromptEnd(promptBoxRef); - await waitFor(() => - expect(onMentionQueryChange).toHaveBeenCalledWith("missing", "@"), - ); - - expect(screen.queryByText("No matching mentions")).toBeNull(); - expect( - screen.queryByRole("button", { name: "Close suggestions" }), - ).toBeNull(); - }); - - it("reopens a silent no-match session when backspace restores a viable query", async () => { - const promptBoxRef = createRef(); - - function BackspaceHarness() { - const [value, setValue] = useState("@fixx"); - const [query, setQuery] = useState(null); - return ( - <> - - setValue(nextValue), - typeahead: buildTypeaheadConfig({ - mentionSuggestions: - query === "fix" ? [githubIssueSuggestion] : [], - onMentionQueryChange: (nextQuery) => setQuery(nextQuery), - }), - })} - promptBoxRef={promptBoxRef} - /> - - ); - } - - render(); - await focusPromptEnd(promptBoxRef); - expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Backspace" })); - - await screen.findByRole("button", { name: /Fix login bug/u }); - expect(getPromptEditorElement().textContent).toBe("@fix"); - }); - - it("keeps an explicitly dismissed occurrence closed as typing extends it", async () => { - const { changes, promptBoxRef } = renderPromptBox("@fix", { - 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("more")); - - await waitFor(() => expect(latestValue(changes)).toBe("@fix more")); - expect(screen.queryByRole("button", { name: /Fix login bug/u })).toBeNull(); - }); - - it("opens a controlled replacement mention occurrence at the same position", async () => { - const promptBoxRef = createRef(); - - function ReplacementHarness() { - const [value, setValue] = useState("@fix"); - return ( - <> - - setValue(nextValue), - typeahead: buildTypeaheadConfig({ - mentionSuggestions: [githubIssueSuggestion], - }), - })} - promptBoxRef={promptBoxRef} - /> - - ); - } - - render(); - await focusPromptEnd(promptBoxRef); - await screen.findByRole("button", { name: /Fix login bug/u }); - fireEvent.keyDown(getPromptEditorElement(), { key: "Escape" }); - fireEvent.click(screen.getByRole("button", { name: "Replace occurrence" })); - - expect(getPromptEditorElement().textContent).toBe("@different title"); - await screen.findByRole("button", { name: /Fix login bug/u }); - }); - - it("does not let passive mention results hijack desktop Enter", async () => { - const { changes, onSubmit, promptBoxRef } = renderPromptBox("@fix", { - mentionSuggestions: [githubIssueSuggestion], - }); - await focusPromptEnd(promptBoxRef); - await screen.findByRole("button", { name: /Fix login bug/u }); - - fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); - - expect(onSubmit).toHaveBeenCalledOnce(); - expect(changes).toHaveLength(0); - }); - - it("applies a mention with Enter only after keyboard navigation", async () => { - const { changes, promptBoxRef } = renderPromptBox("@fix", { - mentionSuggestions: [githubIssueSuggestion], - }); - await focusPromptEnd(promptBoxRef); - await screen.findByRole("button", { name: /Fix login bug/u }); - - fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); await waitFor(() => - expect(latestValue(changes)).toBe("@#42 Fix login bug "), + expect(latestValue(changes)).toBe("Ask @#42 Fix login bug "), ); expect(latestChange(changes)?.mentions).toHaveLength(1); - }); - - it("keeps coarse-pointer Enter passive", async () => { - const restorePointer = mockPointerCoarse(true); - try { - const { changes, onSubmit, promptBoxRef } = renderPromptBox("@fix", { - mentionSuggestions: [githubIssueSuggestion], - }); - await focusPromptEnd(promptBoxRef); - await screen.findByRole("button", { - name: /Fix login bug/u, - }); - - fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); - expect(onSubmit).not.toHaveBeenCalled(); - expect(latestChange(changes)?.mentions).toEqual([]); - expect(latestValue(changes)).toBe("@fix\n"); - } finally { - restorePointer(); - } - }); - - it("applies a coarse-pointer mention by tapping the result", async () => { - const restorePointer = mockPointerCoarse(true); - try { - const { changes } = renderPromptBox("@fix", { - mentionSuggestions: [githubIssueSuggestion], - }); - fireEvent.mouseDown( - await screen.findByRole("button", { name: /Fix login bug/u }), - { button: 0 }, - ); - - await waitFor(() => - expect(latestValue(changes)).toBe("@#42 Fix login bug "), - ); - } finally { - restorePointer(); - } - }); - - it("dismisses the current occurrence from the touch-accessible close button", 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(); - } + expect(onSubmit).not.toHaveBeenCalled(); }); it("reports the queued editor typeahead's open state and measured height", async () => { @@ -3870,7 +3653,6 @@ describe("PromptBoxInternal command typeahead submit", () => { await openCommandMenu(promptBoxRef, "/compact", "compact"); await act(async () => { - fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); }); await act(async () => {}); @@ -3902,7 +3684,6 @@ describe("PromptBoxInternal command typeahead submit", () => { await openCommandMenu(promptBoxRef, "/review", "review"); await act(async () => { - fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); }); await act(async () => {}); @@ -4085,8 +3866,6 @@ describe("PromptBoxInternal command typeahead navigation", () => { ).toContain("bg-state-active"), ); - fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowUp" }); - fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); await waitFor(() => expect(latestValue(changes)).toBe("/plan ")); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 55c97a7c52..6f05f7de03 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1302,12 +1302,10 @@ export function PromptBoxInternal({ const skipEditorChangeRef = useRef(false); const lastSyncedEditorValueRef = useRef(null); const triggerKeyRef = useRef(""); - const hasNavigatedTypeaheadRef = useRef(false); const handleEditorKeyDownRef = useRef< (event: KeyboardEvent, isOriginalIPadHardwareEnter?: boolean) => boolean >(() => false); const compositionEndedAtRef = useRef(Number.NEGATIVE_INFINITY); - const compositionNeedsTriggerSyncRef = useRef(false); const postCompositionKeyDownEvents = usePostCompositionKeyDownEvents(); const dispatchAppCommandKey = useAppCommandKeyDispatch(); // The TipTap editor is created once; its `onUpdate`/`onSelectionUpdate`/click @@ -1624,17 +1622,8 @@ export function PromptBoxInternal({ const dismissedTrigger = dismissedTriggerRef.current; const isRestoringAppliedMention = isRestoringAppliedMentionRef.current && dismissedTrigger !== null; - const detectedTrigger = findActiveTrigger(editor, triggers); if (dismissedTrigger && !isRestoringAppliedMention) { - if ( - !dismissedTrigger.hasLeftRange && - detectedTrigger?.from === dismissedTrigger.start - ) { - // Typing extends the same dismissed occurrence. Keep its session - // closed rather than interpreting the new caret position as a leave. - dismissedTrigger.end = caretPosition; - } const isWithinDismissedRange = caretPosition >= dismissedTrigger.start && caretPosition <= dismissedTrigger.end; @@ -1657,13 +1646,14 @@ export function PromptBoxInternal({ caretPosition <= dismissedTriggerRef.current.end)), ); - const nextTrigger = shouldSuppressTrigger ? null : detectedTrigger; + const nextTrigger = shouldSuppressTrigger + ? null + : findActiveTrigger(editor, triggers); const nextKey = nextTrigger ? `${nextTrigger.kind}:${nextTrigger.from}:${nextTrigger.to}:${nextTrigger.query}` : ""; if (nextKey !== triggerKeyRef.current) { triggerKeyRef.current = nextKey; - hasNavigatedTypeaheadRef.current = false; setSelectedIndex(0); } setActiveTrigger(nextTrigger); @@ -1770,26 +1760,6 @@ export function PromptBoxInternal({ // Magic Keyboard Enter for the next 500 ms. if (!_view.composing) return false; compositionEndedAtRef.current = event.timeStamp; - // Custom DOM handlers run before ProseMirror settles composition - // and flushes any pending DOM change. Two microtasks put this - // fallback after that flush; an ordinary post-composition update - // clears the flag and avoids dispatching the query twice. - queueMicrotask(() => { - queueMicrotask(() => { - if (!compositionNeedsTriggerSyncRef.current) return; - const currentEditor = editorRef.current; - if ( - !currentEditor || - currentEditor.isDestroyed || - currentEditor.view !== _view || - currentEditor.view.composing - ) { - return; - } - compositionNeedsTriggerSyncRef.current = false; - syncTriggerStateRef.current(currentEditor); - }); - }); return false; }, keydown: (_view, event) => { @@ -1921,43 +1891,15 @@ export function PromptBoxInternal({ // for native contenteditable edits; measuring it here with coordsAtPos // forces layout on every keystroke. if (transaction.docChanged) return; - if (updatedEditor.view.composing) { - compositionNeedsTriggerSyncRef.current = true; - return; - } - compositionNeedsTriggerSyncRef.current = false; syncTriggerStateRef.current(updatedEditor); scheduleRevealEditorSelection(); }, 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); - if (updatedEditor.view.composing) { - compositionNeedsTriggerSyncRef.current = true; - } else { - compositionNeedsTriggerSyncRef.current = false; - syncTriggerStateRef.current(updatedEditor); - } + syncTriggerStateRef.current(updatedEditor); // Native typing already asks ProseMirror to scroll the selection into // view. Clipboard and drop transactions still need the prompt's custom // scroll-container reveal that originally fixed multiline paste. @@ -2067,13 +2009,6 @@ export function PromptBoxInternal({ return; } - // A controlled replacement is a new draft occurrence, not continued - // typing in an explicitly dismissed typeahead session. Parent echoes of - // editor updates return above because `lastSyncedEditorValueRef` matches. - dismissedTriggerRef.current = null; - hasNavigatedTypeaheadRef.current = false; - triggerKeyRef.current = ""; - try { skipEditorChangeRef.current = true; editor.commands.setContent( @@ -2275,9 +2210,9 @@ export function PromptBoxInternal({ ? { kind: "error" } : { kind: "results", suggestions: orderedCommandSuggestions }; - // Loaded-empty suppression: command triggers and non-empty mention queries - // with zero loaded results stay literal text and close silently. A bare - // default `@` still opens the mention hint. + // Loaded-empty suppression (§6): a command trigger with zero loaded results + // (not loading, not error) is literal text — never open the menu. Mention + // triggers always open (they have a hint / "no matches" state). const isCommandTriggerLiteral = activeTriggerKind === "command" && !commandLoading && @@ -2291,14 +2226,7 @@ export function PromptBoxInternal({ !isVoiceBusy && activeTrigger !== null && !isCommandTriggerLiteral && - !isBareNonDefaultMentionTrigger && - !( - activeTriggerKind === "mention" && - activeMentionQuery.length > 0 && - !mentionLoading && - !mentionError && - mentionSuggestions.length === 0 - ); + !isBareNonDefaultMentionTrigger; const typeaheadMenuState: TypeaheadMenuState = activeTriggerKind === "command" @@ -2509,21 +2437,6 @@ export function PromptBoxInternal({ [applyCommandSuggestion, applyMentionSuggestion], ); - const dismissActiveTrigger = useCallback(() => { - triggerKeyRef.current = ""; - hasNavigatedTypeaheadRef.current = false; - 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; @@ -2792,17 +2705,11 @@ export function PromptBoxInternal({ const shouldBlurAfterSubmit = blurAfterPointerSubmitRef.current; blurAfterPointerSubmitRef.current = false; if (!canSubmit) return; - triggerKeyRef.current = ""; - hasNavigatedTypeaheadRef.current = false; - dismissedTriggerRef.current = null; - setActiveTrigger(null); - onMentionQueryChange(null, null); - onCommandQueryChange(null); onSubmit(); if (shouldBlurAfterSubmit) { blurPromptEditor(editorRef.current); } - }, [canSubmit, onCommandQueryChange, onMentionQueryChange, onSubmit]); + }, [canSubmit, onSubmit]); const handleSubmitClick = useCallback( (event: ReactMouseEvent) => { @@ -2974,7 +2881,6 @@ export function PromptBoxInternal({ activeSuggestions.length > 0 ) { event.preventDefault(); - hasNavigatedTypeaheadRef.current = true; if ( activeTriggerKind === "command" && !commandError && @@ -2995,18 +2901,16 @@ export function PromptBoxInternal({ activeSuggestions.length > 0 ) { event.preventDefault(); - hasNavigatedTypeaheadRef.current = true; setSelectedIndex( (prev) => (prev + activeSuggestions.length - 1) % activeSuggestions.length, ); return true; } - const canApplyTypeaheadFromKeyboard = - (!isPointerCoarse || isOriginalIPadHardwareEnter) && - (event.key === "Tab" || - (event.key === "Enter" && hasNavigatedTypeaheadRef.current)); - if (canApplyTypeaheadFromKeyboard && activeSuggestions.length > 0) { + if ( + (event.key === "Enter" || event.key === "Tab") && + activeSuggestions.length > 0 + ) { event.preventDefault(); const selected = activeSuggestions[selectedIndex] ?? activeSuggestions[0]; @@ -3025,9 +2929,21 @@ export function PromptBoxInternal({ } return true; } - if (event.key === "Escape" && !isPointerCoarse) { + if (event.key === "Escape") { event.preventDefault(); - dismissActiveTrigger(); + 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); return true; } } @@ -3160,6 +3076,7 @@ export function PromptBoxInternal({ [ activeHistoryIndex, activeSuggestions, + activeTrigger, activeTriggerKind, applyHistoryDraft, applyTrigger, @@ -3168,11 +3085,12 @@ export function PromptBoxInternal({ commandHasMore, commandIsLoadingMore, dispatchAppCommandKey, - dismissActiveTrigger, history, isPointerCoarse, loadMoreCommands, + onCommandQueryChange, onEscape, + onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, resetHistorySession, @@ -3330,7 +3248,6 @@ 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 d2111a6be3..bcb59aee17 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -43,7 +43,6 @@ interface MentionMenuProps { /** Currently-highlighted index in the results list (for keyboard nav). */ selectedIndex: number; onApply: (item: TypeaheadSuggestion) => void; - onDismiss?: () => void; onCommandLoadMore?: () => void; } @@ -517,7 +516,6 @@ export function MentionMenu({ state, selectedIndex, onApply, - onDismiss, onCommandLoadMore, }: MentionMenuProps) { const itemRefs = useRef>([]); @@ -560,19 +558,6 @@ export function MentionMenu({ return (
- {onDismiss ? ( -
- -
- ) : null}
{innerState.kind === "hint" ? (
diff --git a/apps/app/src/hooks/threadMentionSuggestions.test.ts b/apps/app/src/hooks/threadMentionSuggestions.test.ts index d323a4ed2e..3dab95815a 100644 --- a/apps/app/src/hooks/threadMentionSuggestions.test.ts +++ b/apps/app/src/hooks/threadMentionSuggestions.test.ts @@ -9,8 +9,6 @@ interface ThreadFixtureOptions { title: string | null; titleFallback?: string | null; visibility?: Thread["visibility"]; - status?: Thread["status"]; - latestAttentionAt?: number; } interface BuildSuggestionFixtureArgs { @@ -30,7 +28,7 @@ function makeThread(options: ThreadFixtureOptions): Thread { title: options.title, titleFallback: options.titleFallback ?? null, sectionId: null, - status: options.status ?? "idle", + status: "idle", parentThreadId: options.parentThreadId ?? null, sourceThreadId: null, originKind: null, @@ -40,7 +38,7 @@ function makeThread(options: ThreadFixtureOptions): Thread { pinnedAt: null, deletedAt: null, lastReadAt: null, - latestAttentionAt: options.latestAttentionAt ?? 1, + latestAttentionAt: 1, createdAt: 1, updatedAt: 1, }; @@ -169,42 +167,6 @@ describe("buildThreadMentionSuggestions", () => { ).toEqual(["thr_earlier", "thr_later"]); }); - it("uses activity and coarse attention recency after match and relationship quality", () => { - const hour = 60 * 60 * 1000; - const threads = [ - makeThread({ - id: "thr_idle_recent", - title: "Shared context", - latestAttentionAt: 20 * hour, - }), - makeThread({ - id: "thr_active_old", - title: "Shared context", - status: "active", - latestAttentionAt: 2 * hour, - }), - makeThread({ - id: "thr_active_recent", - title: "Shared context", - status: "stopping", - latestAttentionAt: 10 * hour, - }), - makeThread({ - id: "thr_active_same_bucket_z", - title: "Shared context", - status: "starting", - latestAttentionAt: 10 * hour + 20, - }), - ]; - - expect(getSuggestionThreadIds({ threads, query: "shared" })).toEqual([ - "thr_active_recent", - "thr_active_same_bucket_z", - "thr_active_old", - "thr_idle_recent", - ]); - }); - it("ranks directly related, same-parent, and same-project thread matches together", () => { const threads = [ makeThread({ diff --git a/apps/app/src/hooks/threadMentionSuggestions.ts b/apps/app/src/hooks/threadMentionSuggestions.ts index cac7bf5d66..2384f30a8e 100644 --- a/apps/app/src/hooks/threadMentionSuggestions.ts +++ b/apps/app/src/hooks/threadMentionSuggestions.ts @@ -20,8 +20,6 @@ interface BuildThreadMentionSuggestionsArgs { interface RankedThreadMentionSuggestion { suggestion: ThreadMentionSuggestion; relationRank: number; - activityRank: number; - attentionBucket: number; score: number; } @@ -38,16 +36,6 @@ const THREAD_RELATION_RANK = { unrelated: 3, }; -const ATTENTION_BUCKET_MS = 60 * 60 * 1000; - -function getThreadActivityRank(thread: Thread): number { - return thread.status === "active" || - thread.status === "starting" || - thread.status === "stopping" - ? 0 - : 1; -} - function getThreadDisplayTitle(thread: Thread): string | undefined { const title = thread.title?.trim(); if (title) { @@ -159,12 +147,6 @@ function compareRankedThreadMentionSuggestions( if (left.relationRank !== right.relationRank) { return left.relationRank - right.relationRank; } - if (left.activityRank !== right.activityRank) { - return left.activityRank - right.activityRank; - } - if (left.attentionBucket !== right.attentionBucket) { - return right.attentionBucket - left.attentionBucket; - } const leftTitle = left.suggestion.title ?? ""; const rightTitle = right.suggestion.title ?? ""; return ( @@ -200,10 +182,6 @@ export function buildThreadMentionSuggestions( args.projectNamesById, ), relationRank: getThreadRelationRank(match.item, context), - activityRank: getThreadActivityRank(match.item), - attentionBucket: Math.floor( - match.item.latestAttentionAt / ATTENTION_BUCKET_MS, - ), score: match.score, })) .sort(compareRankedThreadMentionSuggestions) diff --git a/apps/mobile/src/composer/model/suggestions.test.ts b/apps/mobile/src/composer/model/suggestions.test.ts index 2019952c69..07c99da391 100644 --- a/apps/mobile/src/composer/model/suggestions.test.ts +++ b/apps/mobile/src/composer/model/suggestions.test.ts @@ -16,8 +16,6 @@ function thread(overrides: Partial & { id: string }): Thread { projectId: "proj_1", parentThreadId: null, visibility: "visible", - status: "idle", - latestAttentionAt: 1, ...overrides, } as Thread; } @@ -54,49 +52,6 @@ describe("buildThreadMentionSuggestions", () => { expect(result[1]?.projectName).toBe("Other project"); }); - it("uses activity and coarse attention recency after match and relationship quality", () => { - const hour = 60 * 60 * 1000; - const threads = [ - thread({ - id: "thr_idle_recent", - title: "Shared context", - latestAttentionAt: 20 * hour, - }), - thread({ - id: "thr_active_old", - title: "Shared context", - status: "active", - latestAttentionAt: 2 * hour, - }), - thread({ - id: "thr_active_recent", - title: "Shared context", - status: "stopping", - latestAttentionAt: 10 * hour, - }), - thread({ - id: "thr_active_same_bucket_z", - title: "Shared context", - status: "starting", - latestAttentionAt: 10 * hour + 20, - }), - ]; - - const result = buildThreadMentionSuggestions({ - threads, - query: "shared", - projectNamesById: new Map(), - limit: 8, - }); - - expect(result.map((entry) => entry.threadId)).toEqual([ - "thr_active_recent", - "thr_active_same_bucket_z", - "thr_active_old", - "thr_idle_recent", - ]); - }); - it("returns nothing for an empty query", () => { expect( buildThreadMentionSuggestions({ diff --git a/apps/mobile/src/composer/model/suggestions.ts b/apps/mobile/src/composer/model/suggestions.ts index 14d1903b30..d0f14d7f55 100644 --- a/apps/mobile/src/composer/model/suggestions.ts +++ b/apps/mobile/src/composer/model/suggestions.ts @@ -48,22 +48,12 @@ const THREAD_RELATION_RANK = { unrelated: 3, }; -const ATTENTION_BUCKET_MS = 60 * 60 * 1000; - interface ThreadMentionContext { currentParentThreadId: string | null; currentProjectId?: string; currentThreadId?: string; } -function threadActivityRank(thread: Thread): number { - return thread.status === "active" || - thread.status === "starting" || - thread.status === "stopping" - ? 0 - : 1; -} - function threadDisplayTitle(thread: Thread): string | undefined { const title = thread.title?.trim(); if (title) return title; @@ -150,10 +140,6 @@ export function buildThreadMentionSuggestions( return { suggestion, relationRank: threadRelationRank(thread, context), - activityRank: threadActivityRank(thread), - attentionBucket: Math.floor( - thread.latestAttentionAt / ATTENTION_BUCKET_MS, - ), score: match.score, }; }) @@ -161,8 +147,6 @@ export function buildThreadMentionSuggestions( (left, right) => right.score - left.score || left.relationRank - right.relationRank || - left.activityRank - right.activityRank || - right.attentionBucket - left.attentionBucket || (left.suggestion.title ?? "").localeCompare( right.suggestion.title ?? "", ) || 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 323b15c74a..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,16 +43,14 @@ function triggerPattern( options: { windowed: boolean }, ): RegExp { const escapedChar = escapeRegexLiteral(trigger.char); - const queryClass = - trigger.kind === "mention" - ? `[^\\r\\n${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 e89810fb4b..17195dcbce 100644 --- a/packages/client-core/test/find-active-trigger.test.ts +++ b/packages/client-core/test/find-active-trigger.test.ts @@ -86,8 +86,8 @@ describe("findActiveTrigger", () => { ).toBeNull(); }); - it("keeps spaces and tabs verbatim in a multiword mention query", () => { - const query = "prompt \t mention "; + it("keeps spaces verbatim in a multiword mention query", () => { + const query = "prompt mention "; const text = `Ask @${query}`; expect( findActiveTrigger(editorWithText(text), [{ char: "@", kind: "mention" }]), @@ -100,17 +100,25 @@ describe("findActiveTrigger", () => { }); }); - it.each(["!", "?", ",", ";", ")", "]", "}", "\n"])( - "ends a mention query at %j", - (punctuation) => { + it.each(["\t", "\n"])( + "keeps existing non-space whitespace termination for %j", + (whitespace) => { expect( - findActiveTrigger(editorWithText(`Ask @prompt${punctuation}`), [ + 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"), [ From 7b21e232bcbd8fb3e8622dece00cd40fc19c1136 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 13:02:43 -0700 Subject: [PATCH 6/8] fix: keep extended mention dismissal closed --- .../promptbox/PromptBoxInternal.test.tsx | 14 +++++++++++++ .../promptbox/PromptBoxInternal.tsx | 20 ++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 3e0894a3a4..01b4ba3ab5 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2812,6 +2812,20 @@ describe("PromptBoxInternal mention triggers", () => { 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("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..0679897336 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}` : ""; From 522376f65e772951f743a283d59306fa9bbbf1fd Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 13:09:26 -0700 Subject: [PATCH 7/8] fix: add touch autocomplete dismissal --- .../promptbox/PromptBoxInternal.test.tsx | 28 ++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 33 ++++++++++--------- .../promptbox/mentions/MentionMenu.tsx | 15 +++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 01b4ba3ab5..aea6077ef4 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2826,6 +2826,34 @@ describe("PromptBoxInternal mention triggers", () => { 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("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 0679897336..14805499a5 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -2447,6 +2447,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; @@ -2941,19 +2955,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; } } @@ -3086,7 +3088,6 @@ export function PromptBoxInternal({ [ activeHistoryIndex, activeSuggestions, - activeTrigger, activeTriggerKind, applyHistoryDraft, applyTrigger, @@ -3095,12 +3096,11 @@ export function PromptBoxInternal({ commandHasMore, commandIsLoadingMore, dispatchAppCommandKey, + dismissActiveTrigger, history, isPointerCoarse, loadMoreCommands, - onCommandQueryChange, onEscape, - onMentionQueryChange, onModifierSubmit, postCompositionKeyDownEvents, resetHistorySession, @@ -3258,6 +3258,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..d2111a6be3 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; } @@ -516,6 +517,7 @@ export function MentionMenu({ state, selectedIndex, onApply, + onDismiss, onCommandLoadMore, }: MentionMenuProps) { const itemRefs = useRef>([]); @@ -558,6 +560,19 @@ export function MentionMenu({ return (
+ {onDismiss ? ( +
+ +
+ ) : null}
{innerState.kind === "hint" ? (
From 5d3f6a33279f73ef26bc313964cd444d1377d8e6 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 25 Aug 2026 13:30:28 -0700 Subject: [PATCH 8/8] fix: reset touch autocomplete dismissal --- .../promptbox/PromptBoxInternal.test.tsx | 51 ++++++++ .../promptbox/PromptBoxInternal.tsx | 23 ++++ .../promptbox/mentions/MentionMenu.tsx | 120 +++++++++++++----- 3 files changed, 165 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index aea6077ef4..f572c70aa6 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2854,6 +2854,57 @@ describe("PromptBoxInternal mention triggers", () => { } }); + 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 14805499a5..3880fd3ba2 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1906,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); @@ -2019,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( diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index d2111a6be3..36e6d5f5ab 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -353,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(() => { @@ -375,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; @@ -448,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( @@ -473,11 +539,12 @@ function CommandResults({ return (
- {sections.map((section) => ( + {sections.map((section, sectionIndex) => (
-
- {section.label} -
+
{section.items.map(({ item, index }) => ( - {onDismiss ? ( -
- -
- ) : null}
{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" ? ( ) : ( )}