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) {
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user