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
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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)
@@ -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
@@ -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
}
@@ -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"`
}
@@ -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
+1
View File
@@ -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
@@ -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(
<code
key={`file-ref-${keyIndex++}`}
className="px-1 py-0.5 bg-emerald-500/20 text-emerald-400 rounded font-mono text-[0.9em]"
>
@{filePath}
</code>
);
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}
</p>
</div>
<p className="whitespace-pre-wrap">{comment.content || '(empty)'}</p>
<p className="whitespace-pre-wrap">
{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}
</p>
</div>
);
}
@@ -44,7 +88,9 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
<div className="flex justify-end w-full">
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl">
<div className="rounded-2xl border-primary/30 bg-primary/10 px-4 py-2.5 text-xs">
<p className="whitespace-pre-wrap">{comment.content || '(empty)'}</p>
<p className="whitespace-pre-wrap">
{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}
</p>
</div>
</div>
</div>
+3 -1
View File
@@ -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();
@@ -189,6 +189,7 @@ export function TaskChatPanel({
: 'Write to submit work to the agent...'
}
planModeEnabled={planModeEnabled}
sessionId={resolvedSessionId}
/>
<ChatToolbar
sessionId={resolvedSessionId}
+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;
}
+5
View File
@@ -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;
+17 -1
View File
@@ -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<FileSearchResponse> {
return client.request<FileSearchResponse>('workspace.files.search', {
session_id: sessionId,
query,
limit,
});
}