From 93ec082e0e3b1f95e906816cf6842904db7eb310 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:47:09 +0000 Subject: [PATCH 1/4] fix(composer): an @ search stays open across spaces in a name Typing a space ended an @ search, so a file, folder, thread, machine or model whose name has a space could not be typed out and picked. A space now continues the search that was already open, on web, desktop and mobile. A query with a space only offers results that contain exactly what was typed, and Enter, Tab and a tap check the picked row against the live query, so prose after an @word is never replaced by a fuzzy or stale suggestion and Enter still sends it. Once such a query settles with nothing matching, the search ends. Fixes pingdotgg/t3code#15156 Co-authored-by: omarbdri <84662449+omarbdri@users.noreply.github.com> Co-Authored-By: Claude Fable 5.2 --- .../threads/use-composer-command-menu.test.ts | 97 ++++++++++++++++++- .../threads/use-composer-command-menu.ts | 41 +++++++- apps/mobile/src/state/queries.ts | 1 + apps/mobile/vite.config.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 23 +++-- .../chat/composerCommandKey.test.ts | 86 ++++++++++++++++ .../src/components/chat/composerCommandKey.ts | 6 +- .../components/chat/useComposerMenuItems.ts | 19 +++- .../chat/useComposerTriggerState.ts | 41 +++++++- apps/web/src/composer-logic.test.ts | 11 +++ apps/web/src/composer-logic.ts | 6 +- packages/shared/src/composerTrigger.test.ts | 85 ++++++++++++++++ packages/shared/src/composerTrigger.ts | 40 ++++++++ .../scenarios/mention-search-spaces.e2e.ts | 28 ++++++ 14 files changed, 463 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/chat/composerCommandKey.test.ts create mode 100644 scripts/scenarios/mention-search-spaces.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 17f970a1ef22d..b4219133ef1b0 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,18 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; 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: () => ({ matches: [], query: "", isPending: false }), + useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); vi.mock("../../state/use-composer-drafts", () => ({ @@ -21,11 +29,13 @@ vi.mock("../../state/server", () => ({ })); import type { ModelOption } from "../../lib/modelOptions"; +import { useComposerPathSearch } from "../../state/queries"; import { buildComposerModelItems, buildComposerSlashCommandItems, resolveComposerCommandSelection, resolveComposerMenuSelection, + useComposerCommandMenu, } from "./use-composer-command-menu"; describe("resolveComposerMenuSelection", () => { @@ -174,3 +184,86 @@ describe("mobile model mentions", () => { expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]); }); }); + +describe("a mention search with spaces", () => { + type MenuInput = Parameters[0]; + + async function mountMenu(options: Partial = {}) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const root = createRoot(document.createElement("div")); + let menu: ReturnType | null = null; + function Probe(props: { readonly text: string; readonly ownerKey: string }) { + const value = useComposerCommandMenu({ + draftMessage: props.text, + ownerKey: props.ownerKey, + environmentId: null, + projectCwd: null, + selectedProviderStatus: null, + hasThread: false, + hasCompactableConversation: false, + onChangeDraftMessage: () => {}, + ...options, + }); + useLayoutEffect(() => { + menu = value; + }); + return null; + } + return { + /** Renders the draft with the caret at its end, as typing leaves it. */ + type: async (text: string, ownerKey = "draft-a") => { + await act(() => root.render(createElement(Probe, { text, ownerKey }))); + await act(() => menu!.onSelectionChange({ start: text.length, end: text.length })); + return menu!; + }, + [Symbol.asyncDispose]: async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + vi.mocked(useComposerPathSearch).mockReset(); + }, + }; + } + + const pathSearch = (isPending: boolean, paths: ReadonlyArray) => + vi.mocked(useComposerPathSearch).mockReturnValue({ + entries: paths.map((path) => ({ path, kind: "file" as const })), + error: null, + isPending, + truncated: false, + refresh: vi.fn(), + }); + + it("stays open across a space while a result contains the typed text", async () => { + pathSearch(false, ["Foreign Subsidiaries/Script Notes.md", "Foreign.md"]); + await using menu = await mountMenu({ + environmentId: EnvironmentId.make("local"), + environments: [ + { environmentId: EnvironmentId.make("remote"), label: "Foreign Server", machine: "server" }, + ], + }); + await menu.type("Open @Foreign"); + const spaced = await menu.type("Open @Foreign S"); + expect(spaced.trigger).toMatchObject({ query: "Foreign S", rangeStart: 5, rangeEnd: 15 }); + expect(spaced.items.map((item) => item.label)).toEqual(["Foreign Server", "Script Notes.md"]); + // Another draft's search is not continued into this one. + expect((await menu.type("Open @Foreign S", "draft-b")).trigger).toBeNull(); + }); + + it("refuses a fuzzy result for prose and ends the search once results settle", async () => { + const onChangeDraftMessage = vi.fn(); + pathSearch(false, ["ChatComposer.tsx"]); + await using menu = await mountMenu({ onChangeDraftMessage }); + const stale = (await menu.type("Fix @ChatComposer")).items[0]!; + + pathSearch(true, ["ChatComposer.tsx"]); + const pending = await menu.type("Fix @ChatComposer so"); + expect(pending.trigger?.query).toBe("ChatComposer so"); + expect(pending.items).toEqual([]); + await act(() => pending.onSelect(stale)); + expect(onChangeDraftMessage).not.toHaveBeenCalled(); + + pathSearch(false, ["ChatComposer.tsx"]); + expect((await menu.type("Fix @ChatComposer so")).trigger).toBeNull(); + expect((await menu.type("Fix @ChatComposer so that")).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 a77f7089c97bd..f829d74f4d495 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,8 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + composerItemAnswersTrigger, + continueComposerPathTrigger, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -329,11 +331,21 @@ export function useComposerCommandMenu({ setSelection(nextSelection); }; + // The `@` search of the last render, which a space in this one continues. + const [carried, setCarried] = useState<{ + readonly ownerKey: string | null; + readonly trigger: ComposerTrigger | null; + }>({ ownerKey, trigger: null }); const trigger = (() => { if (!enabled || selection.start !== selection.end) { return null; } - return detectComposerTrigger(draftMessage, selection.end); + return continueComposerPathTrigger({ + text: draftMessage, + cursor: selection.end, + detected: detectComposerTrigger(draftMessage, selection.end), + previous: carried.ownerKey === ownerKey ? carried.trigger : null, + }); })(); const pathSearch = useComposerPathSearch({ environmentId, @@ -341,7 +353,7 @@ export function useComposerCommandMenu({ query: trigger?.kind === "path" ? trigger.query : null, }); // Mounts older settled threads matching the mention, which shells may leave out. - useThreadSearch( + const threadSearch = useThreadSearch( trigger?.kind === "path" && environmentId ? [environmentId] : [], trigger?.kind === "path" ? trigger.query : "", { titlesOnly: true }, @@ -515,7 +527,7 @@ export function useComposerCommandMenu({ modelOptions && modelProviders ? buildComposerModelItems(modelOptions, trigger.query, modelProviders) : []; - return [ + const pathItems: ComposerCommandItem[] = [ ...threadItems, ...environmentItems, ...modelItems, @@ -531,13 +543,34 @@ export function useComposerCommandMenu({ }; }), ]; + return pathItems.filter((item) => composerItemAnswersTrigger(item, trigger)); } return []; })(); + // A query with a space ends once its search settles with nothing containing it, so prose + // after `@word` stops searching. A capped list waits for the next word instead. + const ruledOut = + trigger?.kind === "path" && + trigger.query.includes(" ") && + items.length === 0 && + !pathSearch.isPending && + !threadSearch.isPending && + !(pathSearch.truncated && trigger.query.endsWith(" ")); + const nextCarried = ruledOut ? null : trigger; + if ( + carried.ownerKey !== ownerKey || + carried.trigger?.kind !== nextCarried?.kind || + carried.trigger?.query !== nextCarried?.query || + carried.trigger?.rangeStart !== nextCarried?.rangeStart || + carried.trigger?.rangeEnd !== nextCarried?.rangeEnd + ) { + setCarried({ ownerKey, trigger: nextCarried }); + } + const onSelect = (item: ComposerCommandItem) => { - if (!trigger) return; + if (!trigger || !composerItemAnswersTrigger(item, trigger)) return; if (item.type === "environment") { if (!ownerKey || trigger.kind !== "path") return; const record = environmentComposerContext(item.environmentId, item.label); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index a4cdbc6244204..5b598f5b613e4 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -452,6 +452,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/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eb01b8242e2df..2a6d4f2698b80 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 { + composerItemAnswersTrigger, + serializeComposerFileLink, +} from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { splitTransferredFiles } from "./workspaceFileDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; @@ -977,6 +980,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const { trigger: composerTrigger, + detectTrigger: detectActiveComposerTrigger, + endTrigger: endComposerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, dismissTrigger: dismissComposerTrigger, @@ -1109,6 +1114,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) items: composerMenuItems, isLoading: isComposerMenuLoading, emptyState: composerMenuEmptyState, + spacedQueryIsEmpty: composerSpacedQueryIsEmpty, + spacedQueryRuledOut: composerSpacedQueryRuledOut, } = useComposerMenuItems({ composerTrigger, environmentId, @@ -1131,7 +1138,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelMentions: { instanceEntries: providerInstanceEntries, modelOptionsByInstance }, }); - const composerMenuOpen = Boolean(composerTrigger); + const composerMenuOpen = Boolean(composerTrigger) && !composerSpacedQueryIsEmpty; const composerMenuSearchKey = composerTrigger ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; @@ -1149,6 +1156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; + // Ends a spaced query whose search settled empty, so prose after `@word` stops searching. + if (composerSpacedQueryRuledOut) endComposerTrigger(composerTrigger); }); const isComposerApprovalState = activePendingApproval !== null; @@ -1569,7 +1578,7 @@ 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), ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -1692,7 +1701,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention ? null : detectActiveComposerTrigger(nextPrompt, expandedCursor), ); }; const onPromptChangeStable = useStableCallback(onPromptChange); @@ -1869,7 +1878,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) snapshot, trigger: isLiteralPendingAnswer ? null - : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + : resolveComposerTrigger( + detectActiveComposerTrigger(snapshot.value, snapshot.expandedCursor), + ), }; }; @@ -1881,7 +1892,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerSelectLockRef.current = false; }); const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; + if (!trigger || !composerItemAnswersTrigger(item, trigger)) 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..295af2b14da7e --- /dev/null +++ b/apps/web/src/components/chat/composerCommandKey.test.ts @@ -0,0 +1,86 @@ +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"])( + "%s does not accept a row left over from before the query gained a space", + (key) => { + const value = "Check @README and fix it"; + const readme: ComposerCommandItem = { + id: "path:file: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 handlerFor = (selected: ComposerCommandItem) => + composerCommandKeyHandler({ + 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 }, + // The menu refs still hold what the last render committed, before the keystrokes. + 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; + + handlerFor(readme)(key, event); + expect(onSelectComposerItem).not.toHaveBeenCalled(); + expect(submitComposer).toHaveBeenCalledTimes(key === "Enter" ? 1 : 0); + + const named = { ...readme, path: "docs/README and fix it.md" }; + handlerFor(named)(key, event); + expect(onSelectComposerItem).toHaveBeenCalledExactlyOnceWith(named); + }, +); diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 40650fe6c8952..37e913c74daef 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -16,6 +16,7 @@ import { collapseExpandedComposerCursor, composerSubmissionIntentForKey, } from "../../composer-logic"; +import { composerItemAnswersTrigger } from "@t3tools/shared/composerTrigger"; import { type PendingUserInput } from "../../session-logic"; import { type PreviewAnnotationPayload } from "@t3tools/contracts"; import { type ReviewCommentContext } from "../../reviewCommentContext"; @@ -237,7 +238,10 @@ export function composerCommandKeyHandler(input: { nudgeComposerMenuHighlight("ArrowUp"); return true; } - if ((key === "Enter" || key === "Tab") && selectedItem) { + // The refs lag a keystroke, so the highlighted row may belong to an earlier query. + const selectable = + selectedItem && (trigger === null || composerItemAnswersTrigger(selectedItem, trigger)); + if ((key === "Enter" || key === "Tab") && selectable) { onSelectComposerItem(selectedItem); return true; } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 8090157604986..3c3d81f509886 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -23,6 +23,7 @@ import { filterComposerPullRequestMatches, withLinkedComposerPullRequestsFirst, } from "@t3tools/shared/composerPullRequestMatches"; +import { composerItemAnswersTrigger } from "@t3tools/shared/composerTrigger"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import type { ComposerTrigger } from "../../composer-logic"; @@ -124,7 +125,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(); @@ -200,7 +203,7 @@ export function useComposerMenuItems({ const items = ((): ComposerCommandItem[] => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { - return [ + const pathItems: ComposerCommandItem[] = [ ...matchComposerThreadItems({ shells: environmentThreadShells, environmentId, @@ -232,6 +235,7 @@ export function useComposerMenuItems({ description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), })), ]; + return pathItems.filter((item) => composerItemAnswersTrigger(item, composerTrigger)); } if (composerTrigger.kind === "slash-command") { const builtInSlashCommandItems = [ @@ -406,6 +410,15 @@ export function useComposerMenuItems({ pullRequestTextQuery !== debouncedPullRequestTextQuery || pullRequestTriggerNumber !== debouncedPullRequestNumber || exactPullRequestLookup.isPending)); + // A query with a space shows only while something contains it, so prose after `@word` gets + // no picker. It ends once its search settles empty; a capped list waits for the next word. + const spacedQueryIsEmpty = + composerTriggerKind === "path" && pathTriggerQuery.includes(" ") && items.length === 0; + const spacedQueryRuledOut = + spacedQueryIsEmpty && + !workspaceEntries.isPending && + !threadSearch.isPending && + !(workspaceEntries.truncated && pathTriggerQuery.endsWith(" ")); const emptyState = (() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -429,5 +442,5 @@ export function useComposerMenuItems({ : "No matching command."; })(); - return { items, isLoading, emptyState }; + return { items, isLoading, emptyState, spacedQueryIsEmpty, spacedQueryRuledOut }; } diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 6adf037e1e45b..7fc0a61ed18ff 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.ts +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -1,11 +1,34 @@ +import { continueComposerPathTrigger } from "@t3tools/shared/composerTrigger"; import { useRef, useState } from "react"; -import type { ComposerTrigger } from "../../composer-logic"; +import { detectComposerTrigger, type ComposerTrigger } from "../../composer-logic"; export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { const [trigger, setActiveTrigger] = useState(initialTrigger); + // Handlers can run twice before a render, so the trigger an edit continues is read from here. + const activeTriggerRef = useRef(trigger); const dismissedTriggerRef = useRef(null); + const activate = (next: ComposerTrigger | null) => { + activeTriggerRef.current = next; + setActiveTrigger(next); + }; + + /** The trigger at the caret, where a space continues the `@` search that was already active. */ + const detectTrigger = (text: string, cursor: number) => { + return continueComposerPathTrigger({ + text, + cursor, + detected: detectComposerTrigger(text, cursor), + previous: activeTriggerRef.current, + }); + }; + + /** Ends `candidate` unless a newer edit already replaced it. */ + const endTrigger = (candidate: ComposerTrigger | null) => { + if (candidate !== null && activeTriggerRef.current === candidate) activate(null); + }; + const resolveTrigger = (candidate: ComposerTrigger | null) => { const dismissed = dismissedTriggerRef.current; return candidate && @@ -21,18 +44,26 @@ export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | if (candidate === null || activeTrigger !== null) { dismissedTriggerRef.current = null; } - setActiveTrigger(activeTrigger); + activate(activeTrigger); }; const dismissTrigger = (candidate: ComposerTrigger | null) => { dismissedTriggerRef.current = candidate; - setActiveTrigger(null); + activate(null); }; const resetTrigger = (candidate: ComposerTrigger | null) => { dismissedTriggerRef.current = null; - setActiveTrigger(candidate); + activate(candidate); }; - return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; + return { + trigger, + detectTrigger, + endTrigger, + setTrigger, + resolveTrigger, + dismissTrigger, + resetTrigger, + }; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index e7ec86822a547..b534a59d2ff35 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -67,6 +67,17 @@ describe("isPathSuggestionCurrent", () => { ).toBe(true); }); + it("allows a suggestion for a spaced query while its search catches up", () => { + expect( + isPathSuggestionCurrent( + searchedTarget, + { kind: "path", query: "alpha beta", rangeStart: 0, rangeEnd: 11 }, + searchedTarget.environmentId, + searchedTarget.cwd, + ), + ).toBe(true); + }); + it.each([ ["query", { ...searchedTarget, query: "beta" }], ["cwd", { ...searchedTarget, cwd: "/other-repo" }], diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 37fef411897fc..b1615e79b8796 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -28,6 +28,10 @@ export interface ComposerTrigger { rangeEnd: number; } +/** + * A row from an earlier search is refused, except for a query with a space: its rows are only + * offered when they contain the typed text, so one from a search still catching up is right. + */ export function isPathSuggestionCurrent( searchTarget: ComposerPathSearchTarget, trigger: ComposerTrigger | null, @@ -38,7 +42,7 @@ export function isPathSuggestionCurrent( trigger?.kind === "path" && searchTarget.environmentId === environmentId && searchTarget.cwd === cwd && - searchTarget.query === trigger.query.trim() + (searchTarget.query === trigger.query.trim() || trigger.query.includes(" ")) ); } diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 338a0406ab0ae..1979d5f497f4c 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import { + composerItemAnswersTrigger, + continueComposerPathTrigger, detectComposerTrigger, parseSideChatCommand, serializeComposerFileLink, @@ -78,6 +80,89 @@ describe("detectComposerTrigger", () => { }); }); +describe("continueComposerPathTrigger", () => { + const continued = (text: string, previousText: string | null) => + continueComposerPathTrigger({ + text, + cursor: text.length, + detected: detectComposerTrigger(text, text.length), + previous: previousText === null ? null : detectComposerTrigger(previousText, Infinity), + }); + + it.each([ + ["Open @Foreign ", "Foreign "], + ["Open @Foreign Sub", "Foreign Sub"], + ])("carries the active search into %j", (text, query) => { + expect(continued(text, "Open @Foreign")).toEqual({ + kind: "path", + query, + rangeStart: 5, + rangeEnd: text.length, + }); + }); + + it("stays in the search while the caret moves back through the query", () => { + const spaced = continued("Open @Foreign Sub", "Open @Foreign"); + expect( + continueComposerPathTrigger({ + text: "Open @Foreign Sub", + cursor: "Open @Foreign ".length, + detected: null, + previous: spaced, + }), + ).toMatchObject({ query: "Foreign ", rangeStart: 5, rangeEnd: 14 }); + }); + + it.each([ + ["no search was active", "Open @Foreign Sub", null], + ["the query was replaced", "Open @Other thing", "Open @Foreign"], + ["the at sign was deleted", "Open Foreign Sub", "Open @Foreign"], + ["a newline ends the line", "Open @Foreign\n", "Open @Foreign"], + ["a tab follows", "Open @Foreign\tSub", "Open @Foreign"], + ["a terminal context follows", "Open @Foreign \uFFFC", "Open @Foreign"], + ["only a space follows the at sign", "Open @ ", "Open @"], + ["the active trigger is not a mention", "Run $skill now", "Run $skill"], + ])("ends the search when %s", (_name, text, previousText) => { + expect(continued(text, previousText)).toBeNull(); + }); + + it("prefers a trigger detected at the caret", () => { + expect(continued("Open @Foreign and @Sub", "Open @Foreign and")).toMatchObject({ + query: "Sub", + rangeStart: 18, + }); + }); +}); + +describe("composerItemAnswersTrigger", () => { + const spaced = { kind: "path", query: "Foreign Sub" }; + + it("offers any result for a query without a space", () => { + expect( + composerItemAnswersTrigger({ label: "README.md" }, { kind: "path", query: "rdme" }), + ).toBe(true); + }); + + it("offers a result for a spaced query only when it contains the typed text", () => { + expect(composerItemAnswersTrigger({ label: "foreign subsidiaries" }, spaced)).toBe(true); + expect( + composerItemAnswersTrigger( + { label: "Notes.md", path: "Foreign Subsidiaries/Notes.md" }, + spaced, + ), + ).toBe(true); + expect( + composerItemAnswersTrigger( + { label: "ChatComposer.tsx", path: "src/ChatComposer.tsx" }, + { kind: "path", query: "ChatComposer so" }, + ), + ).toBe(false); + expect( + composerItemAnswersTrigger({ label: "README.md" }, { kind: "path", query: "README " }), + ).toBe(false); + }); +}); + describe("serializeComposerFileLink", () => { it("encodes markdown-sensitive destination characters", () => { expect(serializeComposerFileLink("docs/My File (draft).md")).toBe( diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 05ab5ca958598..b861875a25d90 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -158,6 +158,46 @@ export function detectComposerTrigger( }; } +/** + * A query with a space offers only results that contain exactly what was typed, so prose + * after an `@word` is never replaced by a fuzzy or stale suggestion. + */ +export function composerItemAnswersTrigger( + item: { readonly label: string; readonly path?: string }, + trigger: { readonly kind: string; readonly query: string }, +): boolean { + if (trigger.kind !== "path" || !trigger.query.includes(" ")) return true; + const query = trigger.query.toLowerCase(); + return ( + item.label.toLowerCase().includes(query) || item.path?.toLowerCase().includes(query) === true + ); +} + +/** + * Carries the `@` search that was active before an edit across spaces, so a name with spaces + * can be typed out. Only that search continues: an `@` further back in prose is never + * reopened. + */ +export function continueComposerPathTrigger(input: { + readonly text: string; + readonly cursor: number; + readonly detected: Trigger | null; + readonly previous: Trigger | null; +}): Trigger | null { + const { text, detected, previous } = input; + if (detected !== null || previous?.kind !== "path") return detected; + const cursor = clampCursor(text, input.cursor); + const query = text.slice(previous.rangeStart + 1, cursor); + if ( + text[previous.rangeStart] !== "@" || + !/^[^\s\uFFFC][^\n\r\t\uFFFC]*$/.test(query) || + !(query.startsWith(previous.query) || previous.query.startsWith(query)) + ) { + return null; + } + return { ...previous, query, rangeEnd: cursor }; +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/scripts/scenarios/mention-search-spaces.e2e.ts b/scripts/scenarios/mention-search-spaces.e2e.ts new file mode 100644 index 0000000000000..8c5a77d1c5ed4 --- /dev/null +++ b/scripts/scenarios/mention-search-spaces.e2e.ts @@ -0,0 +1,28 @@ +import { expect } from "e2e"; + +import { test, WEB } from "./t3.ts"; + +test( + "An active mention search accepts a name 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.getByText("Foreign Unrelated.md", { exact: true })).toBeVisible(); + await composer.press("End"); + for (const key of ["Space", ..."Subsidiaries"]) await composer.press(key); + await app.screenshot("multiword-search"); + await expect(screen.getByRole("option", "Foreign Subsidiaries", { exact: true })).toBeVisible(); + await expect(screen.getByText("Foreign Unrelated.md", { exact: true })).toBeHidden(); + await composer.press("Enter"); + await expect( + screen.getByRole("button", "Preview Foreign Subsidiaries", { exact: true }), + ).toBeVisible(); + await app.screenshot("accepted-mention"); + }, +); From 6d46897c6df3e989e5dc348ba00bf46f7f666c40 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:05:28 +0000 Subject: [PATCH 2/4] fix(composer): a spaced @ search waits for older threads it found A thread search clears its pending state a render before the older threads it found reach the shells the picker ranks. A query with a space whose only match was such a thread was ended in that gap, so the thread was never offered. The search now stays open while found rows exist. Co-Authored-By: Claude Fable 5.2 --- .../threads/use-composer-command-menu.test.ts | 18 ++++++++++++++++-- .../threads/use-composer-command-menu.ts | 1 + apps/mobile/src/state/queries.ts | 3 +++ .../components/chat/useComposerMenuItems.ts | 1 + apps/web/src/state/queries.ts | 3 +++ 5 files changed, 24 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 b4219133ef1b0..c33c5c79b15be 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 @@ -11,7 +11,12 @@ import { vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); vi.mock("../../state/queries", () => ({ - useThreadSearch: () => ({ matches: [], query: "", isPending: false }), + useThreadSearch: vi.fn(() => ({ + matches: [], + query: "", + isPending: false, + hasOlderRows: false, + })), useComposerPathSearch: vi.fn(() => ({ entries: [], isPending: false, truncated: false })), useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), })); @@ -29,7 +34,7 @@ vi.mock("../../state/server", () => ({ })); import type { ModelOption } from "../../lib/modelOptions"; -import { useComposerPathSearch } from "../../state/queries"; +import { useComposerPathSearch, useThreadSearch } from "../../state/queries"; import { buildComposerModelItems, buildComposerSlashCommandItems, @@ -233,6 +238,11 @@ describe("a mention search with spaces", () => { refresh: vi.fn(), }); + const olderThreads = (hasOlderRows: boolean) => + vi + .mocked(useThreadSearch) + .mockReturnValue({ matches: [], query: "", isPending: false, hasOlderRows }); + it("stays open across a space while a result contains the typed text", async () => { pathSearch(false, ["Foreign Subsidiaries/Script Notes.md", "Foreign.md"]); await using menu = await mountMenu({ @@ -263,6 +273,10 @@ describe("a mention search with spaces", () => { expect(onChangeDraftMessage).not.toHaveBeenCalled(); pathSearch(false, ["ChatComposer.tsx"]); + // An older thread the search found is not in the shells yet, so the search stays open for it. + olderThreads(true); + expect((await menu.type("Fix @ChatComposer so")).trigger?.query).toBe("ChatComposer so"); + olderThreads(false); expect((await menu.type("Fix @ChatComposer so")).trigger).toBeNull(); expect((await menu.type("Fix @ChatComposer so that")).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 f829d74f4d495..9cc49b1c44e13 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -557,6 +557,7 @@ export function useComposerCommandMenu({ items.length === 0 && !pathSearch.isPending && !threadSearch.isPending && + !threadSearch.hasOlderRows && !(pathSearch.truncated && trigger.query.endsWith(" ")); const nextCarried = ruledOut ? null : trigger; if ( diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 5b598f5b613e4..494f24a0c7284 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -223,6 +223,8 @@ export function useThreadSearch( /** The query `matches` are for, which lags the typed one while a search runs. */ readonly query: string; readonly isPending: boolean; + /** Older threads were found whose rows reach the caller's shells a render after `isPending` clears. */ + readonly hasOlderRows: boolean; } { // A pasted wall of text searches by its start rather than failing the request. const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); @@ -276,6 +278,7 @@ export function useThreadSearch( matches: visible.matches, query: visible.query, isPending, + hasOlderRows: result.rows.length > 0, }; } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 3c3d81f509886..296c6c3245c6a 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -418,6 +418,7 @@ export function useComposerMenuItems({ spacedQueryIsEmpty && !workspaceEntries.isPending && !threadSearch.isPending && + !threadSearch.hasOlderRows && !(workspaceEntries.truncated && pathTriggerQuery.endsWith(" ")); const emptyState = (() => { if (composerTriggerKind === "skill") { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 0461f3ac2b02a..28707f9711262 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -110,6 +110,8 @@ export function useThreadSearch( readonly matches: ReadonlyArray; readonly query: string; readonly isPending: boolean; + /** Older threads were found whose rows reach the caller's shells a render after `isPending` clears. */ + readonly hasOlderRows: boolean; } { // A pasted wall of text searches by its start rather than failing the request. const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim(); @@ -163,6 +165,7 @@ export function useThreadSearch( matches: visible.matches, query: visible.query, isPending, + hasOlderRows: result.rows.length > 0, }; } From 863cc11a768b67c3d09684827cb338cf5c6962d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:31:00 +0000 Subject: [PATCH 3/4] fix(web): selecting text ends an @ search carried across spaces A selection inside a query with spaces left the picker open, since the search was carried on from the caret's start. A selection now stops the search from being carried, as it already does on mobile. Co-Authored-By: Claude Fable 5.2 --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++++++- .../web/src/components/chat/useComposerTriggerState.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2a6d4f2698b80..8bcd16c9da077 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -980,7 +980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const { trigger: composerTrigger, - detectTrigger: detectActiveComposerTrigger, + detectTrigger: detectContinuedComposerTrigger, endTrigger: endComposerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, @@ -990,6 +990,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const detectActiveComposerTrigger = (text: string, cursor: number) => { + const selection = composerEditorRef.current?.readSelectionRange(); + return detectContinuedComposerTrigger( + text, + cursor, + selection !== undefined && selection.start !== selection.end, + ); + }; const composerSuggestionId = useId(); const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; const promptHistoryPositionRef = useRef<{ diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts index 7fc0a61ed18ff..96931e4d8f662 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.ts +++ b/apps/web/src/components/chat/useComposerTriggerState.ts @@ -14,13 +14,16 @@ export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | setActiveTrigger(next); }; - /** The trigger at the caret, where a space continues the `@` search that was already active. */ - const detectTrigger = (text: string, cursor: number) => { + /** + * The trigger at the caret, where a space continues the `@` search that was already active. + * Selecting text ends that continuation. + */ + const detectTrigger = (text: string, cursor: number, hasSelection = false) => { return continueComposerPathTrigger({ text, cursor, detected: detectComposerTrigger(text, cursor), - previous: activeTriggerRef.current, + previous: hasSelection ? null : activeTriggerRef.current, }); }; From 3968727b1218258b4e99f041a905b71ba306b4fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 22:25:49 +0000 Subject: [PATCH 4/4] fix(mobile): the first word of an open @ search is not drawn as a chip The mobile editor draws `@word` followed by a space as a mention chip. With a search now continuing across spaces, the first word of the query turned into a chip while the picker was still open for it. The editor leaves the mention at the open search's `@` as plain text. Co-Authored-By: Claude Fable 5.2 --- apps/mobile/src/features/threads/ThreadComposer.tsx | 3 +++ apps/mobile/src/native/T3ComposerEditor.ios.tsx | 6 ++++++ apps/mobile/src/native/T3ComposerEditor.native.tsx | 6 ++++++ apps/mobile/src/native/T3ComposerEditor.types.ts | 2 ++ 4 files changed, 17 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 3b1b927ef8a9b..e8ba64d3b819d 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -964,6 +964,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps readOnly={isSubmissionLocked} skills={composerMenu.skills} selection={composerMenu.selection} + searchingMentionStart={ + composerMenu.trigger?.kind === "path" ? composerMenu.trigger.rangeStart : null + } onChangeText={(text) => { if (!isSubmissionLocked) props.onChangeDraftMessage(text); }} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..d1d5b60e0cd08 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -141,6 +141,7 @@ export function ComposerEditor({ skills = EMPTY_SKILLS, models = EMPTY_MODELS, selection, + searchingMentionStart, style, textStyle, onChangeText, @@ -176,6 +177,11 @@ export function ComposerEditor({ }); setConfirmedTokens({ value: props.value, tokens }); } + if (searchingMentionStart != null) { + tokens = tokens.filter( + (token) => token.type !== "mention" || token.start !== searchingMentionStart, + ); + } const bodyText = useScaledTextRole("body"); const theme = useUniwindTheme(); const fontFamily = FONT_FAMILIES.regular; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 5aabd8e0d6071..f7fb417c67092 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -133,6 +133,7 @@ export function ComposerEditor({ skills = EMPTY_SKILLS, models = EMPTY_MODELS, selection, + searchingMentionStart, style, textStyle, onChangeText, @@ -165,6 +166,11 @@ export function ComposerEditor({ }); setConfirmedTokens({ value: props.value, tokens }); } + if (searchingMentionStart != null) { + tokens = tokens.filter( + (token) => token.type !== "mention" || token.start !== searchingMentionStart, + ); + } const theme = useUniwindTheme(); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index f2ffa034cdb11..d3f4e96e5790d 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -47,6 +47,8 @@ export interface ComposerEditorProps { >; readonly models?: ReadonlyArray; readonly selection?: ComposerEditorSelection; + /** Where the `@` of a search still being typed sits; that text is not drawn as a mention chip. */ + readonly searchingMentionStart?: number | null; readonly placeholder?: string; readonly autoFocus?: boolean; readonly editable?: boolean;