feat: implement file referencing with @filename autocomplete in chat input (#118)

This commit is contained in:
João Salavisa
2026-01-28 22:17:29 +00:00
committed by GitHub
parent 83e3429089
commit 7f0592cccd
15 changed files with 497 additions and 21 deletions
+84 -15
View File
@@ -1,16 +1,23 @@
import { useMemo } from 'react';
import { useMemo, useRef, useCallback } from 'react';
import { EditorView } from '@codemirror/view';
import type { CompletionContext } from '@codemirror/autocomplete';
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
import type { CustomPrompt } from '@/lib/types/http';
import { getWebSocketClient } from '@/lib/ws/connection';
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
const truncatePreview = (text: string, max = 140) => {
if (text.length <= max) return text;
return `${text.slice(0, max)}…`;
};
export function useChatCompletions() {
// Debounce delay for file search (ms)
const FILE_SEARCH_DEBOUNCE = 300;
export function useChatCompletions(sessionId?: string | null) {
const { prompts } = useCustomPrompts();
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSearchRef = useRef<{ query: string; results: string[] }>({ query: '', results: [] });
const promptOptions = useMemo(() => {
return prompts.map((prompt: CustomPrompt) => ({
@@ -20,8 +27,33 @@ export function useChatCompletions() {
}));
}, [prompts]);
// File search function with caching
const searchFiles = useCallback(async (query: string): Promise<string[]> => {
if (!sessionId || !query) return [];
// Return cached results if query matches
if (lastSearchRef.current.query === query) {
return lastSearchRef.current.results;
}
try {
const client = getWebSocketClient();
if (!client) return [];
const response = await searchWorkspaceFiles(client, sessionId, query, 20);
const results = response.files || [];
// Cache results
lastSearchRef.current = { query, results };
return results;
} catch (error) {
console.error('File search failed:', error);
return [];
}
}, [sessionId]);
const completionSource = useMemo(() => {
return (context: CompletionContext) => {
return async (context: CompletionContext): Promise<CompletionResult | null> => {
// Slash command completions
const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/);
if (slashMatch) {
@@ -29,27 +61,32 @@ export function useChatCompletions() {
return {
from,
options: [
{ label: '/plan', type: 'slash', info: 'Start a planning response' },
{ label: '/todo', type: 'slash', info: 'Add a todo list' },
{ label: '/review', type: 'slash', info: 'Request a review' },
{ label: '/summarize', type: 'slash', info: 'Summarize the task' },
{ label: '/plan', type: 'keyword', info: 'Start a planning response' },
{ label: '/todo', type: 'keyword', info: 'Add a todo list' },
{ label: '/review', type: 'keyword', info: 'Request a review' },
{ label: '/summarize', type: 'keyword', info: 'Summarize the task' },
],
};
}
// @prompt mention completions
const mentionMatch = context.matchBefore(/(^|\s)@[\w-]*/);
// @ mention completions (prompts and files)
const mentionMatch = context.matchBefore(/(^|\s)@[\w.\/\-_]*/);
if (mentionMatch) {
const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1);
const trimmed = mentionMatch.text.trim();
const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase();
// Prompt completions (existing behavior)
const promptMatches = promptOptions
.filter((prompt: { id: string; name: string; content: string }) => prompt.name.toLowerCase().startsWith(query))
.filter((prompt: { id: string; name: string; content: string }) =>
prompt.name.toLowerCase().startsWith(query)
)
.map((prompt: { id: string; name: string; content: string }) => ({
label: `@${prompt.name}`,
type: 'prompt',
type: 'text',
detail: 'Prompt',
info: truncatePreview(prompt.content),
boost: 10, // Prompts have higher priority
apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => {
const insertText = `${prompt.content}\n`;
const cursorPos = applyFrom + insertText.length;
@@ -60,16 +97,48 @@ export function useChatCompletions() {
view.dispatch({ effects: EditorView.scrollIntoView(cursorPos) });
},
}));
// File completions (only if sessionId is available and query has content)
let fileMatches: Array<{
label: string;
type: string;
detail: string;
info: string;
boost: number;
}> = [];
if (sessionId && query.length > 0) {
// Cancel previous search timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Debounced file search
const files = await new Promise<string[]>((resolve) => {
searchTimeoutRef.current = setTimeout(async () => {
const results = await searchFiles(query);
resolve(results);
}, FILE_SEARCH_DEBOUNCE);
});
fileMatches = files.map((filePath) => ({
label: `@${filePath}`,
type: 'variable',
detail: 'File',
info: filePath,
boost: 5, // Files have lower priority than prompts
}));
}
return {
from,
// Future: add file path completions alongside prompts using additional providers.
options: [...promptMatches],
options: [...promptMatches, ...fileMatches],
};
}
return null;
};
}, [promptOptions]);
}, [promptOptions, sessionId, searchFiles]);
return completionSource;
}