From ee1e3b5daa2d21382f6ab47f06456d5ecb37c5af Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Thu, 12 Nov 2026 16:41:56 +0400 Subject: [PATCH 1/8] fix(composer): continue active mention searches across spaces --- .../threads/use-composer-command-menu.ts | 64 +++++++++++++++-- ...rPromptEditorTiptap.input.browser.test.tsx | 53 ++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 24 +++++-- apps/web/src/components/chat/ChatComposer.tsx | 43 ++++++++---- .../chat/useComposerTriggerState.ts | 70 +++++++++++++++++-- apps/web/src/composer-logic.ts | 5 +- apps/web/src/composer-rich-text-doc.test.ts | 18 ++++- apps/web/src/composer-undo-grouping.ts | 4 +- packages/shared/src/composerTrigger.test.ts | 42 +++++++++++ packages/shared/src/composerTrigger.ts | 30 ++++++++ .../scenarios/mention-search-spaces.e2e.ts | 27 +++++++ 11 files changed, 340 insertions(+), 40 deletions(-) create mode 100644 scripts/scenarios/mention-search-spaces.e2e.ts diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1e66bf4ee2c8e..9c71be1ab937c 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -30,6 +30,7 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + continueComposerPathTrigger, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -328,12 +329,63 @@ export function useComposerCommandMenu({ setSelection(nextSelection); }; - const trigger = (() => { - if (!enabled || selection.start !== selection.end) { - return null; - } - return detectComposerTrigger(draftMessage, selection.end); - })(); + const [previousSearch, setPreviousSearch] = useState(() => ({ + ownerKey, + draftMessage, + selection, + localSelection, + insertedSelection, + cursor: selection.end, + queryEnd: selection.end, + enabled, + trigger: enabled ? detectComposerTrigger(draftMessage, selection.end) : null, + })); + let trigger = previousSearch.trigger; + if ( + previousSearch.ownerKey !== ownerKey || + previousSearch.draftMessage !== draftMessage || + previousSearch.localSelection !== localSelection || + previousSearch.insertedSelection?.start !== insertedSelection?.start || + previousSearch.insertedSelection?.end !== insertedSelection?.end || + previousSearch.enabled !== enabled + ) { + // Draft updates can lead selection updates by several edits. Accumulate + // those edits from the predicted cursor until selection catches up. + const cursor = + previousSearch.ownerKey !== ownerKey + ? draftMessage.length + : previousSearch.localSelection === localSelection && + previousSearch.insertedSelection?.start === insertedSelection?.start && + previousSearch.insertedSelection?.end === insertedSelection?.end + ? previousSearch.cursor + draftMessage.length - previousSearch.draftMessage.length + : selection.end; + const continued = continueComposerPathTrigger( + draftMessage, + cursor, + previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null, + previousSearch.draftMessage, + previousSearch.queryEnd, + ); + trigger = + enabled && selection.start === selection.end + ? (detectComposerTrigger(draftMessage, cursor) ?? continued?.trigger ?? null) + : null; + const queryEnd = + trigger?.kind === "path" && trigger.rangeStart === continued?.trigger.rangeStart + ? continued.queryEnd + : (trigger?.rangeEnd ?? 0); + setPreviousSearch({ + ownerKey, + draftMessage, + selection, + localSelection, + insertedSelection, + cursor, + queryEnd, + enabled, + trigger, + }); + } const pathSearch = useComposerPathSearch({ environmentId, cwd: trigger?.kind === "path" ? projectCwd : null, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 6971383b851e5..d7707029ba703 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -15,6 +15,8 @@ import { createElement, createRef, useEffect, useState } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { useComposerTriggerState } from "./chat/useComposerTriggerState"; + import { act, fakeTimers, render } from "../../test/render"; import { flatToMarkdown, pmToFlat, serializeEditorDoc } from "~/composer-rich-text-doc"; @@ -912,3 +914,54 @@ describe("an Arabic line", () => { ); }); }); + +it("continues an active path search through literal paste and keeps Escape dismissed", async () => { + function ActiveSearch() { + const [prompt, setPrompt] = useState("Inspect @Foreign "); + const state = useComposerTriggerState("Inspect @Foreign"); + return ( + ()} + value={prompt} + cursor={prompt.length} + skills={[]} + disabled={false} + placeholder="" + contextRecords={new Map()} + richTextEnabled={true} + isPathQueryActive={() => state.trigger?.kind === "path"} + onChange={(value, _cursor, expandedCursor) => { + setPrompt(value); + state.setTrigger(state.detectTrigger(value, expandedCursor)); + }} + onPaste={() => {}} + onCommandKeyDown={(key) => { + if (key !== "Escape") return false; + state.dismissTrigger(state.trigger); + return true; + }} + /> + ); + } + await using screen = await render(); + const editor = editorIn(screen.container); + await act(() => { + editor.commands.focus("end"); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", "__Subsidiaries__.md"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe("Inspect @Foreign __Subsidiaries__.md"); + expect(editor.view.dom.querySelector("strong")).toBeNull(); + await act(() => + editor.view.dom.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })), + ); + await act(() => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", " __ordinary__"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + expect(serializeEditorDoc(editor.state.doc).value).toBe( + "Inspect @Foreign __Subsidiaries__.md **ordinary**", + ); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5895b63cbb9a0..cc6ab400266c3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -142,6 +142,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSelectionRange: () => { start: number; end: number }; + hasSelection: () => boolean; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1412,11 +1413,23 @@ function ComposerPromptEditorTiptapInner( ); return true; } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, pastedText, readCallbacks().isPathQueryActive?.()); + const pastedSuffix = pastedText.slice(literalLength); const importFragment = readCallbacks().importContextFragment; let text = !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; + ? importPastedComposerText( + { + getData: (format) => + format === "text/plain" ? pastedSuffix : clipboardData.getData(format), + }, + importFragment, + ) + : pastedSuffix; const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); const isChip = (token: (typeof tokens)[number] | undefined) => token?.type === "mention" || @@ -1431,11 +1444,7 @@ function ComposerPromptEditorTiptapInner( const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; } - const { $from } = view.state.selection; - const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText - ? 0 - : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + text = pastedText.slice(0, literalLength) + text; const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; if (editorInstance) { const $paste = view.state.selection.$from; @@ -1729,6 +1738,7 @@ function ComposerPromptEditorTiptapInner( readSnapshot(); return selectionRangeRef.current; }, + hasSelection: () => editor !== null && !editor.state.selection.empty, requestCitationComment: (request) => { citationRequestRef.current = request; if (!editor) return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bafafcb9612cf..5b14e91980ffc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -975,13 +975,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const { trigger: composerTrigger, + detectTrigger: detectActiveComposerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, dismissTrigger: dismissComposerTrigger, resetTrigger: resetComposerTrigger, - } = useComposerTriggerState(() => - isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), - ); + clearPathTriggerForSelection: clearComposerPathTriggerForSelection, + } = useComposerTriggerState(prompt, !isLiteralPendingAnswer); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const composerSuggestionId = useId(); const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; @@ -1183,7 +1183,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length), nextPrompt); scheduleComposerFocus(); }; const editPromptFromTraitsStable = useStableCallback(editPromptFromTraits); @@ -1434,7 +1434,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = prompt; const { cursor, trigger } = composerStateAtPromptEnd(prompt); setComposerCursor(cursor); - resetComposerTrigger(trigger); + resetComposerTrigger(trigger, prompt); } lastSyncedPendingInputRef.current = null; return; @@ -1459,7 +1459,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = nextCustomAnswer; const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer); setComposerCursor(cursor); - resetComposerTrigger(trigger); + resetComposerTrigger(trigger, nextCustomAnswer); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, @@ -1480,12 +1480,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerSubmissionError(null); setProviderInputSubmissionError(null); const pendingAnswer = activePendingProgress?.customAnswer; - const { cursor, trigger } = composerStateAtPromptEnd( - typeof pendingAnswer === "string" ? pendingAnswer : prompt, - isLiteralPendingAnswer, - ); + const nextPrompt = typeof pendingAnswer === "string" ? pendingAnswer : prompt; + const { cursor, trigger } = composerStateAtPromptEnd(nextPrompt, isLiteralPendingAnswer); setComposerCursor(cursor); - resetComposerTrigger(trigger); + resetComposerTrigger(trigger, nextPrompt); setIsDragOverComposer(false); } @@ -1562,12 +1560,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { + const hasSelection = + nextPrompt === promptRef.current && composerEditorRef.current?.hasSelection() === true; refreshWorkspaceSnapshotOnTyping(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor), + undefined, + hasSelection, ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -1690,7 +1692,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor), + undefined, + hasSelection, ); }; const onPromptChangeStable = useStableCallback(onPromptChange); @@ -1762,6 +1766,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(nextCursor); setComposerTrigger( isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), + next.text, ); if (options?.focusEditorAfterReplace !== false) { window.requestAnimationFrame(() => { @@ -1868,7 +1873,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) snapshot, trigger: isLiteralPendingAnswer ? null - : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + : resolveComposerTrigger( + detectActiveComposerTrigger(snapshot.value, snapshot.expandedCursor), + composerEditorRef.current?.hasSelection() === true, + ), }; }; @@ -3079,6 +3087,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandCollapsedComposerCursor(promptForState, cursor), ) : null, + promptForState, ); }, addTerminalContext: (selection: TerminalContextSelection) => { @@ -3118,7 +3127,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!inserted) return; promptRef.current = insertion.prompt; setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + setComposerTrigger( + detectComposerTrigger(insertion.prompt, insertion.cursor), + insertion.prompt, + ); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); @@ -3543,6 +3555,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) skills={selectedProviderSkills} className={cn(showMobilePendingAnswerActions && "max-sm:pb-12")} onChange={onPromptChangeStable} + onVisibleSelectionChange={clearComposerPathTriggerForSelection} onCommandKeyDown={onComposerCommandKeyStable} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 6adf037e1e45b..0207c4bf24a58 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.ts +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -1,12 +1,47 @@ import { useRef, useState } from "react"; -import type { ComposerTrigger } from "../../composer-logic"; +import { continueComposerPathTrigger } from "@t3tools/shared/composerTrigger"; +import { detectComposerTrigger, type ComposerTrigger } from "../../composer-logic"; -export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { - const [trigger, setActiveTrigger] = useState(initialTrigger); +export function useComposerTriggerState(initialText: string, initialEnabled = true) { + const [trigger, setActiveTrigger] = useState(() => + initialEnabled ? detectComposerTrigger(initialText, initialText.length) : null, + ); + const previousSearchRef = useRef({ + text: initialText, + trigger, + queryEnd: trigger?.rangeEnd ?? 0, + }); const dismissedTriggerRef = useRef(null); - const resolveTrigger = (candidate: ComposerTrigger | null) => { + const detectTrigger = (text: string, cursor: number) => { + const previous = previousSearchRef.current; + const continued = continueComposerPathTrigger( + text, + cursor, + previous.trigger, + previous.text, + previous.queryEnd, + ); + const candidate = detectComposerTrigger(text, cursor) ?? continued?.trigger ?? null; + const queryEnd = + candidate?.kind === "path" && candidate.rangeStart === continued?.trigger.rangeStart + ? continued.queryEnd + : (candidate?.rangeEnd ?? 0); + previousSearchRef.current = { text, trigger: candidate, queryEnd }; + return candidate; + }; + + const clearPathTriggerForSelection = () => { + previousSearchRef.current = { text: "", trigger: null, queryEnd: 0 }; + setActiveTrigger((current) => (current?.kind === "path" ? null : current)); + }; + + const resolveTrigger = (candidate: ComposerTrigger | null, hasSelection = false) => { + if (hasSelection && candidate?.kind === "path") { + clearPathTriggerForSelection(); + return null; + } const dismissed = dismissedTriggerRef.current; return candidate && dismissed && @@ -16,7 +51,19 @@ export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | : candidate; }; - const setTrigger = (candidate: ComposerTrigger | null) => { + const setTrigger = (candidate: ComposerTrigger | null, text?: string, hasSelection = false) => { + if (hasSelection && (candidate === null || candidate.kind === "path")) { + previousSearchRef.current = { text: "", trigger: null, queryEnd: 0 }; + setActiveTrigger(null); + return; + } + if (text !== undefined || candidate === null) { + previousSearchRef.current = { + text: text ?? "", + trigger: candidate, + queryEnd: candidate?.rangeEnd ?? 0, + }; + } const activeTrigger = resolveTrigger(candidate); if (candidate === null || activeTrigger !== null) { dismissedTriggerRef.current = null; @@ -29,10 +76,19 @@ export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | setActiveTrigger(null); }; - const resetTrigger = (candidate: ComposerTrigger | null) => { + const resetTrigger = (candidate: ComposerTrigger | null, text: string) => { + previousSearchRef.current = { text, trigger: candidate, queryEnd: candidate?.rangeEnd ?? 0 }; dismissedTriggerRef.current = null; setActiveTrigger(candidate); }; - return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; + return { + trigger, + detectTrigger, + setTrigger, + resolveTrigger, + dismissTrigger, + resetTrigger, + clearPathTriggerForSelection, + }; } diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 37fef411897fc..4b46dfb6cf390 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -295,9 +295,10 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function pastedPathQueryLength( lineBefore: string, pasted: string, - pathQueryActive = true, + pathQueryActive?: boolean, ): number { - if (!pathQueryActive) return 0; + if (pathQueryActive === true) return /^[^\r\n\t]*/.exec(pasted)![0].length; + if (pathQueryActive === false) return 0; if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0; return /^\S*/.exec(pasted)![0].length; } diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index e8a4bdccfcc48..af8916deff2cf 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -897,7 +897,7 @@ describe("model mention chips", () => { describe("pasting into a path query", () => { const skillLabelFor = (name: string) => ({ label: name, description: null }); - function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) { + function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive?: boolean) { const promptDoc = typeof prompt === "string" ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor)) @@ -929,6 +929,9 @@ describe("pasting into a path query", () => { const doc = paste("Open @", path); expect(boldText(doc)).toEqual([]); expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`); + const continued = paste("Open @Foreign ", path, true); + expect(boldText(continued)).toEqual([]); + expect(serializeEditorDoc(continued).value).toBe(`Open @Foreign ${path}`); }, ); @@ -939,12 +942,25 @@ describe("pasting into a path query", () => { expect(serializeEditorDoc(doc).value).toBe( "Open @src/__test__.tsx and **this**\n- [ ] then **that**", ); + const continued = paste( + "Open @Foreign ", + "__Subsidiaries__.md and **this**\n- [ ] then __that__", + true, + ); + expect(boldText(continued)).toEqual(["that"]); + expect(serializeEditorDoc(continued).value).toBe( + "Open @Foreign __Subsidiaries__.md and **this**\n- [ ] then **that**", + ); + expect(boldText(paste("Open @Foreign ", "__Subsidiaries__.md\t__that__", true))).toEqual([ + "that", + ]); }); it("parses markdown after Escape dismisses the path query", () => { const doc = paste("Open @", "__hello__ world", false); expect(boldText(doc)).toEqual(["hello"]); expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world"); + expect(boldText(paste("Open @Foreign ", "__hello__ world", false))).toEqual(["hello"]); }); it("keeps the rest of the path's line on that line", () => { diff --git a/apps/web/src/composer-undo-grouping.ts b/apps/web/src/composer-undo-grouping.ts index 8ccad3a5896bf..c19a3081d9237 100644 --- a/apps/web/src/composer-undo-grouping.ts +++ b/apps/web/src/composer-undo-grouping.ts @@ -9,12 +9,12 @@ export type ComposerChangeKind = "insert" | "delete" | "other"; /** Tag custom paste/cut dispatches; ProseMirror only tags its built-in paths. */ export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction { - return tr.setMeta("uiEvent", event); + return tr.setMeta("composerClipboardEdit", event); } function changeKindOf(tr: Transaction): ComposerChangeKind | null { if (!tr.docChanged) return null; - const uiEvent = tr.getMeta("uiEvent"); + const uiEvent = tr.getMeta("composerClipboardEdit") ?? tr.getMeta("uiEvent"); if (uiEvent === "paste" || uiEvent === "drop" || uiEvent === "cut") return "other"; // IME input replaces its own range on each keystroke; it is still typing. if (tr.getMeta("composition") != null) return "insert"; diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 72ff3db848cf5..c3a5eb956c8ce 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + continueComposerPathTrigger, detectComposerTrigger, parseSideChatCommand, serializeComposerFileLink, @@ -111,3 +112,44 @@ describe("parseSideChatCommand", () => { expect(parseSideChatCommand("ask /side later")).toBeNull(); }); }); + +describe("active path query continuation", () => { + it("keeps spaces and the far query boundary while moving the caret", () => { + const before = "Inspect @Foreign then summarize"; + const cursor = "Inspect @Foreign".length; + const text = "Inspect @Foreign Subsidiaries then summarize"; + const end = "Inspect @Foreign Subsidiaries".length; + const result = continueComposerPathTrigger( + text, + end, + detectComposerTrigger(before, cursor), + before, + cursor, + ); + expect(result).toEqual({ + trigger: { kind: "path", query: "Foreign Subsidiaries", rangeStart: 8, rangeEnd: end }, + queryEnd: end, + }); + const back = continueComposerPathTrigger(text, cursor, result!.trigger, text, result!.queryEnd); + expect(back?.trigger.query).toBe("Foreign"); + expect( + continueComposerPathTrigger(text, end, back!.trigger, text, back!.queryEnd)?.trigger.query, + ).toBe("Foreign Subsidiaries"); + expect(continueComposerPathTrigger(text, text.length, result!.trigger, text, end)).toBeNull(); + expect(continueComposerPathTrigger(text, end, null, before, cursor)).toBeNull(); + }); + + it.each(["\n", "\r", "\t", "\uFFFC"])("ends at the %j boundary", (boundary) => { + const before = "@Foreign"; + const text = before + boundary; + expect( + continueComposerPathTrigger( + text, + text.length, + detectComposerTrigger(before, before.length), + before, + before.length, + ), + ).toBeNull(); + }); +}); diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 80292492154a8..c1aeacb3932f2 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -45,6 +45,36 @@ function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r"; } +/** Continue an already open @ search across spaces without finding old mentions in prose. */ +export function continueComposerPathTrigger( + text: string, + cursorInput: number, + previous: ComposerTrigger | null, + previousText: string, + previousQueryEnd: number, +): { trigger: ComposerTrigger & { kind: "path" }; queryEnd: number } | null { + if (previous?.kind !== "path") return null; + const cursor = clampCursor(text, cursorInput); + const start = previous.rangeStart; + const queryEnd = previousQueryEnd + text.length - previousText.length; + // Only edits within the active query may extend its range. A caret jump must + // not absorb the unchanged suffix of the prompt into a mention replacement. + if ( + text[start] !== "@" || + cursor <= start || + cursor > queryEnd || + text.slice(0, start + 1) !== previousText.slice(0, start + 1) || + text.slice(queryEnd) !== previousText.slice(previousQueryEnd) + ) + return null; + const query = text.slice(start + 1, cursor); + if (/[\r\n\t\uFFFC]/u.test(query)) return null; + return { + trigger: { kind: "path", query, rangeStart: start, rangeEnd: cursor }, + queryEnd, + }; +} + /** A second slash makes the whole word a path, even when the caret precedes that slash. */ export function isPathWord( text: string, diff --git a/scripts/scenarios/mention-search-spaces.e2e.ts b/scripts/scenarios/mention-search-spaces.e2e.ts new file mode 100644 index 0000000000000..e9ae4d86dafc6 --- /dev/null +++ b/scripts/scenarios/mention-search-spaces.e2e.ts @@ -0,0 +1,27 @@ +import { expect } from "e2e"; + +import { test, WEB } from "./t3.ts"; + +test( + "An active mention search accepts a filename containing spaces", + WEB, + async ({ t3, app, screen }) => { + await t3.project("mention-search", { + "Foreign Subsidiaries/Script Notes.md": "Fixture notes\n", + "Foreign Unrelated.md": "Other fixture\n", + }); + await t3.open(); + const composer = screen.getByRole("textbox"); + await composer.fill("Inspect @Foreign"); + await expect(screen.getByRole("option").first()).toBeVisible(); + await expect(screen.getByText("Foreign Unrelated.md", { exact: true })).toBeVisible(); + await composer.press("End"); + await composer.press("Space"); + await app.screenshot("multiword-search"); + await expect(screen.getByRole("option").first()).toBeVisible(); + for (const character of "Subsidiaries") await composer.press(character); + await expect(screen.getByText("Foreign Unrelated.md", { exact: true })).toBeHidden(); + await composer.press("Enter"); + await expect(composer).toContainText("Foreign Subsidiaries"); + }, +); From 1b03c553cfb0ad01b2d059a5f06a5d53b7601d4a Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Thu, 12 Nov 2026 17:01:18 +0400 Subject: [PATCH 2/8] fix(mobile): honor restored composer selections --- .../threads/use-composer-command-menu.test.ts | 68 +++++++++++++++++++ .../threads/use-composer-command-menu.ts | 7 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 17f970a1ef22d..3242094075e1b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,9 +1,14 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { useComposerDraftSelection } from "../../state/use-composer-drafts"; + import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ + useThreadSearch: vi.fn(), useComposerPathSearch: () => ({ entries: [], isPending: false }), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); @@ -26,6 +31,7 @@ import { buildComposerSlashCommandItems, resolveComposerCommandSelection, resolveComposerMenuSelection, + useComposerCommandMenu, } from "./use-composer-command-menu"; describe("resolveComposerMenuSelection", () => { @@ -174,3 +180,65 @@ describe("mobile model mentions", () => { expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]); }); }); + +async function mountMenu(draftMessage: string, selection: { start: number; end: number }) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const root = createRoot(document.createElement("div")); + let menu: ReturnType | undefined; + function Probe({ text, ownerKey }: { text: string; ownerKey: string }) { + const value = useComposerCommandMenu({ + draftMessage: text, + ownerKey, + environmentId: null, + projectCwd: null, + selectedProviderStatus: null, + hasThread: false, + hasCompactableConversation: false, + onChangeDraftMessage: () => {}, + }); + useLayoutEffect(() => { + menu = value; + }); + return null; + } + const dispose = async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + vi.mocked(useComposerDraftSelection).mockReset(); + }; + const renderDraft = async ( + text: string, + caret: { start: number; end: number }, + ownerKey = "draft-a", + ) => { + vi.mocked(useComposerDraftSelection).mockReturnValue(caret); + await act(() => root.render(createElement(Probe, { text, ownerKey }))); + if (!menu) throw new Error("Composer menu did not mount"); + return menu; + }; + try { + await renderDraft(draftMessage, selection); + return { + renderDraft, + get menu() { + return menu!; + }, + [Symbol.asyncDispose]: dispose, + }; + } catch (error) { + await dispose(); + throw error; + } +} + +it("keeps the picker closed when mounting with a selected mention", async () => { + await using state = await mountMenu("@foo", { start: 0, end: 4 }); + expect(state.menu.trigger).toBeNull(); + expect(state.menu.items).toEqual([]); +}); + +it("uses the restored caret when switching draft owners", async () => { + await using state = await mountMenu("@first", { start: 6, end: 6 }); + const menu = await state.renderDraft("@foo suffix @last", { start: 4, end: 4 }, "draft-b"); + expect(menu.trigger).toMatchObject({ query: "foo", rangeStart: 0, rangeEnd: 4 }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 9c71be1ab937c..c2aea391d626b 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -338,7 +338,10 @@ export function useComposerCommandMenu({ cursor: selection.end, queryEnd: selection.end, enabled, - trigger: enabled ? detectComposerTrigger(draftMessage, selection.end) : null, + trigger: + enabled && selection.start === selection.end + ? detectComposerTrigger(draftMessage, selection.end) + : null, })); let trigger = previousSearch.trigger; if ( @@ -353,7 +356,7 @@ export function useComposerCommandMenu({ // those edits from the predicted cursor until selection catches up. const cursor = previousSearch.ownerKey !== ownerKey - ? draftMessage.length + ? selection.end : previousSearch.localSelection === localSelection && previousSearch.insertedSelection?.start === insertedSelection?.start && previousSearch.insertedSelection?.end === insertedSelection?.end diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 56fafe6d676cc..04729250d5e23 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/threads/use-composer-command-menu.test.ts", "src/features/threads/git/GitBranchesSheet.test.tsx", "src/features/threads/new-task-flow-provider.test.tsx", "src/features/layout/workspace/workspace-tabs-state.test.ts", From c7790eedbfbb40d4b38707676bf480bac9547877 Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Thu, 12 Nov 2026 17:16:25 +0400 Subject: [PATCH 3/8] test(composer): verify multiword mention acceptance --- scripts/scenarios/mention-search-spaces.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/scenarios/mention-search-spaces.e2e.ts b/scripts/scenarios/mention-search-spaces.e2e.ts index e9ae4d86dafc6..a31d7d6601ccb 100644 --- a/scripts/scenarios/mention-search-spaces.e2e.ts +++ b/scripts/scenarios/mention-search-spaces.e2e.ts @@ -21,7 +21,12 @@ test( await expect(screen.getByRole("option").first()).toBeVisible(); for (const character of "Subsidiaries") await composer.press(character); await expect(screen.getByText("Foreign Unrelated.md", { exact: true })).toBeHidden(); + await expect(screen.getByRole("option", "Foreign Subsidiaries", { exact: true })).toBeVisible(); await composer.press("Enter"); - await expect(composer).toContainText("Foreign Subsidiaries"); + await expect( + screen.getByRole("button", "Preview Foreign Subsidiaries", { exact: true }), + ).toBeVisible(); + await expect(screen.getByRole("listbox", "Files and folders")).toBeHidden(); + await app.screenshot("accepted-mention"); }, ); From 9cb8bb4d45ad1b7a8d988e40eaf8eca197ac440b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 16:59:52 +0000 Subject: [PATCH 4/8] fix(composer): close a spaced mention search that no result contains A path search continued across every space, so prose typed after an @word became the query. The fuzzy path search still matched it, and Enter then replaced the sentence with an unrelated file chip instead of sending. A spaced query now stays open only while a settled path result contains it literally; while results load it stays open. Web passes the current path results when it detects the trigger; mobile searches for the stored trigger so the next derivation can check the previous results. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../threads/use-composer-command-menu.ts | 13 ++++--- ...rPromptEditorTiptap.input.browser.test.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 17 +++++++-- .../chat/useComposerTriggerState.ts | 3 +- packages/shared/src/composerTrigger.test.ts | 38 +++++++++++++++++-- packages/shared/src/composerTrigger.ts | 15 +++++++- 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index c2aea391d626b..1b98f3429bdba 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -343,6 +343,13 @@ export function useComposerCommandMenu({ ? detectComposerTrigger(draftMessage, selection.end) : null, })); + // Searches for the stored trigger: a change below re-renders before commit, so the committed + // render searches the current query while the update can check the previous results. + const pathSearch = useComposerPathSearch({ + environmentId, + cwd: previousSearch.trigger?.kind === "path" ? projectCwd : null, + query: previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : null, + }); let trigger = previousSearch.trigger; if ( previousSearch.ownerKey !== ownerKey || @@ -368,6 +375,7 @@ export function useComposerCommandMenu({ previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null, previousSearch.draftMessage, previousSearch.queryEnd, + pathSearch.isPending ? null : pathSearch.entries.map((entry) => entry.path), ); trigger = enabled && selection.start === selection.end @@ -389,11 +397,6 @@ export function useComposerCommandMenu({ trigger, }); } - const pathSearch = useComposerPathSearch({ - environmentId, - cwd: trigger?.kind === "path" ? projectCwd : null, - query: trigger?.kind === "path" ? trigger.query : null, - }); // Mounts older settled threads matching the mention, which shells may leave out. useThreadSearch( trigger?.kind === "path" && environmentId ? [environmentId] : [], diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index d7707029ba703..0a26f437abe5b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -932,7 +932,7 @@ it("continues an active path search through literal paste and keeps Escape dismi isPathQueryActive={() => state.trigger?.kind === "path"} onChange={(value, _cursor, expandedCursor) => { setPrompt(value); - state.setTrigger(state.detectTrigger(value, expandedCursor)); + state.setTrigger(state.detectTrigger(value, expandedCursor, null)); }} onPaste={() => {}} onCommandKeyDown={(key) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5b14e91980ffc..9046f83c26af2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1129,6 +1129,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelMentions: { instanceEntries: providerInstanceEntries, modelOptionsByInstance }, }); + const composerPathResults = isComposerMenuLoading + ? null + : composerMenuItems.flatMap((item) => (item.type === "path" ? [item.path] : [])); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` @@ -1567,7 +1570,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectActiveComposerTrigger(nextPrompt, expandedCursor, composerPathResults), undefined, hasSelection, ); @@ -1692,7 +1697,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectActiveComposerTrigger(nextPrompt, expandedCursor, composerPathResults), undefined, hasSelection, ); @@ -1874,7 +1881,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) trigger: isLiteralPendingAnswer ? null : resolveComposerTrigger( - detectActiveComposerTrigger(snapshot.value, snapshot.expandedCursor), + detectActiveComposerTrigger( + snapshot.value, + snapshot.expandedCursor, + composerPathResults, + ), composerEditorRef.current?.hasSelection() === true, ), }; diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 0207c4bf24a58..74c47f9e61947 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.ts +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -14,7 +14,7 @@ export function useComposerTriggerState(initialText: string, initialEnabled = tr }); const dismissedTriggerRef = useRef(null); - const detectTrigger = (text: string, cursor: number) => { + const detectTrigger = (text: string, cursor: number, resultPaths: readonly string[] | null) => { const previous = previousSearchRef.current; const continued = continueComposerPathTrigger( text, @@ -22,6 +22,7 @@ export function useComposerTriggerState(initialText: string, initialEnabled = tr previous.trigger, previous.text, previous.queryEnd, + resultPaths, ); const candidate = detectComposerTrigger(text, cursor) ?? continued?.trigger ?? null; const queryEnd = diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index c3a5eb956c8ce..4ed6c7bf1835f 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -119,24 +119,36 @@ describe("active path query continuation", () => { const cursor = "Inspect @Foreign".length; const text = "Inspect @Foreign Subsidiaries then summarize"; const end = "Inspect @Foreign Subsidiaries".length; + const paths = ["docs/Foreign Subsidiaries/Script Notes.md"]; const result = continueComposerPathTrigger( text, end, detectComposerTrigger(before, cursor), before, cursor, + paths, ); expect(result).toEqual({ trigger: { kind: "path", query: "Foreign Subsidiaries", rangeStart: 8, rangeEnd: end }, queryEnd: end, }); - const back = continueComposerPathTrigger(text, cursor, result!.trigger, text, result!.queryEnd); + const back = continueComposerPathTrigger( + text, + cursor, + result!.trigger, + text, + result!.queryEnd, + paths, + ); expect(back?.trigger.query).toBe("Foreign"); expect( - continueComposerPathTrigger(text, end, back!.trigger, text, back!.queryEnd)?.trigger.query, + continueComposerPathTrigger(text, end, back!.trigger, text, back!.queryEnd, paths)?.trigger + .query, ).toBe("Foreign Subsidiaries"); - expect(continueComposerPathTrigger(text, text.length, result!.trigger, text, end)).toBeNull(); - expect(continueComposerPathTrigger(text, end, null, before, cursor)).toBeNull(); + expect( + continueComposerPathTrigger(text, text.length, result!.trigger, text, end, paths), + ).toBeNull(); + expect(continueComposerPathTrigger(text, end, null, before, cursor, paths)).toBeNull(); }); it.each(["\n", "\r", "\t", "\uFFFC"])("ends at the %j boundary", (boundary) => { @@ -149,7 +161,25 @@ describe("active path query continuation", () => { detectComposerTrigger(before, before.length), before, before.length, + null, ), ).toBeNull(); }); + + it("closes a spaced query that no settled result contains, as prose", () => { + const before = "Fix @ChatComposer"; + const text = "Fix @ChatComposer so"; + const continueWith = (paths: readonly string[] | null) => + continueComposerPathTrigger( + text, + text.length, + detectComposerTrigger(before, before.length), + before, + before.length, + paths, + )?.trigger.query; + expect(continueWith(["apps/web/src/components/chat/ChatComposer.tsx"])).toBeUndefined(); + expect(continueWith(["docs/ChatComposer so far.md"])).toBe("ChatComposer so"); + expect(continueWith(null)).toBe("ChatComposer so"); + }); }); diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index c1aeacb3932f2..fde400984debb 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -45,13 +45,19 @@ function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r"; } -/** Continue an already open @ search across spaces without finding old mentions in prose. */ +/** + * Continue an already open @ search across spaces without finding old mentions in prose. + * A spaced query stays open only while a settled result path contains it literally, since + * the fuzzy path search would otherwise turn a typed sentence into a mention on Enter. + * `resultPaths` is the latest settled results, or null while they are still loading. + */ export function continueComposerPathTrigger( text: string, cursorInput: number, previous: ComposerTrigger | null, previousText: string, previousQueryEnd: number, + resultPaths: readonly string[] | null, ): { trigger: ComposerTrigger & { kind: "path" }; queryEnd: number } | null { if (previous?.kind !== "path") return null; const cursor = clampCursor(text, cursorInput); @@ -69,6 +75,13 @@ export function continueComposerPathTrigger( return null; const query = text.slice(start + 1, cursor); if (/[\r\n\t\uFFFC]/u.test(query)) return null; + const needle = query.toLowerCase(); + if ( + query.includes(" ") && + resultPaths !== null && + !resultPaths.some((path) => path.toLowerCase().includes(needle)) + ) + return null; return { trigger: { kind: "path", query, rangeStart: start, rangeEnd: cursor }, queryEnd, From 70bc14c47f6c35dbe3e4e86c589d3f574c58b731 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 17:26:53 +0000 Subject: [PATCH 5/8] fix(composer): keep spaced searches matching thread titles or capped results open A spaced @ query now counts thread, machine and model labels as matches, and capped path results never rule a query out. On web, the menu closes once settled results rule out a spaced query, so Enter sends the prose. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../threads/use-composer-command-menu.test.ts | 33 +++++++++++++++++-- .../threads/use-composer-command-menu.ts | 7 +++- apps/mobile/src/state/queries.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 21 +++++++++--- .../components/chat/useComposerMenuItems.ts | 7 +++- packages/shared/src/composerTrigger.ts | 27 ++++++++------- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 3242094075e1b..2d5e9035e7748 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,6 +1,8 @@ import { act, createElement, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; +import { useComposerPathSearch } from "../../state/queries"; import { useComposerDraftSelection } from "../../state/use-composer-drafts"; +import { makeThreadShellFixture } from "../../test-fixtures"; import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; @@ -9,7 +11,7 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ useThreadSearch: vi.fn(), - useComposerPathSearch: () => ({ entries: [], isPending: false }), + useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); vi.mock("../../state/use-composer-drafts", () => ({ @@ -181,7 +183,11 @@ describe("mobile model mentions", () => { }); }); -async function mountMenu(draftMessage: string, selection: { start: number; end: number }) { +async function mountMenu( + draftMessage: string, + selection: { start: number; end: number }, + threadShells?: Parameters[0]["threadShells"], +) { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const root = createRoot(document.createElement("div")); let menu: ReturnType | undefined; @@ -195,6 +201,7 @@ async function mountMenu(draftMessage: string, selection: { start: number; end: hasThread: false, hasCompactableConversation: false, onChangeDraftMessage: () => {}, + ...(threadShells ? { threadShells } : {}), }); useLayoutEffect(() => { menu = value; @@ -205,6 +212,7 @@ async function mountMenu(draftMessage: string, selection: { start: number; end: await act(() => root.unmount()); vi.unstubAllGlobals(); vi.mocked(useComposerDraftSelection).mockReset(); + vi.mocked(useComposerPathSearch).mockReset(); }; const renderDraft = async ( text: string, @@ -242,3 +250,24 @@ it("uses the restored caret when switching draft owners", async () => { const menu = await state.renderDraft("@foo suffix @last", { start: 4, end: 4 }, "draft-b"); expect(menu.trigger).toMatchObject({ query: "foo", rangeStart: 0, rangeEnd: 4 }); }); + +it("keeps a spaced search open while a thread title or capped results may match it", async () => { + const typeQuery = async (state: Awaited>, query: string) => { + for (let end = 2; end <= query.length; end += 1) { + await state.renderDraft(query.slice(0, end), { start: end, end }); + } + return state.menu.trigger?.query; + }; + await using threads = await mountMenu("@", { start: 1, end: 1 }, [ + makeThreadShellFixture({ title: "Quarterly Planning" }), + ]); + expect(await typeQuery(threads, "@Quarterly Pl")).toBe("Quarterly Pl"); + + vi.mocked(useComposerPathSearch).mockReturnValue({ + entries: [{ path: "Foreign Alpha 000.md" }], + isPending: false, + truncated: true, + } as unknown as ReturnType); + await using capped = await mountMenu("@", { start: 1, end: 1 }); + expect(await typeQuery(capped, "@Foreign Ze")).toBe("Foreign Ze"); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1b98f3429bdba..459a4b5f011a0 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -375,7 +375,12 @@ export function useComposerCommandMenu({ previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null, previousSearch.draftMessage, previousSearch.queryEnd, - pathSearch.isPending ? null : pathSearch.entries.map((entry) => entry.path), + pathSearch.isPending || pathSearch.truncated + ? null + : [ + ...pathSearch.entries.map((entry) => entry.path), + ...threadShells.map((shell) => shell.title), + ], ); trigger = enabled && selection.start === selection.end diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 837d77ce851cf..8358264a0c229 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -438,6 +438,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { isPending: normalizedTarget.query !== debouncedTarget.query || (hasTarget && (fileAccess.isPending || result.isPending)), + truncated: result.data?.truncated ?? false, refresh: result.refresh, }; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9046f83c26af2..34d02d0e31e8a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -51,7 +51,10 @@ import * as Schema from "effect/Schema"; import { formatComposerModelMention } from "@t3tools/client-runtime/composer-model-mentions"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { + isSpacedComposerQueryUnmatched, + serializeComposerFileLink, +} from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; @@ -1106,6 +1109,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { items: composerMenuItems, isLoading: isComposerMenuLoading, + pathResultsTruncated: composerPathResultsTruncated, emptyState: composerMenuEmptyState, } = useComposerMenuItems({ composerTrigger, @@ -1129,10 +1133,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelMentions: { instanceEntries: providerInstanceEntries, modelOptionsByInstance }, }); - const composerPathResults = isComposerMenuLoading - ? null - : composerMenuItems.flatMap((item) => (item.type === "path" ? [item.path] : [])); - const composerMenuOpen = Boolean(composerTrigger); + const composerPathResults = + isComposerMenuLoading || composerPathResultsTruncated + ? null + : composerMenuItems.map((item) => (item.type === "path" ? item.path : item.label)); + // Settled results can rule out a spaced query after its last edit; Enter then sends. + const composerMenuOpen = + composerTrigger !== null && + !( + composerTrigger.kind === "path" && + isSpacedComposerQueryUnmatched(composerTrigger.query, composerPathResults) + ); const composerMenuSearchKey = composerTrigger ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 7620bf8611020..6dc738e0efc06 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -417,5 +417,10 @@ export function useComposerMenuItems({ : "No matching command."; })(); - return { items, isLoading, emptyState }; + return { + items, + isLoading, + pathResultsTruncated: isPathTrigger && workspaceEntries.truncated, + emptyState, + }; } diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index fde400984debb..b3d7a3bad1b63 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -46,18 +46,27 @@ function isWhitespace(char: string): boolean { } /** - * Continue an already open @ search across spaces without finding old mentions in prose. - * A spaced query stays open only while a settled result path contains it literally, since - * the fuzzy path search would otherwise turn a typed sentence into a mention on Enter. - * `resultPaths` is the latest settled results, or null while they are still loading. + * A spaced @ query that no result contains literally is prose: the fuzzy search would + * otherwise turn a typed sentence into a mention on Enter. `resultTexts` holds the settled + * results' paths and labels, or null when they cannot rule a query out (loading or capped). */ +export function isSpacedComposerQueryUnmatched( + query: string, + resultTexts: readonly string[] | null, +): boolean { + if (!query.includes(" ") || resultTexts === null) return false; + const needle = query.toLowerCase(); + return !resultTexts.some((text) => text.toLowerCase().includes(needle)); +} + +/** Continue an already open @ search across spaces without finding old mentions in prose. */ export function continueComposerPathTrigger( text: string, cursorInput: number, previous: ComposerTrigger | null, previousText: string, previousQueryEnd: number, - resultPaths: readonly string[] | null, + resultTexts: readonly string[] | null, ): { trigger: ComposerTrigger & { kind: "path" }; queryEnd: number } | null { if (previous?.kind !== "path") return null; const cursor = clampCursor(text, cursorInput); @@ -75,13 +84,7 @@ export function continueComposerPathTrigger( return null; const query = text.slice(start + 1, cursor); if (/[\r\n\t\uFFFC]/u.test(query)) return null; - const needle = query.toLowerCase(); - if ( - query.includes(" ") && - resultPaths !== null && - !resultPaths.some((path) => path.toLowerCase().includes(needle)) - ) - return null; + if (isSpacedComposerQueryUnmatched(query, resultTexts)) return null; return { trigger: { kind: "path", query, rangeStart: start, rangeEnd: cursor }, queryEnd, From 9797933b76764c780dc095812840a0523ff14c57 Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Fri, 13 Nov 2026 13:29:19 +0400 Subject: [PATCH 6/8] fix(composer): validate spaced mentions before accepting search results --- .../threads/use-composer-command-menu.test.ts | 83 +++++++++++++++++-- .../threads/use-composer-command-menu.ts | 47 +++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 5 ++ .../chat/composerCommandKey.test.ts | 81 ++++++++++++++++++ .../src/components/chat/composerCommandKey.ts | 13 ++- .../components/chat/useComposerMenuItems.ts | 8 +- scripts/scenarios/mention-prose-send.e2e.ts | 22 +++++ 7 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/chat/composerCommandKey.test.ts create mode 100644 scripts/scenarios/mention-prose-send.e2e.ts diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 2d5e9035e7748..0af383b29f52f 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,16 +1,21 @@ import { act, createElement, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; -import { useComposerPathSearch } from "../../state/queries"; +import { useComposerPathSearch, useThreadSearch } from "../../state/queries"; import { useComposerDraftSelection } from "../../state/use-composer-drafts"; import { makeThreadShellFixture } from "../../test-fixtures"; import { describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ - useThreadSearch: vi.fn(), + useThreadSearch: vi.fn(() => ({ matches: [], query: "", isPending: false })), useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); @@ -150,6 +155,16 @@ describe("mobile model mentions", () => { }; }; + it("continues a spaced model label without a matching filesystem path", async () => { + await using state = await mountMenu("@GPT", { start: 4, end: 4 }, undefined, { + modelOptions: [option("traex:gpt-6.1", { label: "GPT Six" })], + modelProviders: [provider("traex")], + }); + await state.renderDraft("@GPT ", { start: 5, end: 5 }); + expect(state.menu.trigger?.query).toBe("GPT "); + expect(state.menu.items.some((item) => item.type === "model")).toBe(true); + }); + it("offers current, visible models labelled with their provider", () => { const items = buildComposerModelItems( [ @@ -187,6 +202,7 @@ async function mountMenu( draftMessage: string, selection: { start: number; end: number }, threadShells?: Parameters[0]["threadShells"], + options: Partial[0]> = {}, ) { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const root = createRoot(document.createElement("div")); @@ -202,6 +218,7 @@ async function mountMenu( hasCompactableConversation: false, onChangeDraftMessage: () => {}, ...(threadShells ? { threadShells } : {}), + ...options, }); useLayoutEffect(() => { menu = value; @@ -213,6 +230,7 @@ async function mountMenu( vi.unstubAllGlobals(); vi.mocked(useComposerDraftSelection).mockReset(); vi.mocked(useComposerPathSearch).mockReset(); + vi.mocked(useThreadSearch).mockReset(); }; const renderDraft = async ( text: string, @@ -258,9 +276,12 @@ it("keeps a spaced search open while a thread title or capped results may match } return state.menu.trigger?.query; }; - await using threads = await mountMenu("@", { start: 1, end: 1 }, [ - makeThreadShellFixture({ title: "Quarterly Planning" }), - ]); + await using threads = await mountMenu( + "@", + { start: 1, end: 1 }, + [makeThreadShellFixture({ title: "Quarterly Planning" })], + { environmentId: EnvironmentId.make("environment-test") }, + ); expect(await typeQuery(threads, "@Quarterly Pl")).toBe("Quarterly Pl"); vi.mocked(useComposerPathSearch).mockReturnValue({ @@ -271,3 +292,53 @@ it("keeps a spaced search open while a thread title or capped results may match await using capped = await mountMenu("@", { start: 1, end: 1 }); expect(await typeQuery(capped, "@Foreign Ze")).toBe("Foreign Ze"); }); + +it("rejects pending fuzzy selections and revalidates when the same query settles", async () => { + const onChangeDraftMessage = vi.fn(); + const entry = { path: "ChatComposer.tsx", kind: "file" as const }; + vi.mocked(useComposerPathSearch).mockReturnValue({ + entries: [entry], + error: null, + refresh: vi.fn(), + isPending: true, + truncated: false, + }); + await using state = await mountMenu("Fix @ChatComposer", { start: 17, end: 17 }, undefined, { + onChangeDraftMessage, + }); + const text = "Fix @ChatComposer so"; + await state.renderDraft(text, { start: text.length, end: text.length }); + const item = state.menu.items.find((item) => item.type === "path")!; + await act(() => state.menu.onSelect(item)); + expect.soft(onChangeDraftMessage).not.toHaveBeenCalled(); + vi.mocked(useComposerPathSearch).mockReturnValue({ + entries: [entry], + error: null, + refresh: vi.fn(), + isPending: false, + truncated: false, + }); + await state.renderDraft(text, { start: text.length, end: text.length }); + expect(state.menu.trigger).toBeNull(); + expect(state.menu.items).toEqual([]); +}); + +it("continues a spaced machine name when filesystem results do not contain it", async () => { + const environmentId = EnvironmentId.make("local"); + await using state = await mountMenu("@Build", { start: 6, end: 6 }, undefined, { + environmentId, + environments: [ + { environmentId: EnvironmentId.make("remote"), label: "Build Server", machine: "server" }, + ], + }); + await state.renderDraft("@Build ", { start: 7, end: 7 }); + expect(state.menu.trigger?.query).toBe("Build "); + expect(state.menu.items.some((item) => item.type === "environment")).toBe(true); +}); + +it("keeps continuation while only the remote thread search is pending", async () => { + vi.mocked(useThreadSearch).mockReturnValue({ matches: [], query: "Design", isPending: true }); + await using state = await mountMenu("@Design", { start: 7, end: 7 }); + await state.renderDraft("@Design Review", { start: 14, end: 14 }); + expect(state.menu.trigger?.query).toBe("Design Review"); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 459a4b5f011a0..430ce51900f12 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -31,6 +31,7 @@ import { import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { continueComposerPathTrigger, + isSpacedComposerQueryUnmatched, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -350,6 +351,11 @@ export function useComposerCommandMenu({ cwd: previousSearch.trigger?.kind === "path" ? projectCwd : null, query: previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : null, }); + const threadSearch = useThreadSearch( + previousSearch.trigger?.kind === "path" && environmentId ? [environmentId] : [], + previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : "", + { titlesOnly: true }, + ); let trigger = previousSearch.trigger; if ( previousSearch.ownerKey !== ownerKey || @@ -375,11 +381,21 @@ export function useComposerCommandMenu({ previousSearch.ownerKey === ownerKey ? previousSearch.trigger : null, previousSearch.draftMessage, previousSearch.queryEnd, - pathSearch.isPending || pathSearch.truncated + pathSearch.isPending || pathSearch.truncated || threadSearch.isPending ? null : [ ...pathSearch.entries.map((entry) => entry.path), ...threadShells.map((shell) => shell.title), + ...environments + .filter((environment) => environment.environmentId !== environmentId) + .map((environment) => environment.label), + ...(modelOptions && modelProviders + ? buildComposerModelItems( + modelOptions, + draftMessage.slice((previousSearch.trigger?.rangeStart ?? 0) + 1, cursor), + modelProviders, + ).map((item) => item.label) + : []), ], ); trigger = @@ -402,12 +418,6 @@ export function useComposerCommandMenu({ trigger, }); } - // Mounts older settled threads matching the mention, which shells may leave out. - useThreadSearch( - trigger?.kind === "path" && environmentId ? [environmentId] : [], - trigger?.kind === "path" ? trigger.query : "", - { titlesOnly: true }, - ); const pullRequestSearch = useComposerPullRequestSearch({ environmentId, projectId: pullRequestProjectId, @@ -598,8 +608,25 @@ export function useComposerCommandMenu({ return []; })(); + if ( + trigger?.kind === "path" && + !pathSearch.isPending && + !pathSearch.truncated && + !threadSearch.isPending && + isSpacedComposerQueryUnmatched( + trigger.query, + items.map((item) => (item.type === "path" ? item.path : item.label)), + ) + ) + trigger = null; + const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; + if ( + trigger.kind === "path" && + isSpacedComposerQueryUnmatched(trigger.query, [item.type === "path" ? item.path : item.label]) + ) + return; if (item.type === "environment") { if (!ownerKey || trigger.kind !== "path") return; const record = environmentComposerContext(item.environmentId, item.label); @@ -717,10 +744,12 @@ export function useComposerCommandMenu({ selection, onSelectionChange, trigger, - items, + items: trigger ? items : [], skills, isLoading: - trigger?.kind === "pull-request" ? pullRequestSearch.isPending : pathSearch.isPending, + trigger?.kind === "pull-request" + ? pullRequestSearch.isPending + : pathSearch.isPending || threadSearch.isPending, error: trigger?.kind === "pull-request" ? pullRequestProjectId === null || pullRequestRepository === null diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a35faa761abe1..050bfee845018 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1912,6 +1912,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; + if ( + trigger.kind === "path" && + isSpacedComposerQueryUnmatched(trigger.query, [item.type === "path" ? item.path : item.label]) + ) + return; if (item.type === "path") { if (!isPathSuggestionCurrent(item.searchTarget, trigger, environmentId, gitCwd)) return; const replacement = `${serializeComposerFileLink(item.path)} `; diff --git a/apps/web/src/components/chat/composerCommandKey.test.ts b/apps/web/src/components/chat/composerCommandKey.test.ts new file mode 100644 index 0000000000000..5ba31ee38358d --- /dev/null +++ b/apps/web/src/components/chat/composerCommandKey.test.ts @@ -0,0 +1,81 @@ +import { expect, it, vi } from "vite-plus/test"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { DraftId } from "../../composerDraftStore"; +import { composerCommandKeyHandler } from "./composerCommandKey"; +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +it.each(["Enter", "Tab"])("does not accept a stale fuzzy mention on %s during debounce", (key) => { + const value = "Check @README and fix it"; + const selected: ComposerCommandItem = { + id: "path:README.md", + type: "path", + path: "README.md", + label: "README.md", + description: "", + pathKind: "file", + searchTarget: { environmentId: null, cwd: null, query: "READ" }, + }; + const submitComposer = vi.fn(); + const onSelectComposerItem = vi.fn(); + const input: Parameters[0] = { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + sendShortcut: "enter", + routeKind: "draft", + phase: "ready", + canSteer: false, + followUpBehavior: "queue", + isSoftwareKeyboardOpen: false, + isLiteralPendingAnswer: false, + pendingUserInputs: [], + promptHistoryEntries: [], + promptHistoryPositionRef: { current: null }, + composerDraftTarget: DraftId.make("test"), + promptRef: { current: value }, + composerImagesRef: { current: [] }, + composerFilesRef: { current: [] }, + composerTerminalContextsRef: { current: [] }, + composerPreviewAnnotations: [], + composerReviewComments: [], + composerEditorRef: { current: null }, + composerMenuOpenRef: { current: true }, + composerMenuItemsRef: { current: [selected] }, + activeComposerMenuItemRef: { current: selected }, + setComposerDraftPrompt: vi.fn(), + setComposerCursor: vi.fn(), + setComposerTrigger: vi.fn(), + setComposerHighlightedItemId: vi.fn(), + dismissComposerTrigger: vi.fn(), + nudgeComposerMenuHighlight: vi.fn(), + onSelectComposerItem, + submitComposer, + resolveActiveComposerTrigger: () => ({ + trigger: { kind: "path", query: "README and fix it", rangeStart: 6, rangeEnd: value.length }, + }), + readComposerSnapshot: () => ({ value }), + applyPromptReplacement: () => false, + promptSuggestion: null, + dismissPromptSuggestion: vi.fn(), + }; + const event = { + key, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + repeat: false, + } as KeyboardEvent; + composerCommandKeyHandler(input)(key, event); + expect(onSelectComposerItem).not.toHaveBeenCalled(); + expect(submitComposer).toHaveBeenCalledTimes(key === "Enter" ? 1 : 0); + selected.path = "docs/README and fix it.md"; + composerCommandKeyHandler(input)(key, event); + expect(onSelectComposerItem).toHaveBeenCalledExactlyOnceWith(selected); + expect(submitComposer).toHaveBeenCalledTimes(key === "Enter" ? 1 : 0); + composerCommandKeyHandler({ ...input, resolveActiveComposerTrigger: () => ({ trigger: null }) })( + key, + event, + ); + expect(onSelectComposerItem).toHaveBeenCalledTimes(1); + expect(submitComposer).toHaveBeenCalledTimes(key === "Enter" ? 2 : 0); +}); diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 40650fe6c8952..fb7dc5c8f8444 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -1,3 +1,4 @@ +import { isSpacedComposerQueryUnmatched } from "@t3tools/shared/composerTrigger"; import type { ChatComposerProps } from "./ChatComposer"; import { type ComposerCommandItem } from "./ComposerCommandMenu"; import { @@ -237,7 +238,17 @@ export function composerCommandKeyHandler(input: { nudgeComposerMenuHighlight("ArrowUp"); return true; } - if ((key === "Enter" || key === "Tab") && selectedItem) { + if ( + (key === "Enter" || key === "Tab") && + selectedItem && + trigger !== null && + !( + trigger.kind === "path" && + isSpacedComposerQueryUnmatched(trigger.query, [ + selectedItem.type === "path" ? selectedItem.path : selectedItem.label, + ]) + ) + ) { onSelectComposerItem(selectedItem); return true; } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 6dc738e0efc06..fbe11cfccbba5 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -120,7 +120,9 @@ export function useComposerMenuItems({ const isPathTrigger = composerTriggerKind === "path"; const environmentThreadShells = useVisibleThreadShells(isPathTrigger); // Mounts older settled threads matching the mention, which shells may leave out. - useThreadSearch(isPathTrigger ? [environmentId] : [], pathTriggerQuery, { titlesOnly: true }); + const threadSearch = useThreadSearch(isPathTrigger ? [environmentId] : [], pathTriggerQuery, { + titlesOnly: true, + }); // The user's machines feed `@` machine matches; the catalog summary is cheap and stable. const environmentIdentities = useEnvironmentIdentities(); const environmentMachines = useEnvironmentMachines(); @@ -384,7 +386,9 @@ export function useComposerMenuItems({ return []; })(); const isLoading = - (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "path" && + pathTriggerQuery.length > 0 && + (workspaceEntries.isPending || threadSearch.isPending)) || ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && workspaceSkillsScanning) || (composerTriggerKind === "pull-request" && diff --git a/scripts/scenarios/mention-prose-send.e2e.ts b/scripts/scenarios/mention-prose-send.e2e.ts new file mode 100644 index 0000000000000..bc24480b3a724 --- /dev/null +++ b/scripts/scenarios/mention-prose-send.e2e.ts @@ -0,0 +1,22 @@ +import { expect } from "e2e"; + +import { test, WEB } from "./t3.ts"; + +test( + "Enter sends prose after a mention prefix even before search settles", + WEB, + async ({ t3, app, screen }) => { + await t3.project("mention-prose", { "README.md": "Fixture readme\n" }); + await t3.open(); + const composer = screen.getByRole("textbox"); + await composer.fill("Check @READ"); + await expect(screen.getByRole("option", "README.md", { exact: true })).toBeVisible(); + for (const character of "ME and fix it") + await composer.press(character === " " ? "Space" : character); + await app.screenshot("prose-before-enter"); + await composer.press("Enter"); + await expect(composer).toHaveValue(""); + await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible(); + await app.screenshot("prose-sent"); + }, +); From 1ce9c74f09502c0648cde5e014f07efc8e740d92 Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Fri, 13 Nov 2026 13:34:06 +0400 Subject: [PATCH 7/8] test(composer): exercise Enter before the debounced menu commits --- scripts/scenarios/mention-prose-send.e2e.ts | 27 +++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/scenarios/mention-prose-send.e2e.ts b/scripts/scenarios/mention-prose-send.e2e.ts index bc24480b3a724..6da10f241cdd3 100644 --- a/scripts/scenarios/mention-prose-send.e2e.ts +++ b/scripts/scenarios/mention-prose-send.e2e.ts @@ -5,16 +5,33 @@ import { test, WEB } from "./t3.ts"; test( "Enter sends prose after a mention prefix even before search settles", WEB, - async ({ t3, app, screen }) => { + async ({ t3, app, screen, browser }) => { await t3.project("mention-prose", { "README.md": "Fixture readme\n" }); await t3.open(); const composer = screen.getByRole("textbox"); await composer.fill("Check @READ"); await expect(screen.getByRole("option", "README.md", { exact: true })).toBeVisible(); - for (const character of "ME and fix it") - await composer.press(character === " " ? "Space" : character); - await app.screenshot("prose-before-enter"); - await composer.press("Enter"); + // One event loop turn keeps the keypress ahead of the debounced search and committed menu refs. + await browser.evaluate(() => { + const editor = document.querySelector< + HTMLElement & { + editor?: { commands: { insertContent: (text: string) => boolean } }; + } + >("[data-testid='composer-editor']"); + if (!editor?.editor) throw new Error("Composer editor did not mount"); + editor.editor.commands.insertContent("ME and fix it"); + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + bubbles: true, + cancelable: true, + }), + ); + return null; + }); + await app.screenshot("prose-after-enter"); await expect(composer).toHaveValue(""); await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible(); await app.screenshot("prose-sent"); From 3869c167b1914eddc3b9bcb3b017a6639cd2b30f Mon Sep 17 00:00:00 2001 From: Omar Badri Date: Fri, 13 Nov 2026 13:35:17 +0400 Subject: [PATCH 8/8] test(composer): keep browser DOM evaluation in the browser context --- scripts/scenarios/mention-prose-send.e2e.ts | 22 ++++++--------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/scripts/scenarios/mention-prose-send.e2e.ts b/scripts/scenarios/mention-prose-send.e2e.ts index 6da10f241cdd3..4e4b90720f665 100644 --- a/scripts/scenarios/mention-prose-send.e2e.ts +++ b/scripts/scenarios/mention-prose-send.e2e.ts @@ -12,25 +12,15 @@ test( await composer.fill("Check @READ"); await expect(screen.getByRole("option", "README.md", { exact: true })).toBeVisible(); // One event loop turn keeps the keypress ahead of the debounced search and committed menu refs. - await browser.evaluate(() => { - const editor = document.querySelector< - HTMLElement & { - editor?: { commands: { insertContent: (text: string) => boolean } }; - } - >("[data-testid='composer-editor']"); + await browser.evaluate(`() => { + const editor = document.querySelector("[data-testid='composer-editor']"); if (!editor?.editor) throw new Error("Composer editor did not mount"); editor.editor.commands.insertContent("ME and fix it"); - editor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - code: "Enter", - keyCode: 13, - bubbles: true, - cancelable: true, - }), - ); + editor.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true + })); return null; - }); + }`); await app.screenshot("prose-after-enter"); await expect(composer).toHaveValue(""); await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible();