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 9a2c8b74bdd8..912e8eb16caa 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 @@ -5,7 +5,7 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; -import { act, createElement } from "react"; +import { act, createElement, useLayoutEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; const refreshProviders = vi.hoisted(() => vi.fn()); @@ -321,3 +321,118 @@ describe("workspace command discovery retry", () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe("mobile mention search", () => { + let root: Root; + let menu: ReturnType; + const onChangeDraftMessage = vi.fn(); + + function Probe({ draftMessage, ownerKey }: { draftMessage: string; ownerKey: string }) { + const state = useComposerCommandMenu({ + draftMessage, + ownerKey, + environmentId: null, + projectCwd: null, + selectedProviderStatus: null, + hasThread: false, + hasCompactableConversation: false, + onChangeDraftMessage, + }); + useLayoutEffect(() => { + menu = state; + }); + return null; + } + + async function renderDraft(draftMessage: string, ownerKey = "draft-1") { + await act(() => root.render(createElement(Probe, { draftMessage, ownerKey }))); + } + + beforeEach(() => { + onChangeDraftMessage.mockReset(); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + }); + + afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + }); + + it("keeps the replacement range while native selection trails text changes", async () => { + const prefix = "Inspect "; + const suffix = " then summarize"; + await renderDraft(`${prefix}@Release${suffix}`); + const cursor = `${prefix}@Release`.length; + await act(() => menu.onSelectionChange({ start: cursor, end: cursor })); + await renderDraft(`${prefix}@Release ${suffix}`); + expect(menu.trigger?.query).toBe("Release "); + await renderDraft(`${prefix}@Release Notes.md${suffix}`); + expect.soft(menu.trigger?.query).toBe("Release Notes.md"); + const end = `${prefix}@Release Notes.md`.length; + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger).toMatchObject({ query: "Release Notes.md", rangeEnd: end }); + await act(() => menu.onSelectionChange({ start: end - 1, end: end - 1 })); + expect(menu.trigger?.query).toBe("Release Notes.m"); + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger?.query).toBe("Release Notes.md"); + await act(() => menu.onSelectionChange({ start: cursor, end: cursor })); + expect(menu.trigger?.query).toBe("Release"); + const editCursor = `${prefix}@Release No`.length; + await act(() => menu.onSelectionChange({ start: editCursor, end: editCursor })); + await renderDraft(`${prefix}@Release Nootes.md${suffix}`); + await act(() => menu.onSelectionChange({ start: end + 1, end: end + 1 })); + expect(menu.trigger?.query).toBe("Release Nootes.md"); + await act(() => menu.onSelectionChange({ start: editCursor + 1, end: editCursor + 1 })); + await renderDraft(`${prefix}@Release Notes.md${suffix}`); + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger).toMatchObject({ query: "Release Notes.md", rangeEnd: end }); + await act(() => + menu.onSelect({ + id: "path:docs/Release Notes.md", + type: "path", + path: "docs/Release Notes.md", + kind: "file", + label: "Release Notes.md", + description: "docs", + }), + ); + const expected = "Inspect [Release Notes.md](docs/Release%20Notes.md) then summarize"; + expect(onChangeDraftMessage).toHaveBeenCalledExactlyOnceWith(expected); + await renderDraft(expected); + expect(menu.trigger).toBeNull(); + }); + + it("drops continuation on text selection and when switching draft owners", async () => { + await renderDraft("@Release"); + await renderDraft("@Release Notes"); + const end = "@Release Notes".length; + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger?.query).toBe("Release Notes"); + await act(() => menu.onSelectionChange({ start: 0, end })); + expect(menu.trigger).toBeNull(); + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger).toBeNull(); + + await renderDraft("@Release"); + await act(() => menu.onSelectionChange({ start: 8, end: 8 })); + await renderDraft("@Release Notes"); + await act(() => menu.onSelectionChange({ start: end, end })); + expect(menu.trigger?.query).toBe("Release Notes"); + await renderDraft("@Release Notes", "draft-2"); + expect(menu.trigger).toBeNull(); + await renderDraft("@Release Notes draft", "draft-2"); + expect(menu.trigger).toBeNull(); + }); +}); 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 67e560b8eaa3..e75c6bf1a382 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -25,6 +25,7 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + continueComposerPathTrigger, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -332,12 +333,47 @@ export function useComposerCommandMenu({ workspaceRefreshRetry, ]); - const trigger = (() => { - if (!enabled || selection.start !== selection.end) { - return null; - } - return detectComposerTrigger(draftMessage, selection.end); - })(); + const [previousSearch, setPreviousSearch] = useState(() => ({ + ownerKey, + draftMessage, + selection, + 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.selection !== selection || + 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.selection === selection + ? 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, 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 8af19e152c76..282b43d28411 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -22,7 +22,11 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -async function mountEditor(value: string, richTextEnabled = true) { +async function mountEditor( + value: string, + richTextEnabled = true, + isPathQueryActive?: () => boolean, +) { await act(async () => root.render( { + let active = true; + const editor = await mountEditor("@Foreign", true, () => active); + async function paste(text: string) { + await act(async () => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }), + ); + }); + } + await act(async () => typeText(editor, 9, 9, " ")); + await paste("__Subsidiaries__.md"); + expect(editor.view.dom.querySelector("strong")).toBeNull(); + expect(serializeEditorDoc(editor.state.doc).value).toBe("@Foreign __Subsidiaries__.md"); + await paste(" @README.md"); + expect(serializeEditorDoc(editor.state.doc).value).toBe( + "@Foreign __Subsidiaries__.md @README.md", + ); + active = false; + await paste(" __ordinary__"); + expect(editor.view.dom.querySelector("strong")?.textContent).toBe("ordinary"); + await act(async () => { + editor.commands.setContent("

Drop here

"); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + typeText(editor, editor.state.selection.from, editor.state.selection.to, " "); + const caret = editor.view.coordsAtPos(editor.state.selection.from); + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "__dropped__"); + editor.view.dom.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + clientX: caret.left, + clientY: (caret.top + caret.bottom) / 2, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(editor.view.dom.querySelector("strong")).not.toBeNull(); + expect(serializeEditorDoc(editor.state.doc).value).toBe("Drop here **dropped**"); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 14562f73cd47..ad63cf933a22 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -125,6 +125,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSelectionRange: () => { start: number; end: number }; + hasSelection: () => boolean; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1351,11 +1352,23 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ); return true; } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, pastedText, isPathQueryActiveRef.current?.()); + const pastedSuffix = pastedText.slice(literalLength); const importFragment = importFragmentRef.current; 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 lastToken = tokens.at(-1); if ( @@ -1372,11 +1385,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { 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, isPathQueryActiveRef.current?.()); + text = pastedText.slice(0, literalLength) + text; const editorInstance = editorHolder.current; if (editorInstance) { const $paste = view.state.selection.$from; @@ -1633,6 +1642,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { 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 ebc30a4a1e70..7d2ec3e7c24f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2393,13 +2393,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`; @@ -2849,7 +2849,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 setPromptFromTraitsStable = useStableCallback(setPromptFromTraits); @@ -3340,7 +3340,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; @@ -3365,7 +3365,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, @@ -3386,12 +3386,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); setIsComposerScrollCollapsed(false); } @@ -3506,7 +3504,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); setIsComposerScrollCollapsed(false); }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); + const onComposerVisibleSelectionChange = () => { + expandComposerForEditorChange(); + clearComposerPathTriggerForSelection(); + }; + const onComposerVisibleSelectionChangeStable = useStableCallback( + onComposerVisibleSelectionChange, + ); /** * Payloads for chips the prompt no longer references. History undo restores the @@ -3531,6 +3535,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) contextIds: string[], ) => { expandComposerForEditorChange(); + const hasSelection = + nextPrompt === promptRef.current && composerEditorRef.current?.hasSelection() === true; // Typing is when the menu is about to need skills, so it re-checks a // snapshot that has aged past its TTL. refreshWorkspaceSnapshotOnTyping(); @@ -3538,7 +3544,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 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, @@ -3639,7 +3647,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); @@ -3711,6 +3721,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(() => { @@ -3754,7 +3765,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, + ), }; }; @@ -4158,7 +4172,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode(); return true; } - const { trigger } = resolveActiveComposerTrigger(); + const { snapshot, trigger } = resolveActiveComposerTrigger(); const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { @@ -4205,13 +4219,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey)) ) { - const selection = composerEditorRef.current?.readSelectionRange(); - const snapshot = readComposerSnapshot(); - if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { + if ( + composerEditorRef.current?.hasSelection() === false && + snapshot.value === promptRef.current + ) { + const cursor = snapshot.expandedCursor; const edit = key === "Enter" - ? listContinuationForEnter(snapshot.value, selection.start) - : listIndentForTab(snapshot.value, selection.start, selection.end); + ? listContinuationForEnter(snapshot.value, cursor) + : listIndentForTab(snapshot.value, cursor, cursor); if ( edit && applyPromptReplacement( @@ -4219,7 +4235,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) edit.end, edit.replacement, key === "Tab" - ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } + ? { expandedCursorAfterReplace: cursor + edit.replacement.length } : undefined, ) ) { @@ -6185,6 +6201,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandCollapsedComposerCursor(promptForState, cursor), ) : null, + promptForState, ); }, addTerminalContext: (selection: TerminalContextSelection) => { @@ -6214,7 +6231,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); }); @@ -7097,7 +7117,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "flex items-center overflow-hidden whitespace-nowrap leading-8", )} onChange={onPromptChangeStable} - onVisibleSelectionChange={expandComposerForEditorChangeStable} + onVisibleSelectionChange={onComposerVisibleSelectionChangeStable} onCommandKeyDown={onComposerCommandKeyStable} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} diff --git a/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx index 03225f5249be..c33ff242f9a8 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerTriggerState.browser.test.tsx @@ -1,6 +1,7 @@ import { act, StrictMode, useLayoutEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { replaceTextRange, serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { detectComposerTrigger } from "../../composer-logic"; import { useComposerTriggerState } from "./useComposerTriggerState"; @@ -10,10 +11,8 @@ const initialPrompt = "pnpm install -g @openai"; let root: Root; let composer: ReturnType; -function ComposerProbe() { - const state = useComposerTriggerState(() => - detectComposerTrigger(initialPrompt, initialPrompt.length), - ); +function ComposerProbe({ text = initialPrompt, enabled = true } = {}) { + const state = useComposerTriggerState(text, enabled); useLayoutEffect(() => { composer = state; }); @@ -21,7 +20,7 @@ function ComposerProbe() { } async function updatePrompt(text: string, cursor = text.length) { - await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); + await act(() => composer.setTrigger(composer.detectTrigger(text, cursor))); } beforeEach(async () => { @@ -42,13 +41,129 @@ afterEach(async () => { }); describe("composer suggestion dismissal", () => { + it.each(["@Foreign", "/plan", "$review", "#123"])( + "keeps suggestions closed when initialized with the literal answer %s", + async (text) => { + await act(() => + root.render(), + ); + expect(composer.trigger).toBeNull(); + }, + ); + + it("clears a multi-word search when resetting to a literal answer", async () => { + await updatePrompt("@Foreign"); + await updatePrompt("@Foreign Subsidiaries"); + expect(composer.trigger?.query).toBe("Foreign Subsidiaries"); + await act(() => composer.resetTrigger(null, "@Literal answer")); + expect(composer.trigger).toBeNull(); + // Returning to a regular composer must not continue the literal answer as an old search. + await updatePrompt("@Literal answer more"); + expect(composer.trigger).toBeNull(); + }); + + it("continues through spaces and replaces only the mention before closing", async () => { + const prefix = "Inspect "; + const suffix = " then summarize"; + for (const query of ["Release", "Release ", "Release Notes.md"]) { + const beforeCaret = `${prefix}@${query}`; + await updatePrompt(beforeCaret + suffix, beforeCaret.length); + expect(composer.trigger).toEqual({ + kind: "path", + query, + rangeStart: prefix.length, + rangeEnd: beforeCaret.length, + }); + } + let text = `${prefix}@Release Notes.md${suffix}`; + const end = text.length - suffix.length; + await updatePrompt(text, end - 1); + expect(composer.trigger).toMatchObject({ query: "Release Notes.m", rangeEnd: end - 1 }); + await updatePrompt(text, end); + expect(composer.trigger?.query).toBe("Release Notes.md"); + await updatePrompt(text, `${prefix}@Release`.length); + expect(composer.trigger?.query).toBe("Release"); + await updatePrompt(text, `${prefix}@Release No`.length); + text = `${prefix}@Release Nootes.md${suffix}`; + await updatePrompt(text, `${prefix}@Release Noo`.length); + await updatePrompt(text, text.length - suffix.length); + expect(composer.trigger?.query).toBe("Release Nootes.md"); + text = `${prefix}@Release Notes.md${suffix}`; + await updatePrompt(text, `${prefix}@Release No`.length); + await updatePrompt(text, end); + expect(composer.trigger?.query).toBe("Release Notes.md"); + const trigger = composer.resolveTrigger( + composer.detectTrigger(text, text.length - suffix.length), + ); + expect(trigger).not.toBeNull(); + const result = replaceTextRange( + text, + trigger!.rangeStart, + trigger!.rangeEnd, + serializeComposerFileLink("docs/Release Notes.md"), + ); + expect(result.text).toBe("Inspect [Release Notes.md](docs/Release%20Notes.md) then summarize"); + await updatePrompt(result.text, result.cursor); + expect(composer.trigger).toBeNull(); + }); + + it("rejects selection after the caret leaves the query for existing prose", async () => { + const suffix = " then summarize"; + await updatePrompt("Inspect @Release" + suffix, "Inspect @Release".length); + const text = "Inspect @Release Notes" + suffix; + await updatePrompt(text, "Inspect @Release Notes".length); + expect(composer.trigger?.query).toBe("Release Notes"); + expect(composer.resolveTrigger(composer.detectTrigger(text, text.length))).toBeNull(); + await updatePrompt(text); + expect(composer.trigger).toBeNull(); + await updatePrompt(text + " more"); + expect(composer.trigger).toBeNull(); + }); + + it.each(["\n", "\t"])("closes an active search at a %j boundary", async (boundary) => { + await updatePrompt("@Release"); + await updatePrompt("@Release Notes"); + expect(composer.trigger?.query).toBe("Release Notes"); + await updatePrompt(`@Release Notes${boundary}`); + expect(composer.trigger).toBeNull(); + await updatePrompt(`@Release Notes${boundary}more`); + expect(composer.trigger).toBeNull(); + }); + + it("keeps Escape dismissal through spaces until a new mention starts", async () => { + await updatePrompt("@Release"); + const trigger = composer.trigger; + expect(trigger?.query).toBe("Release"); + await act(() => { + composer.dismissTrigger(trigger); + expect(composer.resolveTrigger(trigger)).toBeNull(); + }); + await act(() => { + composer.clearPathTriggerForSelection(); + const selected = composer.detectTrigger("@Release", "@Releas".length); + composer.setTrigger(selected, undefined, true); + expect(composer.resolveTrigger(selected, true)).toBeNull(); + }); + await updatePrompt("@Release"); + expect(composer.trigger).toBeNull(); + await updatePrompt("@Release @Release"); + await updatePrompt("@Release @Release Notes"); + expect(composer.trigger?.query).toBe("Release Notes"); + await act(() => composer.dismissTrigger(composer.trigger)); + await updatePrompt("@Release @Release Notes "); + await updatePrompt("@Release @Release Notes draft"); + expect(composer.trigger).toBeNull(); + await updatePrompt("@Release @Release Notes draft @src"); + expect(composer.trigger?.query).toBe("src"); + }); + it("closes suggestions and rejects keyboard selection before the next render", async () => { const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); expect(composer.trigger).toEqual(candidate); await act(() => { - composer.dismissTrigger(candidate); - expect(composer.resolveTrigger(candidate)).toBeNull(); + composer.clearPathTriggerForSelection(); + expect(composer.resolveTrigger(candidate, true)).toBeNull(); }); expect(composer.trigger).toBeNull(); }); @@ -111,13 +226,23 @@ describe("composer suggestion dismissal", () => { it("clears dismissal when switching drafts or pending questions", async () => { await act(() => composer.dismissTrigger(composer.trigger)); const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); - await act(() => composer.resetTrigger(candidate)); + await act(() => composer.resetTrigger(candidate, initialPrompt)); expect(composer.trigger).toEqual(candidate); expect(composer.resolveTrigger(candidate)).toEqual(candidate); }); it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + await updatePrompt(text); + const candidate = composer.trigger; + await act(() => { + composer.clearPathTriggerForSelection(); + composer.setTrigger(composer.detectTrigger(text, text.length - 1), undefined, true); + }); + expect(composer.trigger?.kind).toBe(candidate?.kind); + expect(composer.resolveTrigger(candidate, true)).toEqual(candidate); + await act(() => composer.setTrigger(composer.detectTrigger(text, 0), undefined, true)); + expect(composer.trigger).toBeNull(); await updatePrompt(text); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(`${text}x`); diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 6adf037e1e45..0207c4bf24a5 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 80478a7aae5b..7cf7bc84e02d 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -304,9 +304,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 d60590cbbb0d..8ea36c1090c2 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -986,7 +986,7 @@ describe("pasting into a list item or quote", () => { 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)) @@ -1018,6 +1018,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}`); }, ); @@ -1028,12 +1031,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 e283ec56adb4..528e4222b68d 100644 --- a/apps/web/src/composer-undo-grouping.ts +++ b/apps/web/src/composer-undo-grouping.ts @@ -17,15 +17,16 @@ export type ComposerChangeKind = "insert" | "delete" | "other"; * ProseMirror tags these itself only on its built-in clipboard paths; the * composer's `handlePaste` and cut handler replace those paths, so each of * their dispatches must call this, or the change merges with the typing or - * deleting around it. + * deleting around it. A separate key avoids rerunning Tiptap paste rules on + * text the custom handler has already parsed. */ 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.ts b/packages/shared/src/composerTrigger.ts index f8f77ffae3ea..fd331ab282a1 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 slash word with a second slash, such as `/tmp/build.sh`, is a path, not a * command. The whole word counts, so a caret before the second slash still sees it.