diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 9192fbc7..963e8c46 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; +import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults'; import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type FileInfo = ProjectFileSearchHit; @@ -124,9 +125,11 @@ export const FileMentionAutocomplete = React.forwardRef normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2), [agents, normalizedSearchQuery.length], ); - const visibleDirectories = directories; const visibleRecentFiles = recentFiles; - const visibleFiles = files; + const visibleResults = React.useMemo( + () => rankFileMentionResults(files, directories, normalizedSearchQuery, 20), + [files, directories, normalizedSearchQuery], + ); React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { @@ -152,13 +155,9 @@ export const FileMentionAutocomplete = React.forwardRef file.path)); - setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15)); + setFiles(hits.filter((hit) => !recentSet.has(hit.path))); }) .catch(() => { if (!cancelled) { @@ -210,13 +209,9 @@ export const FileMentionAutocomplete = React.forwardRef { if (!cancelled) { - setDirectories(hits.slice(0, 10)); + setDirectories(hits); } }) .catch(() => { @@ -282,7 +277,7 @@ export const FileMentionAutocomplete = React.forwardRef { selectedIndexRef.current = selectedIndex; @@ -332,7 +327,7 @@ export const FileMentionAutocomplete = React.forwardRef { const labelNode = labelRefs.current[selectedIndex]; @@ -376,7 +371,7 @@ export const FileMentionAutocomplete = React.forwardRef { const ext = file.extension?.toLowerCase(); @@ -482,38 +469,11 @@ export const FileMentionAutocomplete = React.forwardRef )} - {visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && ( -
- )} - {visibleDirectories.map((dir, index) => { - const rowIndex = visibleAgents.length + index; - const relativePath = dir.relativePath || dir.name; - const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); - const isSelected = selectedIndex === rowIndex; - - return ( -
{ itemRefs.current[rowIndex] = el; }} - className={cn( - "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg", - isSelected && "bg-interactive-selection" - )} - onClick={() => handleFileSelect(dir)} - onMouseMove={() => setSelectedIndex(rowIndex)} - > - - - {displayPath} - -
- ); - })} - {visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && ( + {visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
)} {visibleRecentFiles.map((file, index) => { - const rowIndex = visibleAgents.length + visibleDirectories.length + index; + const rowIndex = visibleAgents.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; @@ -561,11 +521,11 @@ export const FileMentionAutocomplete = React.forwardRef ); })} - {visibleRecentFiles.length > 0 && visibleFiles.length > 0 && ( + {visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
)} - {visibleFiles.map((file, index) => { - const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index; + {visibleResults.map((file, index) => { + const rowIndex = visibleAgents.length + visibleRecentFiles.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; @@ -582,7 +542,9 @@ export const FileMentionAutocomplete = React.forwardRef handleFileSelect(file)} onMouseMove={() => setSelectedIndex(rowIndex)} > - {getFileIcon(file)} + {file.kind === 'directory' + ? + : getFileIcon(file)} { labelRefs.current[rowIndex] = el; }} className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container" @@ -613,12 +575,12 @@ export const FileMentionAutocomplete = React.forwardRef + {item} ); })} - {visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && ( + {visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{t('chat.fileMentionAutocomplete.empty')}
diff --git a/packages/ui/src/components/chat/fileMentionResults.test.ts b/packages/ui/src/components/chat/fileMentionResults.test.ts new file mode 100644 index 00000000..f8f66131 --- /dev/null +++ b/packages/ui/src/components/chat/fileMentionResults.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; + +import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults'; + +const hit = (relativePath: string) => { + const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath; + return { + name, + path: `/root/${relativePath}`, + relativePath, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; +}; + +describe('tokenizeMentionQuery', () => { + test('normalizes leading ./ and slashes and splits on whitespace', () => { + expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']); + expect(tokenizeMentionQuery(' ')).toEqual([]); + }); +}); + +describe('mentionServerQuery', () => { + test('uses the longest token for the server search', () => { + expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a'); + expect(mentionServerQuery('')).toBe(''); + }); +}); + +describe('rankFileMentionResults', () => { + test('ranks files and directories together by match quality, not by category', () => { + const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')]; + const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')]; + + const ranked = rankFileMentionResults(files, directories, 'solo'); + const paths = ranked.map((entry) => entry.relativePath); + + expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']); + expect(paths).not.toContain('machine-learning/tensorflow/'); + }); + + test('multi-token queries match tokens in any order across the path', () => { + const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')]; + + const ranked = rankFileMentionResults(files, [], 'team solo'); + expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']); + }); + + test('tags each result with its kind', () => { + const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a'); + expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory'); + expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file'); + }); +}); diff --git a/packages/ui/src/components/chat/fileMentionResults.ts b/packages/ui/src/components/chat/fileMentionResults.ts new file mode 100644 index 00000000..c1b40d2d --- /dev/null +++ b/packages/ui/src/components/chat/fileMentionResults.ts @@ -0,0 +1,60 @@ +import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; +import type { ProjectFileSearchHit } from '@/lib/opencode/client'; + +export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' }; + +export const tokenizeMentionQuery = (query: string): string[] => + (query ?? '') + .trim() + .replace(/^\.\//, '') + .replace(/^\/+/, '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + +/** + * The opencode file search takes a single term, so multi-word queries send the + * most selective (longest) token and the remaining tokens filter client-side. + */ +export const mentionServerQuery = (query: string): string => { + const tokens = tokenizeMentionQuery(query); + if (tokens.length === 0) { + return ''; + } + return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest)); +}; + +/** + * Merge directory and file hits into one list ranked by match quality against + * the full relative path. Multi-token queries require every token to appear + * somewhere in the path, in any order. + */ +export function rankFileMentionResults( + files: ProjectFileSearchHit[], + directories: ProjectFileSearchHit[], + query: string, + limit = 20, +): FileMentionHit[] { + const merged: FileMentionHit[] = [ + ...directories.map((hit) => ({ ...hit, kind: 'directory' as const })), + ...files.map((hit) => ({ ...hit, kind: 'file' as const })), + ]; + + const tokens = tokenizeMentionQuery(query); + if (tokens.length === 0) { + return merged.slice(0, limit); + } + + const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name; + const candidates = tokens.length === 1 + ? merged + : merged.filter((hit) => { + const haystack = pathOf(hit).toLowerCase(); + return tokens.every((token) => haystack.includes(token)); + }); + + const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest)); + return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map( + (scored) => scored.item, + ); +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 393f43a8..90fc3337 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -66,29 +66,22 @@ export const truncatePathMiddle = ( return source; } - const prefixBudget = Math.max(0, maxLength - (fileName.length + 2)); - if (prefixBudget <= 0) { - return `…/${fileName}`; - } - - let prefix = ''; - for (const segment of segments) { + // Keep the segments closest to the file name: in trees full of index.md the + // parent directory is the distinguishing part, so drop leading segments. + let suffix = fileName; + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; if (!segment) { continue; } - const candidate = prefix ? `${prefix}/${segment}` : segment; - if (candidate.length > prefixBudget) { + const candidate = `${segment}/${suffix}`; + if (candidate.length + 2 > maxLength) { break; } - prefix = candidate; + suffix = candidate; } - if (!prefix) { - const first = segments[0] ?? ''; - prefix = first ? first.slice(0, prefixBudget) : ''; - } - - return prefix ? `${prefix}…/${fileName}` : `…/${fileName}`; + return `…/${suffix}`; }; const normalizePath = (value: string) => {