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..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,10 +1,22 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +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", () => ({ - useComposerPathSearch: () => ({ entries: [], isPending: false }), + useThreadSearch: vi.fn(() => ({ matches: [], query: "", isPending: false })), + useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); vi.mock("../../state/use-composer-drafts", () => ({ @@ -26,6 +38,7 @@ import { buildComposerSlashCommandItems, resolveComposerCommandSelection, resolveComposerMenuSelection, + useComposerCommandMenu, } from "./use-composer-command-menu"; describe("resolveComposerMenuSelection", () => { @@ -142,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( [ @@ -174,3 +197,148 @@ describe("mobile model mentions", () => { expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]); }); }); + +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")); + 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: () => {}, + ...(threadShells ? { threadShells } : {}), + ...options, + }); + useLayoutEffect(() => { + menu = value; + }); + return null; + } + const dispose = async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + vi.mocked(useComposerDraftSelection).mockReset(); + vi.mocked(useComposerPathSearch).mockReset(); + vi.mocked(useThreadSearch).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 }); +}); + +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" })], + { environmentId: EnvironmentId.make("environment-test") }, + ); + 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"); +}); + +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 1e66bf4ee2c8e..430ce51900f12 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,8 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + continueComposerPathTrigger, + isSpacedComposerQueryUnmatched, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -328,23 +330,94 @@ 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 && selection.start === selection.end + ? 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: trigger?.kind === "path" ? projectCwd : null, - query: trigger?.kind === "path" ? trigger.query : null, + cwd: previousSearch.trigger?.kind === "path" ? projectCwd : null, + query: previousSearch.trigger?.kind === "path" ? previousSearch.trigger.query : null, }); - // Mounts older settled threads matching the mention, which shells may leave out. - useThreadSearch( - trigger?.kind === "path" && environmentId ? [environmentId] : [], - trigger?.kind === "path" ? trigger.query : "", + 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 || + 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 + ? selection.end + : 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, + 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 = + 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 pullRequestSearch = useComposerPullRequestSearch({ environmentId, projectId: pullRequestProjectId, @@ -535,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); @@ -654,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/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/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 2b688fee30ad2..bf8198f571732 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", diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index cd01804f8efe3..a9a343e91bbd7 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"; @@ -957,3 +959,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, null)); + }} + 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 5a036e34611ed..9cd1fdfa83c76 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -145,6 +145,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSelectionRange: () => { start: number; end: number }; + hasSelection: () => boolean; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1424,11 +1425,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" || @@ -1443,11 +1456,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; @@ -1741,6 +1750,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 eb01b8242e2df..050bfee845018 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"; @@ -977,13 +980,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`; @@ -1108,6 +1111,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { items: composerMenuItems, isLoading: isComposerMenuLoading, + pathResultsTruncated: composerPathResultsTruncated, emptyState: composerMenuEmptyState, } = useComposerMenuItems({ composerTrigger, @@ -1131,7 +1135,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelMentions: { instanceEntries: providerInstanceEntries, modelOptionsByInstance }, }); - 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; @@ -1185,7 +1199,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); @@ -1436,7 +1450,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; @@ -1461,7 +1475,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, @@ -1482,12 +1496,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); } @@ -1564,12 +1576,18 @@ 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, composerPathResults), + undefined, + hasSelection, ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -1692,7 +1710,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectActiveComposerTrigger(nextPrompt, expandedCursor, composerPathResults), + undefined, + hasSelection, ); }; const onPromptChangeStable = useStableCallback(onPromptChange); @@ -1764,6 +1786,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(() => { @@ -1869,7 +1892,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) snapshot, trigger: isLiteralPendingAnswer ? null - : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + : resolveComposerTrigger( + detectActiveComposerTrigger( + snapshot.value, + snapshot.expandedCursor, + composerPathResults, + ), + composerEditorRef.current?.hasSelection() === true, + ), }; }; @@ -1882,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)} `; @@ -3087,6 +3122,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandCollapsedComposerCursor(promptForState, cursor), ) : null, + promptForState, ); }, addTerminalContext: (selection: TerminalContextSelection) => { @@ -3126,7 +3162,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); }); @@ -3567,6 +3606,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/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 7620bf8611020..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" && @@ -417,5 +421,10 @@ export function useComposerMenuItems({ : "No matching command."; })(); - return { items, isLoading, emptyState }; + return { + items, + isLoading, + pathResultsTruncated: isPathTrigger && workspaceEntries.truncated, + emptyState, + }; } diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 6adf037e1e45b..74c47f9e61947 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.ts +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -1,12 +1,48 @@ 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, resultPaths: readonly string[] | null) => { + const previous = previousSearchRef.current; + const continued = continueComposerPathTrigger( + text, + cursor, + previous.trigger, + previous.text, + previous.queryEnd, + resultPaths, + ); + 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 +52,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 +77,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 338a0406ab0ae..09dec3859cd09 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, @@ -113,3 +114,74 @@ 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 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, + paths, + ); + expect(back?.trigger.query).toBe("Foreign"); + expect( + continueComposerPathTrigger(text, end, back!.trigger, text, back!.queryEnd, paths)?.trigger + .query, + ).toBe("Foreign Subsidiaries"); + 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) => { + const before = "@Foreign"; + const text = before + boundary; + expect( + continueComposerPathTrigger( + text, + text.length, + 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 05ab5ca958598..c0b85fc146864 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -45,6 +45,52 @@ function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r"; } +/** + * 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, + resultTexts: readonly string[] | null, +): { 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; + if (isSpacedComposerQueryUnmatched(query, resultTexts)) return null; + return { + trigger: { kind: "path", query, rangeStart: start, rangeEnd: cursor }, + queryEnd, + }; +} + /** A second slash or backslash makes the whole word a path, even when the caret precedes it. */ export function isPathWord( text: string, diff --git a/scripts/scenarios/mention-prose-send.e2e.ts b/scripts/scenarios/mention-prose-send.e2e.ts new file mode 100644 index 0000000000000..4e4b90720f665 --- /dev/null +++ b/scripts/scenarios/mention-prose-send.e2e.ts @@ -0,0 +1,29 @@ +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, 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(); + // One event loop turn keeps the keypress ahead of the debounced search and committed menu refs. + 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 + })); + 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"); + }, +); diff --git a/scripts/scenarios/mention-search-spaces.e2e.ts b/scripts/scenarios/mention-search-spaces.e2e.ts new file mode 100644 index 0000000000000..a31d7d6601ccb --- /dev/null +++ b/scripts/scenarios/mention-search-spaces.e2e.ts @@ -0,0 +1,32 @@ +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 expect(screen.getByRole("option", "Foreign Subsidiaries", { exact: true })).toBeVisible(); + await composer.press("Enter"); + 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"); + }, +);