From 0918bee5661515fa8cfc3cbd12249a37c822a3ca Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:22:40 +0300 Subject: [PATCH] fix(chat): rank @ mention files and directories together by match quality Directories and files were rendered as fixed category blocks, so an exact file match sat below unrelated directories. Merge both result sets and rank them with the shared fuzzy scorer against the full relative path. Multi-word queries now match tokens in any order (longest token queries the server, the rest filter client-side), and path truncation keeps the parent segments next to the file name so index.md-heavy trees stay distinguishable. --- .../chat/FileMentionAutocomplete.tsx | 100 ++++++------------ .../chat/fileMentionResults.test.ts | 53 ++++++++++ .../src/components/chat/fileMentionResults.ts | 60 +++++++++++ packages/ui/src/lib/utils.ts | 25 ++--- 4 files changed, 153 insertions(+), 85 deletions(-) create mode 100644 packages/ui/src/components/chat/fileMentionResults.test.ts create mode 100644 packages/ui/src/components/chat/fileMentionResults.ts 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) => {