diff --git a/packages/components/src/components/mentions/mention-registry.ts b/packages/components/src/components/mentions/mention-registry.ts index 71021faad..772b0e0df 100644 --- a/packages/components/src/components/mentions/mention-registry.ts +++ b/packages/components/src/components/mentions/mention-registry.ts @@ -161,6 +161,14 @@ export type MentionCategory = { header?: MentionCategoryHeader; /** Optional actionable empty state supplied by the category. */ emptyState?: MentionCategoryEmptyState; + /** + * Claims a search that carries no `:` prefix, scoping the menu to + * this category alone. A directory drill-down writes a bare path (`@src/`), + * and only one source can answer a path — without this the menu falls back to + * the aggregate level and answers a directory listing with slash commands and + * issues beside the files. + */ + ownsBareSearch?: (search: string) => boolean; /** * Candidates for a term inside this category. Lazy on purpose: ranking the * file index is the expensive one, and a query aimed at another category @@ -254,6 +262,19 @@ export function selectMentionMenuView( return { level: 'categories', categories: [...categories] }; } + // A drill-down that is not `:` still belongs to one category — a path + // belongs to files. Asked of the categories rather than named here, so the + // selector learns nothing about any individual source. + const owner = categories.find((entry) => entry.ownsBareSearch?.(search)); + if (owner) { + return { + level: 'category', + category: owner, + term: search, + candidates: owner.getCandidates(search), + }; + } + const limit = options?.aggregateLimitPerCategory ?? AGGREGATE_LIMIT_PER_CATEGORY; const groups: MentionCandidateGroup[] = []; for (const category of categories) { @@ -358,6 +379,15 @@ export function toFileCandidate(item: PathSuggestion): MentionCandidate { }; } +/** + * Whether a bare `@` search is a path, and therefore the file category's alone. + * The separator IS the whole test: a directory drill-down writes `@src/`, and + * every keystroke after it (`@src/comp`) is still inside that path. + */ +export function isMentionPathSearch(search: string): boolean { + return search.includes('/'); +} + export function buildFileCandidates( index: FileSuggestionIndex | null, term: string, @@ -656,6 +686,7 @@ export function useMentionCategories(sources: MentionCategorySources): MentionCa icon: 'file', ...sourceCategoryFields('file', file), notice: file.notice, + ownsBareSearch: isMentionPathSearch, getCandidates: (term, limit) => buildFileCandidates(file.index, term, file.fuse, limit), }); } diff --git a/packages/components/src/components/mentions/mention-two-level-menu.tsx b/packages/components/src/components/mentions/mention-two-level-menu.tsx index e59e0cfbb..72e0a29b1 100644 --- a/packages/components/src/components/mentions/mention-two-level-menu.tsx +++ b/packages/components/src/components/mentions/mention-two-level-menu.tsx @@ -18,6 +18,7 @@ import { useFireOnKeyChange, useFireOncePerCycle } from '@/hooks/use-fire-once'; import { FileIcon, FolderIcon } from '@/components/icons/file-icons'; import { AgentRoleDetailPane } from '@/components/sessions/agent-role-detail-pane'; import { MentionContent, MentionItem, useMentionContext } from '@/ui/mention'; +import { getMentionDrillDownParent } from '@/ui/mention/mention-trigger'; import { useIsMentionMobile } from '@/ui/mention/mention-mobile-content'; import { getCategoryNavigateText, @@ -494,13 +495,16 @@ export function MentionTwoLevelMenu({ }); }, [getEnabledItems, highlightKey, onHighlightedItemChange, shouldHighlightFirst]); - // The click equivalent of the primitive's Backspace/ArrowLeft contract; mobile - // has no Backspace habit, so the second level always carries a visible way - // out. Focus stays in the composer — the menu closes the moment it blurs. + // The click equivalent of the primitive's ArrowLeft contract, by the same + // helper: ONE level, so a path drill-down walks back a directory at a time + // rather than losing every level at once. Mobile has no Backspace habit, so + // the second level always carries a visible way out; a level with nothing + // above it falls back to the bare trigger. Focus stays in the composer — the + // menu closes the moment it blurs. const { onNavigateBack, inputRef } = context; const handleBack = React.useCallback(() => { - if (onNavigateBack()) inputRef.current?.focus(); - }, [inputRef, onNavigateBack]); + if (onNavigateBack(getMentionDrillDownParent(search) ?? '')) inputRef.current?.focus(); + }, [inputRef, onNavigateBack, search]); // Side panel follows the highlight. Falls back to the first candidate so the // pane is populated before the highlight effect lands, and stays off mobile diff --git a/packages/components/src/ui/mention/mention-input.tsx b/packages/components/src/ui/mention/mention-input.tsx index 4d972d412..b9f1d6434 100644 --- a/packages/components/src/ui/mention/mention-input.tsx +++ b/packages/components/src/ui/mention/mention-input.tsx @@ -14,7 +14,11 @@ import { removeMentionText, } from './mention-input-core'; import { MentionHighlighter } from './mention-highlighter'; -import { findTriggerCandidates, isMentionNavigationPrefix } from './mention-trigger'; +import { + findTriggerCandidates, + getMentionDrillDownParent, + isMentionNavigationPrefix, +} from './mention-trigger'; import { type Mention, useMentionContext } from './mention-root'; const INPUT_NAME = 'MentionInput'; @@ -458,10 +462,9 @@ const MentionInput = React.forwardRef((props, f const input = event.currentTarget; onMentionUpdate(input); - if (!context.onMentionClick) return; - const selectionStart = input.selectionStart ?? 0; const selectionEnd = input.selectionEnd ?? selectionStart; + // A click that ENDS a drag-selection is not a click on a chip. if (selectionStart !== selectionEnd) return; const mentionAtCursor = context.mentions.find( @@ -469,11 +472,20 @@ const MentionInput = React.forwardRef((props, f ); if ( - mentionAtCursor && - isPointInsideMentionHighlight(input, mentionAtCursor, event.clientX, event.clientY) + !mentionAtCursor || + !isPointInsideMentionHighlight(input, mentionAtCursor, event.clientX, event.clientY) ) { - context.onMentionClick(mentionAtCursor); + return; } + + // A committed mention is atomic to every other input path: Backspace + // deletes the whole range and the horizontal arrows step over it, so a + // caret dropped inside one is a position no edit can use. Selecting the + // range is the click's own outcome — the chip mirror already paints a + // selected range, and until now only a drag could reach that. Callers + // with a kind-specific action run on top of it, not instead of it. + input.setSelectionRange(mentionAtCursor.start, mentionAtCursor.end); + context.onMentionClick?.(mentionAtCursor); }, [context, onMentionUpdate] ); @@ -635,6 +647,21 @@ const MentionInput = React.forwardRef((props, f return context.onNavigateBack(); } + /** + * Go up ONE drill-down level: `@ns:` to the bare trigger, `@src/comp/` to + * `@src/`. Wider than `tryNavigateBack` because it also walks a path, + * which Backspace must not do — inside a path Backspace still deletes one + * character at a time. + */ + function tryNavigateUp() { + if (hasSelection || event.shiftKey) return false; + const span = getTriggerSpan(); + if (!span) return false; + const parent = getMentionDrillDownParent(span.search); + if (parent === null) return false; + return context.onNavigateBack(parent); + } + /** Commit the highlighted (or exact-match) item, matching Enter. */ function trySelectHighlighted() { const span = getTriggerSpan(); @@ -695,7 +722,7 @@ const MentionInput = React.forwardRef((props, f break; } case 'ArrowLeft': { - if (tryNavigateBack()) event.preventDefault(); + if (tryNavigateUp()) event.preventDefault(); break; } case 'Backspace': { diff --git a/packages/components/src/ui/mention/mention-root.tsx b/packages/components/src/ui/mention/mention-root.tsx index 7cde427b5..3c787c3b9 100644 --- a/packages/components/src/ui/mention/mention-root.tsx +++ b/packages/components/src/ui/mention/mention-root.tsx @@ -199,13 +199,15 @@ interface MentionContextValue { */ onMentionInsert: (request: MentionInsertRequest) => void; /** - * Pop the text between the trigger and the caret back to the bare trigger, - * undoing one drill-down step. Returns false when there is no trigger to pop - * back to. The caller decides *when* this applies (Backspace on a namespace - * prefix, the menu's Back button); the transaction itself lives here because - * it has to interleave the controlled value commit with caret restoration. + * Rewrite the text between the trigger and the caret to `nextSearch`, undoing + * one drill-down step; the default pops all the way back to the bare trigger. + * Returns false when there is no trigger to pop back to. The caller decides + * *when* this applies and *where* it lands (Backspace on a namespace prefix, + * ArrowLeft on one path level, the menu's Back button); the transaction itself + * lives here because it has to interleave the controlled value commit with + * caret restoration. */ - onNavigateBack: () => boolean; + onNavigateBack: (nextSearch?: string) => boolean; onMentionsRemove: (mentionsToRemove: Mention[]) => void; onMentionClick?: (mention: Mention) => void; getMentionChip?: MentionChipResolver; @@ -665,25 +667,30 @@ const MentionRoot = React.forwardRef((props, forw [filterStore, inputValue, setInputValue, setMentions, setOpen, setValue] ); - const onNavigateBack = React.useCallback(() => { - const input = inputRef.current; - if (!input) return false; - const caretPosition = input.selectionStart ?? input.value.length; - const triggerIndex = input.value.lastIndexOf(trigger, caretPosition); - if (triggerIndex === -1) return false; - - const caret = triggerIndex + trigger.length; - const nextValue = input.value.slice(0, caret) + input.value.slice(caretPosition); - setInputValue(nextValue); - // Same reason as `onMentionAdd`: MentionInput restores the caret once it has - // rendered this exact value, because touching the DOM selection here races - // the controlled value commit. - setPendingSelection({ start: caret, end: caret, expectedValue: nextValue }); - filterStore.search = ''; - setHighlightedItem(null); - requestAnimationFrame(() => onItemsFilter()); - return true; - }, [filterStore, onItemsFilter, setInputValue, trigger]); + const onNavigateBack = React.useCallback( + (nextSearch = '') => { + const input = inputRef.current; + if (!input) return false; + const caretPosition = input.selectionStart ?? input.value.length; + const triggerIndex = input.value.lastIndexOf(trigger, caretPosition); + if (triggerIndex === -1) return false; + + const searchStart = triggerIndex + trigger.length; + const caret = searchStart + nextSearch.length; + const nextValue = + input.value.slice(0, searchStart) + nextSearch + input.value.slice(caretPosition); + setInputValue(nextValue); + // Same reason as `onMentionAdd`: MentionInput restores the caret once it has + // rendered this exact value, because touching the DOM selection here races + // the controlled value commit. + setPendingSelection({ start: caret, end: caret, expectedValue: nextValue }); + filterStore.search = nextSearch; + setHighlightedItem(null); + requestAnimationFrame(() => onItemsFilter()); + return true; + }, + [filterStore, onItemsFilter, setInputValue, trigger] + ); const onMentionsRemove = React.useCallback( (mentionsToRemove: Mention[]) => { diff --git a/packages/components/src/ui/mention/mention-trigger.ts b/packages/components/src/ui/mention/mention-trigger.ts index 04193e3a1..f2992895c 100644 --- a/packages/components/src/ui/mention/mention-trigger.ts +++ b/packages/components/src/ui/mention/mention-trigger.ts @@ -52,3 +52,34 @@ export function parseMentionNamespaceSearch( export function isMentionNavigationPrefix(search: string): boolean { return parseMentionNamespaceSearch(search)?.term === ''; } + +/** + * The parent of a completed PATH level — `src/components/` becomes `src/`, and + * `src/` becomes the empty search. Anything mid-segment (`src/comp`) has no + * level above it yet and answers null, so a key bound to this stays out of the + * way while the user is still typing one. + */ +function getPathDrillDownParent(search: string): string | null { + if (!search.endsWith('/')) return null; + const withoutTrailingSlash = search.slice(0, -1); + const lastSeparator = withoutTrailingSlash.lastIndexOf('/'); + return lastSeparator === -1 ? '' : withoutTrailingSlash.slice(0, lastSeparator + 1); +} + +/** + * The search ONE drill-down level above `search`, or null when there is no + * level above it. A bare `:` prefix pops to the bare trigger, as + * `isMentionNavigationPrefix` already says; a path pops one segment, whether or + * not it sits inside a namespace (`file:src/components/` -> `file:src/`). + * + * Deliberately NOT what Backspace uses: inside a path Backspace still deletes + * one character at a time. This is the rule for the gestures that mean "go up" + * rather than "delete" — ArrowLeft and the menu's own Back control. + */ +export function getMentionDrillDownParent(search: string): string | null { + const namespaced = parseMentionNamespaceSearch(search); + if (!namespaced) return getPathDrillDownParent(search); + if (namespaced.term === '') return ''; + const parent = getPathDrillDownParent(namespaced.term); + return parent === null ? null : `${namespaced.namespace}:${parent}`; +} diff --git a/packages/components/tests/mention-drill-down.test.ts b/packages/components/tests/mention-drill-down.test.ts new file mode 100644 index 000000000..74f4fb95f --- /dev/null +++ b/packages/components/tests/mention-drill-down.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getMentionDrillDownParent } from '../src/ui/mention/mention-trigger'; +import { + isMentionPathSearch, + selectMentionMenuView, + type MentionCandidate, + type MentionCategory, +} from '../src/components/mentions/mention-registry'; + +function makeCandidate(value: string): MentionCandidate { + return { + value, + label: value, + insertText: `@${value}`, + kind: 'file', + icon: 'file', + title: value, + }; +} + +function makeCategory( + id: MentionCategory['id'], + namespace: string, + label: string, + candidates: string[], + extra: Partial = {} +): MentionCategory { + return { + id, + namespace, + label, + icon: 'file', + status: 'ready', + getCandidates: vi.fn((term: string) => + candidates.filter((entry) => entry.includes(term)).map(makeCandidate) + ), + ...extra, + } as MentionCategory; +} + +describe('getMentionDrillDownParent', () => { + it('pops a bare namespace prefix to the bare trigger', () => { + expect(getMentionDrillDownParent('issue:')).toBe(''); + }); + + it('pops one path segment at a time', () => { + expect(getMentionDrillDownParent('src/components/')).toBe('src/'); + expect(getMentionDrillDownParent('src/')).toBe(''); + }); + + it('pops one path segment inside a namespace', () => { + expect(getMentionDrillDownParent('file:src/components/')).toBe('file:src/'); + expect(getMentionDrillDownParent('file:src/')).toBe('file:'); + }); + + // A level the user is still typing has nothing above it yet, so a key bound + // to this stays out of the way instead of eating the keystroke. + it('answers null mid-segment and for an empty search', () => { + expect(getMentionDrillDownParent('src/comp')).toBeNull(); + expect(getMentionDrillDownParent('file:src/comp')).toBeNull(); + expect(getMentionDrillDownParent('')).toBeNull(); + expect(getMentionDrillDownParent('readme')).toBeNull(); + }); +}); + +describe('isMentionPathSearch', () => { + it('claims a search that carries a separator, and nothing else', () => { + expect(isMentionPathSearch('src/')).toBe(true); + expect(isMentionPathSearch('src/comp')).toBe(true); + expect(isMentionPathSearch('readme')).toBe(false); + expect(isMentionPathSearch('')).toBe(false); + }); +}); + +describe('selectMentionMenuView with a category that owns the bare search', () => { + const file = makeCategory('file', 'file', 'Files', ['src/index.ts', 'src/app.ts'], { + ownsBareSearch: isMentionPathSearch, + }); + const command = makeCategory('command', 'command', 'Commands', ['src-sync']); + + it('scopes a bare path to the owning category instead of the aggregate level', () => { + const view = selectMentionMenuView([file, command], 'src/'); + expect(view.level).toBe('category'); + if (view.level !== 'category') return; + expect(view.category).toBe(file); + expect(view.term).toBe('src/'); + }); + + it('leaves a search no category claims at the aggregate level', () => { + const view = selectMentionMenuView([file, command], 'src'); + expect(view.level).toBe('aggregate'); + }); + + it('still resolves an explicit namespace first', () => { + const view = selectMentionMenuView([file, command], 'command:src'); + expect(view.level).toBe('category'); + if (view.level !== 'category') return; + expect(view.category).toBe(command); + }); +});