From 7f0592cccd60b41e1fb4a3ed7871849c4b15fb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 28 Jan 2026 22:17:29 +0000 Subject: [PATCH] feat: implement file referencing with @filename autocomplete in chat input (#118) --- .../f8829b44-c035-4de0-bd2e-9778dabf4782.md | 152 ++++++++++++++++++ .../agent/handlers/workspace_file_handlers.go | 45 ++++++ .../internal/agentctl/client/client.go | 42 ++++- .../internal/agentctl/server/api/server.go | 1 + .../internal/agentctl/server/api/workspace.go | 15 ++ .../server/process/workspace_tracker.go | 63 ++++++++ .../internal/agentctl/types/streams/file.go | 20 +++ apps/backend/internal/agentctl/types/types.go | 2 + apps/backend/pkg/websocket/actions.go | 1 + .../task/chat/messages/chat-message.tsx | 50 +++++- apps/web/components/task/task-chat-input.tsx | 4 +- apps/web/components/task/task-chat-panel.tsx | 1 + apps/web/hooks/use-chat-completions.ts | 99 ++++++++++-- apps/web/lib/types/backend.ts | 5 + apps/web/lib/ws/workspace-files.ts | 18 ++- 15 files changed, 497 insertions(+), 21 deletions(-) create mode 100644 .kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md diff --git a/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md b/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md new file mode 100644 index 0000000000..ec52f89de4 --- /dev/null +++ b/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md @@ -0,0 +1,152 @@ +# File Reference Feature Implementation Plan + +## Overview +Implement file referencing in chat input with `@filename` autocomplete. When a user types `@`, they should see autocomplete suggestions for files in the workspace. Selected files are passed as **path references** in the prompt - the agent can then read them from its workspace using its own tools. + +**Key insight**: The agent runs in the workspace where files exist. We don't need to embed file contents - just tell the agent which files the user is referring to. The agent can read them itself. + +## Requirements Analysis + +1. **Frontend: `@filename` Autocomplete** + - Detect `@` prefix followed by path characters in chat input + - Debounce search requests (only search when user stops typing ~300ms) + - Call backend to search/filter files in the workspace + - Display matching files as autocomplete options + - Insert selected file reference into the chat input (keep as `@path/to/file.ts`) + +2. **Backend: File Search Endpoint** + - New WebSocket action `workspace.files.search` for searching files + - Accept session_id (to route to correct agentctl) and query string + - Return matching file paths (fuzzy match) + +3. **Prompt Format** + - Keep `@filepath` references in the message as-is + - Agent sees: "Look at @src/main.ts and fix the bug" + - Agent uses its tools to read the file when needed + +## Architecture Flow + +``` +User types @main.ts → Frontend debounces 300ms → WS request workspace.files.search + ↓ + Backend routes to agentctl + ↓ + agentctl searches worktree (git ls-files) + ↓ + Returns matching files + ↓ + Frontend shows autocomplete + ↓ +User selects file → Insert @src/main.ts in input + ↓ +User submits → message.add with content "Look at @src/main.ts" + ↓ + Agent receives prompt, reads file using its tools +``` + +## Files to Modify/Create + +### Backend (Go) + +1. **`apps/backend/pkg/websocket/actions.go`** + - Add `ActionWorkspaceFilesSearch = "workspace.files.search"` + +2. **`apps/backend/internal/agentctl/server/process/workspace_tracker.go`** + - Add `SearchFiles(query string, limit int) ([]string, error)` method + - Use fuzzy/substring matching on cached file list from `git ls-files` + +3. **`apps/backend/internal/agentctl/server/api/workspace.go`** + - Add `handleFileSearch` HTTP endpoint: `GET /api/v1/workspace/search?q=&limit=` + +4. **`apps/backend/internal/agentctl/client/client.go`** + - Add `SearchFiles(ctx, query, limit) ([]string, error)` method + +5. **`apps/backend/internal/agent/handlers/workspace_file_handlers.go`** + - Add `wsSearchFiles` handler for `workspace.files.search` + +6. **`apps/backend/internal/agentctl/types/streams/file.go`** + - Add `FileSearchRequest` and `FileSearchResponse` types + +### Frontend (TypeScript) + +1. **`apps/web/hooks/use-chat-completions.ts`** + - Add file path completion alongside prompt completions + - Accept sessionId prop for file search context + - Use async completion source with debouncing + +2. **`apps/web/lib/ws/workspace-files.ts`** + - Add `searchWorkspaceFiles(client, sessionId, query, limit)` function + +3. **`apps/web/lib/types/backend.ts`** + - Add `FileSearchResponse` type + +4. **`apps/web/components/task/task-chat-input.tsx`** + - Pass sessionId to useChatCompletions hook + +5. **`apps/web/components/task/task-chat-panel.tsx`** + - Pass sessionId down to TaskChatInput + +## Implementation Approach + +### Phase 1: Backend File Search +1. Add `FileSearchResponse` type in `types/streams/file.go` +2. Add `SearchFiles` method in `workspace_tracker.go` (fuzzy match on git ls-files) +3. Add `handleFileSearch` HTTP endpoint in `workspace.go` +4. Add `SearchFiles` client method in `client.go` +5. Add WS action constant in `actions.go` +6. Add `wsSearchFiles` handler in `workspace_file_handlers.go` + +### Phase 2: Frontend Autocomplete +1. Add `searchWorkspaceFiles` function in `workspace-files.ts` +2. Add `FileSearchResponse` type in `backend.ts` +3. Extend `useChatCompletions` to accept sessionId and add file completions +4. Update `TaskChatInput` to receive and pass sessionId +5. Update `TaskChatPanel` to pass sessionId to input + +## Fuzzy Search Algorithm + +Simple substring matching with scoring: +- Exact filename match: highest score +- Filename contains query: high score +- Path contains query: medium score +- Sort by score, return top N results + +```go +func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { + query = strings.ToLower(query) + var matches []scoredMatch + + for _, file := range wt.currentFiles.Files { + path := strings.ToLower(file.Path) + name := filepath.Base(path) + + score := 0 + if name == query { score = 100 } + else if strings.Contains(name, query) { score = 50 } + else if strings.Contains(path, query) { score = 25 } + + if score > 0 { + matches = append(matches, scoredMatch{path: file.Path, score: score}) + } + } + + sort.Slice(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) + // Return top `limit` results +} +``` + +## Risks & Considerations + +1. **Performance**: Large repos could have many files. Solutions: + - Limit search results (default 20) + - Cache file list on workspace tracker (already done via git ls-files) + - Client-side debouncing (300ms) + +2. **Session Requirement**: File search requires an active session. Solutions: + - Only show file completions when sessionId is available + - Gracefully return empty results if no session + +3. **Concurrent Searches**: User typing fast could queue many searches. Solutions: + - Debouncing handles this naturally + - Each new search supersedes previous results + diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 8a7b5e19e1..33c8dc79b1 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -28,6 +28,7 @@ func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *Worksp func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree) d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent) + d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles) } // wsGetFileTree handles workspace.tree.get action @@ -106,3 +107,47 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me return ws.NewResponse(msg.ID, msg.Action, response) } + +// wsSearchFiles handles workspace.files.search action +func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + Query string `json:"query"` + Limit int `json:"limit"` + } + + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) + } + + // Get agentctl client + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) + } + + // Default limit + limit := req.Limit + if limit <= 0 { + limit = 20 + } + + // Search files via agentctl + response, err := client.SearchFiles(ctx, req.Query, limit) + if err != nil { + h.logger.Error("failed to search files", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to search files: %v", err), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, response) +} diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index ed738e8db4..6b3bfe4610 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "sync" "time" @@ -314,6 +315,12 @@ type stopProcessResponse struct { Error string `json:"error,omitempty"` } +// FileSearchResponse represents a response with matching files +type FileSearchResponse struct { + Files []string `json:"files"` + Error string `json:"error,omitempty"` +} + // RequestFileTree requests a file tree via HTTP GET func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) { url := fmt.Sprintf("%s/api/v1/workspace/tree?path=%s&depth=%d", c.baseURL, path, depth) @@ -345,11 +352,42 @@ func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (* return &response, nil } +// SearchFiles searches for files matching the query via HTTP GET +func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*FileSearchResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/search?q=%s&limit=%d", c.baseURL, url.QueryEscape(query), limit) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to search files: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file search response body", zap.Error(err)) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("file search failed with status %d", resp.StatusCode) + } + + var result FileSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + // RequestFileContent requests file content via HTTP GET func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) { - url := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, path) + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, path) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 2f864557ca..4e31cb5a78 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -82,6 +82,7 @@ func (s *Server) setupRoutes() { // Workspace file operations (simple HTTP) api.GET("/workspace/tree", s.handleFileTree) api.GET("/workspace/file/content", s.handleFileContent) + api.GET("/workspace/search", s.handleFileSearch) // Shell access (HTTP endpoints only - streaming is via /workspace/stream) api.GET("/shell/status", s.handleShellStatus) diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 77cf884344..579c2ac138 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -151,6 +151,21 @@ func (s *Server) handleFileContent(c *gin.Context) { c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size}) } +// handleFileSearch handles file search requests via HTTP GET +func (s *Server) handleFileSearch(c *gin.Context) { + query := c.Query("q") + limit := 20 + if l := c.Query("limit"); l != "" { + if parsed := mustParseInt(l); parsed > 0 { + limit = parsed + } + } + + files := s.procMgr.GetWorkspaceTracker().SearchFiles(query, limit) + + c.JSON(200, types.FileSearchResponse{Files: files}) +} + // mustParseInt parses a string to int, returns 0 on error func mustParseInt(s string) int { var n int diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 5ccc9054a6..e177e2d046 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -1183,3 +1184,65 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error return string(content), info.Size(), nil } + +// scoredMatch holds a file path and its match score for sorting +type scoredMatch struct { + path string + score int +} + +// SearchFiles searches for files matching the query string. +// It uses fuzzy matching with scoring based on how well the query matches. +func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { + if query == "" { + return []string{} + } + if limit <= 0 { + limit = 20 + } + + query = strings.ToLower(query) + var matches []scoredMatch + + wt.mu.RLock() + files := wt.currentFiles.Files + wt.mu.RUnlock() + + for _, file := range files { + path := file.Path + lowerPath := strings.ToLower(path) + name := filepath.Base(lowerPath) + + score := 0 + if name == query { + score = 100 // Exact filename match + } else if strings.HasPrefix(name, query) { + score = 75 // Filename starts with query + } else if strings.Contains(name, query) { + score = 50 // Filename contains query + } else if strings.Contains(lowerPath, query) { + score = 25 // Path contains query + } + + if score > 0 { + matches = append(matches, scoredMatch{path: path, score: score}) + } + } + + // Sort by score descending + sort.Slice(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score > matches[j].score + } + // Secondary sort by path length (shorter paths first) + return len(matches[i].path) < len(matches[j].path) + }) + + // Return top limit results + result := make([]string, 0, limit) + for i := 0; i < len(matches) && i < limit; i++ { + result = append(result, matches[i].path) + } + + return result +} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index 043776ac5e..ae76db2588 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -128,3 +128,23 @@ type FileContentResponse struct { Error string `json:"error,omitempty"` } +// FileSearchRequest represents a request to search for files. +// +// HTTP endpoint: GET /api/v1/workspace/search +type FileSearchRequest struct { + // Query is the search query (partial filename or path). + Query string `json:"query"` + + // Limit is the maximum number of results to return. + Limit int `json:"limit"` +} + +// FileSearchResponse represents a response with matching files. +type FileSearchResponse struct { + // Files is the list of matching file paths. + Files []string `json:"files"` + + // Error contains error message if the request failed. + Error string `json:"error,omitempty"` +} + diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index b5fba3797c..09f20b8b80 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -39,6 +39,8 @@ type ( FileTreeResponse = streams.FileTreeResponse FileContentRequest = streams.FileContentRequest FileContentResponse = streams.FileContentResponse + FileSearchRequest = streams.FileSearchRequest + FileSearchResponse = streams.FileSearchResponse // Shell stream types ShellMessage = streams.ShellMessage diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 0f4ca0db0c..b774aab530 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -161,6 +161,7 @@ const ( // Workspace file operations ActionWorkspaceFileTreeGet = "workspace.tree.get" ActionWorkspaceFileContentGet = "workspace.file.get" + ActionWorkspaceFilesSearch = "workspace.files.search" ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification // Shell actions diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index f4149c4124..d58dcec7d8 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -16,6 +16,48 @@ type ChatMessageProps = { showRichBlocks?: boolean; }; +// Regex to match @file references (file paths after @) +// Matches @path/to/file.ext or @file.ext patterns +const FILE_REF_REGEX = /@([\w./-]+\.[\w]+|[\w/-]+)/g; + +/** + * Renders content with file references highlighted in code style + */ +function renderContentWithFileRefs(content: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + let keyIndex = 0; + + FILE_REF_REGEX.lastIndex = 0; // Reset regex state + while ((match = FILE_REF_REGEX.exec(content)) !== null) { + // Add text before the match + if (match.index > lastIndex) { + parts.push(content.slice(lastIndex, match.index)); + } + + // Add the file reference with code styling + const filePath = match[1]; + parts.push( + + @{filePath} + + ); + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last match + if (lastIndex < content.length) { + parts.push(content.slice(lastIndex)); + } + + return parts.length > 0 ? parts : [content]; +} + export function ChatMessage({ comment, label, className, showRichBlocks }: ChatMessageProps) { const isUser = comment.author_type === 'user'; const isTaskDescription = label === 'Task'; @@ -33,7 +75,9 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM ) : null}

-

{comment.content || '(empty)'}

+

+ {comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'} +

); } @@ -44,7 +88,9 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
-

