feat: implement file referencing with @filename autocomplete in chat input (#118)
This commit is contained in:
@@ -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) {
|
func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||||
d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree)
|
d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree)
|
||||||
d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent)
|
d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent)
|
||||||
|
d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsGetFileTree handles workspace.tree.get action
|
// 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)
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -314,6 +315,12 @@ type stopProcessResponse struct {
|
|||||||
Error string `json:"error,omitempty"`
|
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
|
// RequestFileTree requests a file tree via HTTP GET
|
||||||
func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) {
|
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)
|
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
|
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
|
// RequestFileContent requests file content via HTTP GET
|
||||||
func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func (s *Server) setupRoutes() {
|
|||||||
// Workspace file operations (simple HTTP)
|
// Workspace file operations (simple HTTP)
|
||||||
api.GET("/workspace/tree", s.handleFileTree)
|
api.GET("/workspace/tree", s.handleFileTree)
|
||||||
api.GET("/workspace/file/content", s.handleFileContent)
|
api.GET("/workspace/file/content", s.handleFileContent)
|
||||||
|
api.GET("/workspace/search", s.handleFileSearch)
|
||||||
|
|
||||||
// Shell access (HTTP endpoints only - streaming is via /workspace/stream)
|
// Shell access (HTTP endpoints only - streaming is via /workspace/stream)
|
||||||
api.GET("/shell/status", s.handleShellStatus)
|
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})
|
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
|
// mustParseInt parses a string to int, returns 0 on error
|
||||||
func mustParseInt(s string) int {
|
func mustParseInt(s string) int {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -1183,3 +1184,65 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error
|
|||||||
|
|
||||||
return string(content), info.Size(), nil
|
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"`
|
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
|
FileTreeResponse = streams.FileTreeResponse
|
||||||
FileContentRequest = streams.FileContentRequest
|
FileContentRequest = streams.FileContentRequest
|
||||||
FileContentResponse = streams.FileContentResponse
|
FileContentResponse = streams.FileContentResponse
|
||||||
|
FileSearchRequest = streams.FileSearchRequest
|
||||||
|
FileSearchResponse = streams.FileSearchResponse
|
||||||
|
|
||||||
// Shell stream types
|
// Shell stream types
|
||||||
ShellMessage = streams.ShellMessage
|
ShellMessage = streams.ShellMessage
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ const (
|
|||||||
// Workspace file operations
|
// Workspace file operations
|
||||||
ActionWorkspaceFileTreeGet = "workspace.tree.get"
|
ActionWorkspaceFileTreeGet = "workspace.tree.get"
|
||||||
ActionWorkspaceFileContentGet = "workspace.file.get"
|
ActionWorkspaceFileContentGet = "workspace.file.get"
|
||||||
|
ActionWorkspaceFilesSearch = "workspace.files.search"
|
||||||
ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification
|
ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification
|
||||||
|
|
||||||
// Shell actions
|
// Shell actions
|
||||||
|
|||||||
@@ -16,6 +16,48 @@ type ChatMessageProps = {
|
|||||||
showRichBlocks?: boolean;
|
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) {
|
export function ChatMessage({ comment, label, className, showRichBlocks }: ChatMessageProps) {
|
||||||
const isUser = comment.author_type === 'user';
|
const isUser = comment.author_type === 'user';
|
||||||
const isTaskDescription = label === 'Task';
|
const isTaskDescription = label === 'Task';
|
||||||
@@ -33,7 +75,9 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap">{comment.content || '(empty)'}</p>
|
<p className="whitespace-pre-wrap">
|
||||||
|
{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,7 +88,9 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
|
|||||||
<div className="flex justify-end w-full">
|
<div className="flex justify-end w-full">
|
||||||
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type TaskChatInputProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
planModeEnabled?: boolean;
|
planModeEnabled?: boolean;
|
||||||
|
sessionId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskChatInput({
|
export function TaskChatInput({
|
||||||
@@ -31,9 +32,10 @@ export function TaskChatInput({
|
|||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
planModeEnabled,
|
planModeEnabled,
|
||||||
|
sessionId,
|
||||||
}: TaskChatInputProps) {
|
}: TaskChatInputProps) {
|
||||||
const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT);
|
const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT);
|
||||||
const completionSource = useChatCompletions();
|
const completionSource = useChatCompletions(sessionId);
|
||||||
|
|
||||||
const handleSubmitKey = () => {
|
const handleSubmitKey = () => {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function TaskChatPanel({
|
|||||||
: 'Write to submit work to the agent...'
|
: 'Write to submit work to the agent...'
|
||||||
}
|
}
|
||||||
planModeEnabled={planModeEnabled}
|
planModeEnabled={planModeEnabled}
|
||||||
|
sessionId={resolvedSessionId}
|
||||||
/>
|
/>
|
||||||
<ChatToolbar
|
<ChatToolbar
|
||||||
sessionId={resolvedSessionId}
|
sessionId={resolvedSessionId}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useRef, useCallback } from 'react';
|
||||||
import { EditorView } from '@codemirror/view';
|
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 { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||||
import type { CustomPrompt } from '@/lib/types/http';
|
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) => {
|
const truncatePreview = (text: string, max = 140) => {
|
||||||
if (text.length <= max) return text;
|
if (text.length <= max) return text;
|
||||||
return `${text.slice(0, max)}…`;
|
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 { prompts } = useCustomPrompts();
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastSearchRef = useRef<{ query: string; results: string[] }>({ query: '', results: [] });
|
||||||
|
|
||||||
const promptOptions = useMemo(() => {
|
const promptOptions = useMemo(() => {
|
||||||
return prompts.map((prompt: CustomPrompt) => ({
|
return prompts.map((prompt: CustomPrompt) => ({
|
||||||
@@ -20,8 +27,33 @@ export function useChatCompletions() {
|
|||||||
}));
|
}));
|
||||||
}, [prompts]);
|
}, [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(() => {
|
const completionSource = useMemo(() => {
|
||||||
return (context: CompletionContext) => {
|
return async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||||
// Slash command completions
|
// Slash command completions
|
||||||
const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/);
|
const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/);
|
||||||
if (slashMatch) {
|
if (slashMatch) {
|
||||||
@@ -29,27 +61,32 @@ export function useChatCompletions() {
|
|||||||
return {
|
return {
|
||||||
from,
|
from,
|
||||||
options: [
|
options: [
|
||||||
{ label: '/plan', type: 'slash', info: 'Start a planning response' },
|
{ label: '/plan', type: 'keyword', info: 'Start a planning response' },
|
||||||
{ label: '/todo', type: 'slash', info: 'Add a todo list' },
|
{ label: '/todo', type: 'keyword', info: 'Add a todo list' },
|
||||||
{ label: '/review', type: 'slash', info: 'Request a review' },
|
{ label: '/review', type: 'keyword', info: 'Request a review' },
|
||||||
{ label: '/summarize', type: 'slash', info: 'Summarize the task' },
|
{ label: '/summarize', type: 'keyword', info: 'Summarize the task' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// @prompt mention completions
|
// @ mention completions (prompts and files)
|
||||||
const mentionMatch = context.matchBefore(/(^|\s)@[\w-]*/);
|
const mentionMatch = context.matchBefore(/(^|\s)@[\w.\/\-_]*/);
|
||||||
if (mentionMatch) {
|
if (mentionMatch) {
|
||||||
const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1);
|
const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1);
|
||||||
const trimmed = mentionMatch.text.trim();
|
const trimmed = mentionMatch.text.trim();
|
||||||
const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase();
|
const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase();
|
||||||
|
|
||||||
|
// Prompt completions (existing behavior)
|
||||||
const promptMatches = promptOptions
|
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 }) => ({
|
.map((prompt: { id: string; name: string; content: string }) => ({
|
||||||
label: `@${prompt.name}`,
|
label: `@${prompt.name}`,
|
||||||
type: 'prompt',
|
type: 'text',
|
||||||
detail: 'Prompt',
|
detail: 'Prompt',
|
||||||
info: truncatePreview(prompt.content),
|
info: truncatePreview(prompt.content),
|
||||||
|
boost: 10, // Prompts have higher priority
|
||||||
apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => {
|
apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => {
|
||||||
const insertText = `${prompt.content}\n`;
|
const insertText = `${prompt.content}\n`;
|
||||||
const cursorPos = applyFrom + insertText.length;
|
const cursorPos = applyFrom + insertText.length;
|
||||||
@@ -60,16 +97,48 @@ export function useChatCompletions() {
|
|||||||
view.dispatch({ effects: EditorView.scrollIntoView(cursorPos) });
|
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 {
|
return {
|
||||||
from,
|
from,
|
||||||
// Future: add file path completions alongside prompts using additional providers.
|
options: [...promptMatches, ...fileMatches],
|
||||||
options: [...promptMatches],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
}, [promptOptions]);
|
}, [promptOptions, sessionId, searchFiles]);
|
||||||
|
|
||||||
return completionSource;
|
return completionSource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,11 @@ export type FileContentResponse = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileSearchResponse = {
|
||||||
|
files: string[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Single file change event (used within batched notifications)
|
// Single file change event (used within batched notifications)
|
||||||
export type FileChangeEvent = {
|
export type FileChangeEvent = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WebSocketClient } from './client';
|
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
|
* Request file tree from the backend
|
||||||
@@ -30,3 +30,19 @@ export async function requestFileContent(
|
|||||||
path,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user