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)'} +