{comment.content || '(empty)'}

+

+ {comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'} +

diff --git a/apps/web/components/task/task-chat-input.tsx b/apps/web/components/task/task-chat-input.tsx index bb27bde77e..d982c5483a 100644 --- a/apps/web/components/task/task-chat-input.tsx +++ b/apps/web/components/task/task-chat-input.tsx @@ -21,6 +21,7 @@ type TaskChatInputProps = { disabled?: boolean; className?: string; planModeEnabled?: boolean; + sessionId?: string | null; }; export function TaskChatInput({ @@ -31,9 +32,10 @@ export function TaskChatInput({ disabled, className, planModeEnabled, + sessionId, }: TaskChatInputProps) { const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT); - const completionSource = useChatCompletions(); + const completionSource = useChatCompletions(sessionId); const handleSubmitKey = () => { onSubmit(); diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index bbcef7e2f1..16aa10e6a1 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -189,6 +189,7 @@ export function TaskChatPanel({ : 'Write to submit work to the agent...' } planModeEnabled={planModeEnabled} + sessionId={resolvedSessionId} /> { 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 | 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 => { + 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 => { // 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((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; } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index e22b6cace1..be93220c07 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -361,6 +361,11 @@ export type FileContentResponse = { error?: string; }; +export type FileSearchResponse = { + files: string[]; + error?: string; +}; + // Single file change event (used within batched notifications) export type FileChangeEvent = { timestamp: string; diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index 05941e5708..fe0cb3a97e 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -1,5 +1,5 @@ import type { WebSocketClient } from './client'; -import type { FileTreeResponse, FileContentResponse } from '@/lib/types/backend'; +import type { FileTreeResponse, FileContentResponse, FileSearchResponse } from '@/lib/types/backend'; /** * Request file tree from the backend @@ -30,3 +30,19 @@ export async function requestFileContent( path, }); } + +/** + * Search for files in the workspace matching a query + */ +export async function searchWorkspaceFiles( + client: WebSocketClient, + sessionId: string, + query: string, + limit: number = 20 +): Promise { + return client.request('workspace.files.search', { + session_id: sessionId, + query, + limit, + }); +}