Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/components/src/components/mentions/mention-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<namespace>:` 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
Expand Down Expand Up @@ -254,6 +262,19 @@ export function selectMentionMenuView(
return { level: 'categories', categories: [...categories] };
}

// A drill-down that is not `<ns>:` 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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 34 additions & 7 deletions packages/components/src/ui/mention/mention-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -458,22 +462,30 @@ const MentionInput = React.forwardRef<InputElement, MentionInputProps>((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(
(mention) => selectionStart >= mention.start && selectionStart <= mention.end
);

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]
);
Expand Down Expand Up @@ -635,6 +647,21 @@ const MentionInput = React.forwardRef<InputElement, MentionInputProps>((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();
Expand Down Expand Up @@ -695,7 +722,7 @@ const MentionInput = React.forwardRef<InputElement, MentionInputProps>((props, f
break;
}
case 'ArrowLeft': {
if (tryNavigateBack()) event.preventDefault();
if (tryNavigateUp()) event.preventDefault();
break;
}
case 'Backspace': {
Expand Down
57 changes: 32 additions & 25 deletions packages/components/src/ui/mention/mention-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -665,25 +667,30 @@ const MentionRoot = React.forwardRef<RootElement, MentionRootProps>((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[]) => {
Expand Down
31 changes: 31 additions & 0 deletions packages/components/src/ui/mention/mention-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ns>:` 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}`;
}
101 changes: 101 additions & 0 deletions packages/components/tests/mention-drill-down.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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);
});
});