diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go
index a6d2dda214..87916edf9e 100644
--- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go
+++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go
@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"strings"
+ "unicode/utf8"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
@@ -18,6 +20,12 @@ type WorkspaceFileHandlers struct {
logger *logger.Logger
}
+type workspaceContentSearchRequest struct {
+ SessionID string `json:"session_id"`
+ Query string `json:"query"`
+ LimitPerRepo int `json:"limit_per_repo"`
+}
+
// NewWorkspaceFileHandlers creates new workspace file handlers
func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *WorkspaceFileHandlers {
return &WorkspaceFileHandlers{
@@ -36,6 +44,7 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile)
d.RegisterFunc(ws.ActionWorkspaceFileRename, h.wsRenameFile)
d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles)
+ d.RegisterFunc(ws.ActionWorkspaceContentSearch, h.wsSearchContent)
}
// wsGetFileTree handles workspace.tree.get action
@@ -327,6 +336,68 @@ func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Messa
return ws.NewResponse(msg.ID, msg.Action, response)
}
+// wsSearchContent handles workspace.content.search.
+func (h *WorkspaceFileHandlers) wsSearchContent(
+ ctx context.Context,
+ msg *ws.Message,
+) (*ws.Message, error) {
+ req, err := decodeWorkspaceContentSearchRequest(msg)
+ if 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,
+ )
+ }
+ if utf8.RuneCountInString(req.Query) > streams.WorkspaceContentSearchMaxQueryRunes {
+ return ws.NewError(
+ msg.ID, msg.Action, ws.ErrorCodeValidation,
+ "query exceeds 200 characters", nil,
+ )
+ }
+
+ execution, err := h.lifecycle.GetOrEnsureExecution(ctx, req.SessionID)
+ if err != nil {
+ return ws.NewError(
+ msg.ID, msg.Action, ws.ErrorCodeNotFound,
+ "No agent found for session: "+err.Error(), nil,
+ )
+ }
+ client := execution.GetAgentCtlClient()
+ if client == nil {
+ return ws.NewError(
+ msg.ID, msg.Action, ws.ErrorCodeInternalError,
+ "Agent client not available", nil,
+ )
+ }
+ response, err := client.SearchWorkspaceContent(ctx, req.Query, req.LimitPerRepo)
+ if err != nil {
+ h.logger.Error(
+ "failed to search workspace content",
+ zap.Error(err),
+ zap.String("session_id", req.SessionID),
+ )
+ return ws.NewError(
+ msg.ID, msg.Action, ws.ErrorCodeInternalError,
+ fmt.Sprintf("Failed to search workspace content: %v", err), nil,
+ )
+ }
+ return ws.NewResponse(msg.ID, msg.Action, response)
+}
+
+func decodeWorkspaceContentSearchRequest(
+ msg *ws.Message,
+) (workspaceContentSearchRequest, error) {
+ var request workspaceContentSearchRequest
+ err := msg.ParsePayload(&request)
+ return request, err
+}
+
// wsRenameFile handles workspace.file.rename action
func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go
new file mode 100644
index 0000000000..b8b4a21600
--- /dev/null
+++ b/apps/backend/internal/agent/handlers/workspace_file_handlers_test.go
@@ -0,0 +1,86 @@
+package handlers
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ ws "github.com/kandev/kandev/pkg/websocket"
+)
+
+func TestWorkspaceFileHandlersRegisterContentSearch(t *testing.T) {
+ handler := NewWorkspaceFileHandlers(nil, newTestLogger())
+ dispatcher := ws.NewDispatcher()
+ handler.RegisterHandlers(dispatcher)
+
+ if !dispatcher.HasHandler(ws.ActionWorkspaceContentSearch) {
+ t.Fatal("workspace.content.search handler was not registered")
+ }
+}
+
+func TestWorkspaceContentSearchRequiresSessionID(t *testing.T) {
+ handler := NewWorkspaceFileHandlers(nil, newTestLogger())
+ msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{
+ "query": "needle",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ response, err := handler.wsSearchContent(context.Background(), msg)
+ if err != nil {
+ t.Fatalf("wsSearchContent returned error: %v", err)
+ }
+ assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeValidation)
+}
+
+func TestWorkspaceContentSearchRejectsLongQueryBeforeLifecycleLookup(t *testing.T) {
+ handler := NewWorkspaceFileHandlers(nil, newTestLogger())
+ msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{
+ "session_id": "session-1",
+ "query": strings.Repeat("界", 201),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ response, err := handler.wsSearchContent(context.Background(), msg)
+ if err != nil {
+ t.Fatalf("wsSearchContent returned error: %v", err)
+ }
+ assertWorkspaceContentSearchErrorCode(t, response, ws.ErrorCodeValidation)
+}
+
+func TestDecodeWorkspaceContentSearchRequestUsesPerRepositoryLimit(t *testing.T) {
+ msg, err := ws.NewRequest("request-1", ws.ActionWorkspaceContentSearch, map[string]any{
+ "session_id": "session-1",
+ "query": "needle",
+ "limit_per_repo": 37,
+ "limit": 9,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ request, err := decodeWorkspaceContentSearchRequest(msg)
+ if err != nil {
+ t.Fatalf("decodeWorkspaceContentSearchRequest returned error: %v", err)
+ }
+ if request.LimitPerRepo != 37 {
+ t.Fatalf("limit_per_repo = %d, want 37", request.LimitPerRepo)
+ }
+}
+
+func assertWorkspaceContentSearchErrorCode(t *testing.T, response *ws.Message, want string) {
+ t.Helper()
+ if response == nil || response.Type != ws.MessageTypeError {
+ t.Fatalf("response = %#v, want error message", response)
+ }
+ var payload ws.ErrorPayload
+ if err := response.ParsePayload(&payload); err != nil {
+ t.Fatalf("parse error payload: %v", err)
+ }
+ if payload.Code != want {
+ t.Fatalf("error code = %q, want %q", payload.Code, want)
+ }
+}
diff --git a/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go b/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go
new file mode 100644
index 0000000000..586a57a8f5
--- /dev/null
+++ b/apps/backend/internal/agent/runtime/agentctl/client_content_search_test.go
@@ -0,0 +1,58 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSearchWorkspaceContentUsesDedicatedEndpointAndDecodesMatches(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/workspace/content-search" {
+ t.Fatalf("request = %s %s", r.Method, r.URL.Path)
+ }
+ if got := r.URL.Query().Get("q"); got != "hello world" {
+ t.Fatalf("query = %q, want %q", got, "hello world")
+ }
+ if got := r.URL.Query().Get("limit_per_repo"); got != "12" {
+ t.Fatalf("limit_per_repo = %q, want 12", got)
+ }
+ if legacy := r.URL.Query().Get("limit"); legacy != "" {
+ t.Fatalf("legacy limit query unexpectedly set to %q", legacy)
+ }
+ _ = json.NewEncoder(w).Encode(WorkspaceContentSearchResponse{
+ Results: []WorkspaceContentSearchResult{{
+ RepositoryName: "backend",
+ Path: "main.go",
+ Line: 9,
+ Column: 4,
+ Preview: "say hello world",
+ MatchRanges: []WorkspaceContentMatchRange{{Start: 4, End: 15}},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ client := &Client{baseURL: server.URL, httpClient: server.Client(), logger: newTestLogger()}
+ response, err := client.SearchWorkspaceContent(context.Background(), "hello world", 12)
+ if err != nil {
+ t.Fatalf("SearchWorkspaceContent returned error: %v", err)
+ }
+ if len(response.Results) != 1 || response.Results[0].RepositoryName != "backend" {
+ t.Fatalf("response = %#v", response)
+ }
+}
+
+func TestSearchWorkspaceContentRejectsNonOKResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "bad query", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ client := &Client{baseURL: server.URL, httpClient: server.Client(), logger: newTestLogger()}
+ if _, err := client.SearchWorkspaceContent(context.Background(), "needle", 10); err == nil {
+ t.Fatal("SearchWorkspaceContent returned nil error for non-OK response")
+ }
+}
diff --git a/apps/backend/internal/agent/runtime/agentctl/client_files.go b/apps/backend/internal/agent/runtime/agentctl/client_files.go
index d59f77f095..f86f89dda2 100644
--- a/apps/backend/internal/agent/runtime/agentctl/client_files.go
+++ b/apps/backend/internal/agent/runtime/agentctl/client_files.go
@@ -80,6 +80,45 @@ func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*Fil
return &result, nil
}
+// SearchWorkspaceContent searches matching lines in every task repository.
+func (c *Client) SearchWorkspaceContent(
+ ctx context.Context,
+ query string,
+ limit int,
+) (*WorkspaceContentSearchResponse, error) {
+ reqURL := fmt.Sprintf(
+ "%s/api/v1/workspace/content-search?q=%s&limit_per_repo=%d",
+ c.baseURL,
+ url.QueryEscape(query),
+ limit,
+ )
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create content search request: %w", err)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to search workspace content: %w", err)
+ }
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ c.logger.Debug("failed to close content search response body", zap.Error(err))
+ }
+ }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("workspace content search failed with status %d", resp.StatusCode)
+ }
+
+ var result WorkspaceContentSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("failed to decode content search response: %w", err)
+ }
+ if result.Error != "" {
+ return nil, fmt.Errorf("workspace content search error: %s", result.Error)
+ }
+ return &result, nil
+}
+
// RequestFileContent requests file content via HTTP GET.
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
func (c *Client) RequestFileContent(ctx context.Context, path, repo string) (*FileContentResponse, error) {
@@ -324,11 +363,13 @@ func (c *Client) RenameFile(ctx context.Context, oldPath, newPath, repo string)
return &response, nil
}
-// FileSearchResponse represents a response with matching files
-type FileSearchResponse struct {
- Files []string `json:"files"`
- Error string `json:"error,omitempty"`
-}
+type (
+ FileSearchResult = streams.FileSearchResult
+ FileSearchResponse = streams.FileSearchResponse
+ WorkspaceContentMatchRange = streams.WorkspaceContentMatchRange
+ WorkspaceContentSearchResult = streams.WorkspaceContentSearchResult
+ WorkspaceContentSearchResponse = streams.WorkspaceContentSearchResponse
+)
// CopyFilesRequest is the body for POST /workspace/copy-files. Mirrors the
// agentctl-side api.CopyFilesRequest shape — kept local rather than imported
diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go
index 3113add732..1713917f01 100644
--- a/apps/backend/internal/agentctl/server/api/server.go
+++ b/apps/backend/internal/agentctl/server/api/server.go
@@ -132,6 +132,7 @@ func (s *Server) setupRoutes() {
api.POST("/workspace/file/rename", s.handleFileRename)
api.DELETE("/workspace/file", s.handleFileDelete)
api.GET("/workspace/search", s.handleFileSearch)
+ api.GET("/workspace/content-search", s.handleWorkspaceContentSearch)
// Batched copy of files from the host (used by remote executors —
// Docker, Sprites — to seed the workspace with gitignored config
diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go
index 44b369ea7f..de766736bf 100644
--- a/apps/backend/internal/agentctl/server/api/workspace.go
+++ b/apps/backend/internal/agentctl/server/api/workspace.go
@@ -2,10 +2,13 @@ package api
import (
"encoding/json"
+ "errors"
"io"
+ "net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
+ "github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
@@ -232,9 +235,35 @@ func (s *Server) handleFileSearch(c *gin.Context) {
}
}
- files := s.procMgr.GetWorkspaceTracker().SearchFiles(query, limit)
+ results := s.procMgr.SearchWorkspaceFileResults(query, limit)
+ files := make([]string, 0, len(results))
+ for _, result := range results {
+ files = append(files, result.Path)
+ }
- c.JSON(200, types.FileSearchResponse{Files: files})
+ c.JSON(200, types.FileSearchResponse{Files: files, Results: results})
+}
+
+// handleWorkspaceContentSearch searches matching lines across every task repository.
+func (s *Server) handleWorkspaceContentSearch(c *gin.Context) {
+ query := c.Query("q")
+ limit := 20
+ if rawLimit := c.Query("limit_per_repo"); rawLimit != "" {
+ if parsed := mustParseInt(rawLimit); parsed > 0 {
+ limit = parsed
+ }
+ }
+
+ response, err := s.procMgr.SearchWorkspaceContent(c.Request.Context(), query, limit)
+ if err == nil {
+ c.JSON(http.StatusOK, response)
+ return
+ }
+ if errors.Is(err, process.ErrContentSearchQueryTooLong) {
+ c.JSON(http.StatusBadRequest, types.WorkspaceContentSearchResponse{Error: err.Error()})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, types.WorkspaceContentSearchResponse{Error: err.Error()})
}
// handleFileCreate handles file create requests via HTTP POST.
diff --git a/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go b/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go
new file mode 100644
index 0000000000..95dd080c28
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/api/workspace_content_search_test.go
@@ -0,0 +1,69 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agentctl/server/config"
+ "github.com/kandev/kandev/internal/agentctl/server/process"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+)
+
+func TestHandleWorkspaceContentSearchReturnsTypedMatches(t *testing.T) {
+ repoDir := t.TempDir()
+ initWorkspaceGitRepo(t, repoDir)
+ if err := os.WriteFile(
+ filepath.Join(repoDir, "hello.txt"),
+ []byte("zero\nhello Needle world\nanother needle\n"),
+ 0o644,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ log := newTestLogger()
+ cfg := &config.InstanceConfig{WorkDir: repoDir}
+ server := NewServer(cfg, process.NewManager(cfg, log), nil, nil, log)
+
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/api/v1/workspace/content-search?q=needle&limit_per_repo=1",
+ nil,
+ )
+ resp := httptest.NewRecorder()
+ server.router.ServeHTTP(resp, req)
+
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200: %s", resp.Code, resp.Body.String())
+ }
+ var body streams.WorkspaceContentSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(body.Results) != 1 {
+ t.Fatalf("results = %#v, want one", body.Results)
+ }
+ match := body.Results[0]
+ if match.Path != "hello.txt" || match.Line != 2 || match.Column != 7 {
+ t.Fatalf("match = %#v", match)
+ }
+}
+
+func TestHandleWorkspaceContentSearchRejectsQueryOverMaximum(t *testing.T) {
+ server := newTestServer(t)
+ query := strings.Repeat("a", process.WorkspaceContentSearchMaxQueryRunes+1)
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/api/v1/workspace/content-search?q="+query,
+ nil,
+ )
+ resp := httptest.NewRecorder()
+ server.router.ServeHTTP(resp, req)
+
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400: %s", resp.Code, resp.Body.String())
+ }
+}
diff --git a/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go b/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go
new file mode 100644
index 0000000000..3629e30eac
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/api/workspace_file_search_test.go
@@ -0,0 +1,94 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/kandev/kandev/internal/agentctl/server/config"
+ "github.com/kandev/kandev/internal/agentctl/server/process"
+)
+
+func TestHandleFileSearchIncludesEveryTaskRepository(t *testing.T) {
+ type result struct {
+ RepositoryName string `json:"repository_name"`
+ Path string `json:"path"`
+ }
+ type response struct {
+ Files []string `json:"files"`
+ Results []result `json:"results"`
+ }
+
+ taskRoot := t.TempDir()
+ for _, fixture := range []struct {
+ repository string
+ path string
+ }{
+ {repository: "backend", path: "src/shared-search.go"},
+ {repository: "frontend", path: "src/shared-search.ts"},
+ } {
+ repoDir := filepath.Join(taskRoot, fixture.repository)
+ initWorkspaceGitRepo(t, repoDir)
+ fullPath := filepath.Join(repoDir, fixture.path)
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fullPath, []byte("fixture"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ log := newTestLogger()
+ cfg := &config.InstanceConfig{WorkDir: taskRoot}
+ manager := process.NewManager(cfg, log)
+ manager.StartAllWorkspaceTrackers(context.Background())
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := manager.Stop(ctx); err != nil {
+ t.Errorf("stop manager: %v", err)
+ }
+ })
+ server := NewServer(cfg, manager, nil, nil, log)
+
+ deadline := time.Now().Add(2 * time.Second)
+ var files []string
+ var results []result
+ for time.Now().Before(deadline) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/search?q=shared", nil)
+ resp := httptest.NewRecorder()
+ server.router.ServeHTTP(resp, req)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200: %s", resp.Code, resp.Body.String())
+ }
+ var body response
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ files = body.Files
+ results = body.Results
+ if len(files) == 2 {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+
+ want := []string{"backend/src/shared-search.go", "frontend/src/shared-search.ts"}
+ if !reflect.DeepEqual(files, want) {
+ t.Fatalf("files = %v, want %v", files, want)
+ }
+
+ wantResults := []result{
+ {RepositoryName: "backend", Path: "backend/src/shared-search.go"},
+ {RepositoryName: "frontend", Path: "frontend/src/shared-search.ts"},
+ }
+ if !reflect.DeepEqual(results, wantResults) {
+ t.Fatalf("results = %+v, want %+v", results, wantResults)
+ }
+}
diff --git a/apps/backend/internal/agentctl/server/process/workspace_content_search.go b/apps/backend/internal/agentctl/server/process/workspace_content_search.go
new file mode 100644
index 0000000000..eee8f8fbad
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/process/workspace_content_search.go
@@ -0,0 +1,604 @@
+package process
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "sort"
+ "unicode"
+ "unicode/utf16"
+ "unicode/utf8"
+
+ "github.com/kandev/kandev/internal/agentctl/types"
+)
+
+const (
+ // WorkspaceContentSearchMaxQueryRunes is the largest accepted content query.
+ WorkspaceContentSearchMaxQueryRunes = types.WorkspaceContentSearchMaxQueryRunes
+ workspaceContentSearchMaxLimit = 50
+ workspaceContentSearchDefaultLimit = 20
+ workspaceContentSearchPreviewRunes = 300
+ workspaceContentSearchReadBuffer = 32 * 1024
+ workspaceContentContiguousScoreBase = 1_000_000_000
+ workspaceContentSearchCancelRunes = 1_024
+ workspaceContentSearchFuzzyMaxSteps = 250_000
+)
+
+// ErrContentSearchQueryTooLong indicates that the query exceeds the public limit.
+var ErrContentSearchQueryTooLong = errors.New("content search query exceeds 200 characters")
+
+var errContentSearchFuzzyWorkLimit = errors.New("content search fuzzy work limit reached")
+
+type contentLineMatch struct {
+ score int
+ positions []int
+}
+
+type contentSearchFuzzyWork struct {
+ steps int
+}
+
+type scoredContentSearchResult struct {
+ result types.WorkspaceContentSearchResult
+ score int
+}
+
+// SearchWorkspaceContent searches every repository represented by the manager.
+func (m *Manager) SearchWorkspaceContent(
+ ctx context.Context,
+ query string,
+ limit int,
+) (*types.WorkspaceContentSearchResponse, error) {
+ limit, err := validateContentSearch(query, limit)
+ if err != nil {
+ return nil, err
+ }
+ response := &types.WorkspaceContentSearchResponse{
+ Results: []types.WorkspaceContentSearchResult{},
+ }
+ if query == "" {
+ return response, nil
+ }
+
+ for _, tracker := range m.contentSearchTrackers() {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ results, err := tracker.SearchContent(ctx, query, limit)
+ if err != nil {
+ return nil, err
+ }
+ response.Results = append(response.Results, results...)
+ }
+ return response, nil
+}
+
+func (m *Manager) contentSearchTrackers() []*WorkspaceTracker {
+ root, repositories := m.snapshotTrackers()
+ trackers := make([]*WorkspaceTracker, 0, len(repositories)+1)
+ if len(repositories) == 0 {
+ if root != nil {
+ trackers = append(trackers, root)
+ }
+ return trackers
+ }
+ if root != nil && root.RepositoryName() != "" {
+ trackers = append(trackers, root)
+ }
+ trackers = append(trackers, repositories...)
+ sort.Slice(trackers, func(i, j int) bool {
+ if trackers[i].RepositoryName() != trackers[j].RepositoryName() {
+ return trackers[i].RepositoryName() < trackers[j].RepositoryName()
+ }
+ return trackers[i].workDir < trackers[j].workDir
+ })
+ return trackers
+}
+
+// SearchContent searches the tracked and untracked nonignored text files for
+// matching lines. Results are bounded and sorted by match quality.
+func (wt *WorkspaceTracker) SearchContent(
+ ctx context.Context,
+ query string,
+ limit int,
+) ([]types.WorkspaceContentSearchResult, error) {
+ limit, err := validateContentSearch(query, limit)
+ if err != nil {
+ return nil, err
+ }
+ if query == "" {
+ return []types.WorkspaceContentSearchResult{}, nil
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ files, err := wt.getFileList(ctx)
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(files.Files, func(i, j int) bool {
+ return files.Files[i].Path < files.Files[j].Path
+ })
+ queryRunes := foldRunes([]rune(query))
+ ranked := make([]scoredContentSearchResult, 0, limit)
+ for _, file := range files.Files {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ data, searchable, readErr := wt.readContentSearchFile(ctx, file.Path)
+ if readErr != nil {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ continue
+ }
+ if !searchable {
+ continue
+ }
+ ranked, err = wt.searchContentFile(ctx, ranked, file.Path, data, queryRunes, limit)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return contentSearchResults(ranked), nil
+}
+
+func validateContentSearch(query string, limit int) (int, error) {
+ if utf8.RuneCountInString(query) > WorkspaceContentSearchMaxQueryRunes {
+ return 0, ErrContentSearchQueryTooLong
+ }
+ if limit <= 0 {
+ return workspaceContentSearchDefaultLimit, nil
+ }
+ if limit > workspaceContentSearchMaxLimit {
+ return workspaceContentSearchMaxLimit, nil
+ }
+ return limit, nil
+}
+
+func (wt *WorkspaceTracker) readContentSearchFile(
+ ctx context.Context,
+ path string,
+) ([]byte, bool, error) {
+ safePath, err := wt.resolveSafePath(path)
+ if err != nil {
+ return nil, false, err
+ }
+ file, err := os.Open(safePath)
+ if err != nil {
+ return nil, false, err
+ }
+ defer func() { _ = file.Close() }()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, false, err
+ }
+ if !info.Mode().IsRegular() || info.Size() > maxFileSize {
+ return nil, false, nil
+ }
+
+ data, err := readContentSearchBytes(ctx, file, info.Size())
+ if err != nil {
+ return nil, false, err
+ }
+ if len(data) > maxFileSize {
+ return nil, false, nil
+ }
+ if !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 {
+ return nil, false, nil
+ }
+ return data, true, nil
+}
+
+func readContentSearchBytes(ctx context.Context, reader io.Reader, size int64) ([]byte, error) {
+ buffer := bytes.NewBuffer(make([]byte, 0, int(size)))
+ chunk := make([]byte, workspaceContentSearchReadBuffer)
+ limited := io.LimitReader(reader, maxFileSize+1)
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ count, err := limited.Read(chunk)
+ if count > 0 {
+ _, _ = buffer.Write(chunk[:count])
+ }
+ if errors.Is(err, io.EOF) {
+ return buffer.Bytes(), nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+}
+
+func (wt *WorkspaceTracker) searchContentFile(
+ ctx context.Context,
+ ranked []scoredContentSearchResult,
+ path string,
+ data []byte,
+ query []rune,
+ limit int,
+) ([]scoredContentSearchResult, error) {
+ lines := bytes.Split(data, []byte{'\n'})
+ for index, lineBytes := range lines {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ lineBytes = bytes.TrimSuffix(lineBytes, []byte{'\r'})
+ lineRunes := []rune(string(lineBytes))
+ match, ok, err := bestContentLineMatch(ctx, lineRunes, query)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ continue
+ }
+ result := buildContentSearchResult(wt.repositoryName, path, index+1, lineRunes, match)
+ ranked = insertContentSearchResult(ranked, scoredContentSearchResult{
+ result: result,
+ score: match.score,
+ }, limit)
+ }
+ return ranked, nil
+}
+
+func bestContentLineMatch(
+ ctx context.Context,
+ line, query []rune,
+) (contentLineMatch, bool, error) {
+ if len(query) == 0 || len(line) < len(query) {
+ return contentLineMatch{}, false, nil
+ }
+ if err := ctx.Err(); err != nil {
+ return contentLineMatch{}, false, err
+ }
+ foldedLine := foldRunes(line)
+ match, ok, err := bestContiguousContentMatch(ctx, line, foldedLine, query)
+ if err != nil {
+ return contentLineMatch{}, false, err
+ }
+ if ok {
+ return match, true, nil
+ }
+ return bestSubsequenceContentMatch(ctx, line, foldedLine, query)
+}
+
+func bestContiguousContentMatch(
+ ctx context.Context,
+ line, foldedLine, query []rune,
+) (contentLineMatch, bool, error) {
+ bestStart, bestScore := 0, 0
+ found := false
+ queryIndex := 0
+ prefixes := contentSearchPrefixTable(query)
+ for lineIndex, item := range foldedLine {
+ if lineIndex%workspaceContentSearchCancelRunes == 0 {
+ if err := ctx.Err(); err != nil {
+ return contentLineMatch{}, false, err
+ }
+ }
+ for queryIndex > 0 && item != query[queryIndex] {
+ queryIndex = prefixes[queryIndex-1]
+ }
+ if item == query[queryIndex] {
+ queryIndex++
+ }
+ if queryIndex != len(query) {
+ continue
+ }
+ start := lineIndex - len(query) + 1
+ score := workspaceContentContiguousScoreBase - start
+ if isContentWordBoundary(line, start) {
+ score += 100
+ }
+ if !found || score > bestScore {
+ bestStart, bestScore, found = start, score, true
+ }
+ queryIndex = prefixes[queryIndex-1]
+ }
+ if !found {
+ return contentLineMatch{}, false, nil
+ }
+ return contentLineMatch{
+ score: bestScore,
+ positions: contiguousPositions(bestStart, len(query)),
+ }, true, nil
+}
+
+func bestSubsequenceContentMatch(
+ ctx context.Context,
+ line, foldedLine, query []rune,
+) (contentLineMatch, bool, error) {
+ if err := ctx.Err(); err != nil {
+ return contentLineMatch{}, false, err
+ }
+ best := contentLineMatch{}
+ found := false
+ work := contentSearchFuzzyWork{}
+ for cursor := 0; cursor < len(foldedLine); {
+ positions, ok, err := nextSubsequenceContentPositions(
+ ctx,
+ foldedLine,
+ query,
+ cursor,
+ &work,
+ )
+ if errors.Is(err, errContentSearchFuzzyWorkLimit) {
+ break
+ }
+ if err != nil {
+ return contentLineMatch{}, false, err
+ }
+ if !ok {
+ break
+ }
+ score := scoreSubsequenceContentMatch(line, positions)
+ if !found || score > best.score {
+ best = contentLineMatch{score: score, positions: positions}
+ found = true
+ }
+ cursor = positions[0] + 1
+ }
+ return best, found, nil
+}
+
+func nextSubsequenceContentPositions(
+ ctx context.Context,
+ line, query []rune,
+ start int,
+ work *contentSearchFuzzyWork,
+) ([]int, bool, error) {
+ positions := make([]int, 0, len(query))
+ queryIndex := 0
+ end := -1
+ for position := start; position < len(line); position++ {
+ if err := work.consume(ctx); err != nil {
+ return nil, false, err
+ }
+ if line[position] != query[queryIndex] {
+ continue
+ }
+ queryIndex++
+ if queryIndex == len(query) {
+ end = position
+ break
+ }
+ }
+ if end < 0 {
+ return nil, false, nil
+ }
+ positions = positions[:len(query)]
+ queryIndex = len(query) - 1
+ for position := end; position >= start; position-- {
+ if err := work.consume(ctx); err != nil {
+ return nil, false, err
+ }
+ if line[position] != query[queryIndex] {
+ continue
+ }
+ positions[queryIndex] = position
+ queryIndex--
+ if queryIndex < 0 {
+ break
+ }
+ }
+ return positions, true, nil
+}
+
+func (work *contentSearchFuzzyWork) consume(ctx context.Context) error {
+ if work.steps >= workspaceContentSearchFuzzyMaxSteps {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return errContentSearchFuzzyWorkLimit
+ }
+ if work.steps%workspaceContentSearchCancelRunes == 0 {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ }
+ work.steps++
+ return nil
+}
+
+func scoreSubsequenceContentMatch(line []rune, positions []int) int {
+ score := len(positions)*20 - positions[0]
+ for index, position := range positions {
+ if isContentWordBoundary(line, position) {
+ score += 8
+ }
+ if index == 0 {
+ continue
+ }
+ gap := position - positions[index-1] - 1
+ if gap == 0 {
+ score += 15
+ } else {
+ score -= gap * 2
+ }
+ }
+ return score
+}
+
+func isContentWordBoundary(line []rune, position int) bool {
+ if position == 0 {
+ return true
+ }
+ previous, current := line[position-1], line[position]
+ if !unicode.IsLetter(previous) && !unicode.IsNumber(previous) && previous != '_' {
+ return true
+ }
+ return unicode.IsLower(previous) && unicode.IsUpper(current)
+}
+
+func buildContentSearchResult(
+ repositoryName, path string,
+ lineNumber int,
+ line []rune,
+ match contentLineMatch,
+) types.WorkspaceContentSearchResult {
+ preview, ranges := buildContentSearchPreview(line, match.positions)
+ return types.WorkspaceContentSearchResult{
+ RepositoryName: repositoryName,
+ Path: path,
+ Line: lineNumber,
+ Column: utf16Length(line[:match.positions[0]]) + 1,
+ Preview: preview,
+ MatchRanges: ranges,
+ }
+}
+
+func buildContentSearchPreview(
+ line []rune,
+ positions []int,
+) (string, []types.WorkspaceContentMatchRange) {
+ start, end := contentSearchPreviewBounds(len(line), positions[0])
+ previewRunes := append([]rune(nil), line[start:end]...)
+ prefixUnits := 0
+ if start > 0 {
+ previewRunes = append([]rune{'…'}, previewRunes...)
+ prefixUnits = 1
+ }
+ if end < len(line) {
+ previewRunes = append(previewRunes, '…')
+ }
+
+ ranges := contentSearchMatchRanges(line, positions, start, end, prefixUnits)
+ return string(previewRunes), ranges
+}
+
+func contentSearchPreviewBounds(lineLength, firstMatch int) (int, int) {
+ if lineLength <= workspaceContentSearchPreviewRunes {
+ return 0, lineLength
+ }
+ start := firstMatch - workspaceContentSearchPreviewRunes/4
+ if start < 0 {
+ start = 0
+ }
+ end := start + workspaceContentSearchPreviewRunes
+ if end > lineLength {
+ end = lineLength
+ start = end - workspaceContentSearchPreviewRunes
+ }
+ return start, end
+}
+
+func contentSearchMatchRanges(
+ line []rune,
+ positions []int,
+ previewStart, previewEnd, prefixUnits int,
+) []types.WorkspaceContentMatchRange {
+ ranges := make([]types.WorkspaceContentMatchRange, 0, len(positions))
+ for _, position := range positions {
+ if position < previewStart || position >= previewEnd {
+ continue
+ }
+ start := prefixUnits + utf16Length(line[previewStart:position])
+ end := start + utf16RuneLength(line[position])
+ if len(ranges) > 0 && ranges[len(ranges)-1].End == start {
+ ranges[len(ranges)-1].End = end
+ continue
+ }
+ ranges = append(ranges, types.WorkspaceContentMatchRange{Start: start, End: end})
+ }
+ return ranges
+}
+
+func insertContentSearchResult(
+ ranked []scoredContentSearchResult,
+ candidate scoredContentSearchResult,
+ limit int,
+) []scoredContentSearchResult {
+ index := sort.Search(len(ranked), func(index int) bool {
+ return betterContentSearchResult(candidate, ranked[index])
+ })
+ if len(ranked) < limit {
+ ranked = append(ranked, scoredContentSearchResult{})
+ copy(ranked[index+1:], ranked[index:])
+ ranked[index] = candidate
+ return ranked
+ }
+ if index == len(ranked) {
+ return ranked
+ }
+ copy(ranked[index+1:], ranked[index:len(ranked)-1])
+ ranked[index] = candidate
+ return ranked
+}
+
+func betterContentSearchResult(left, right scoredContentSearchResult) bool {
+ if left.score != right.score {
+ return left.score > right.score
+ }
+ if left.result.Path != right.result.Path {
+ return left.result.Path < right.result.Path
+ }
+ if left.result.Line != right.result.Line {
+ return left.result.Line < right.result.Line
+ }
+ return left.result.Column < right.result.Column
+}
+
+func contentSearchResults(
+ ranked []scoredContentSearchResult,
+) []types.WorkspaceContentSearchResult {
+ results := make([]types.WorkspaceContentSearchResult, len(ranked))
+ for index := range ranked {
+ results[index] = ranked[index].result
+ }
+ return results
+}
+
+func foldRunes(value []rune) []rune {
+ folded := make([]rune, len(value))
+ for index, item := range value {
+ folded[index] = unicode.ToLower(item)
+ }
+ return folded
+}
+
+func contentSearchPrefixTable(query []rune) []int {
+ prefixes := make([]int, len(query))
+ prefixLength := 0
+ for index := 1; index < len(query); {
+ if query[index] == query[prefixLength] {
+ prefixLength++
+ prefixes[index] = prefixLength
+ index++
+ continue
+ }
+ if prefixLength > 0 {
+ prefixLength = prefixes[prefixLength-1]
+ continue
+ }
+ index++
+ }
+ return prefixes
+}
+
+func contiguousPositions(start, length int) []int {
+ positions := make([]int, length)
+ for index := range positions {
+ positions[index] = start + index
+ }
+ return positions
+}
+
+func utf16Length(value []rune) int {
+ length := 0
+ for _, item := range value {
+ length += utf16RuneLength(item)
+ }
+ return length
+}
+
+func utf16RuneLength(value rune) int {
+ length := utf16.RuneLen(value)
+ if length < 1 {
+ return 1
+ }
+ return length
+}
diff --git a/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go b/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go
new file mode 100644
index 0000000000..77aa4202cc
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/process/workspace_content_search_test.go
@@ -0,0 +1,300 @@
+package process
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/kandev/kandev/internal/agentctl/server/config"
+)
+
+func TestWorkspaceTrackerSearchContentRanksContiguousMatchAndUsesUTF16Ranges(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, "search.txt", "n_e_e_d_l_e\n🙂 Needle value\n")
+
+ tracker := NewWorkspaceTrackerForRepo(repoDir, "backend", newTestLogger(t))
+ results, err := tracker.SearchContent(context.Background(), "NeEdLe", 10)
+ if err != nil {
+ t.Fatalf("SearchContent returned error: %v", err)
+ }
+ if len(results) != 2 {
+ t.Fatalf("SearchContent result count = %d, want 2: %#v", len(results), results)
+ }
+
+ exact := results[0]
+ if exact.RepositoryName != "backend" || exact.Path != "search.txt" {
+ t.Fatalf("exact result identity = %#v", exact)
+ }
+ if exact.Line != 2 || exact.Column != 4 || exact.Preview != "🙂 Needle value" {
+ t.Fatalf("exact location = line %d, column %d, preview %q", exact.Line, exact.Column, exact.Preview)
+ }
+ if len(exact.MatchRanges) != 1 ||
+ exact.MatchRanges[0].Start != 3 || exact.MatchRanges[0].End != 9 {
+ t.Fatalf("exact UTF-16 ranges = %#v, want [{3 9}]", exact.MatchRanges)
+ }
+
+ fuzzy := results[1]
+ if fuzzy.Line != 1 || len(fuzzy.MatchRanges) != 6 {
+ t.Fatalf("fuzzy result = %#v, want six subsequence ranges on line 1", fuzzy)
+ }
+}
+
+func TestContentSearchAlwaysRanksContiguousAboveSubsequence(t *testing.T) {
+ query := foldRunes([]rune("needle"))
+ exactLine := []rune(strings.Repeat("x", 1_100_000) + "needle")
+ fuzzyLine := []rune("n_e_e_d_l_e")
+
+ exact, ok, err := bestContentLineMatch(context.Background(), exactLine, query)
+ if err != nil {
+ t.Fatalf("bestContentLineMatch returned error: %v", err)
+ }
+ if !ok {
+ t.Fatal("long-line contiguous match was not found")
+ }
+ fuzzy, ok, err := bestContentLineMatch(context.Background(), fuzzyLine, query)
+ if err != nil {
+ t.Fatalf("bestContentLineMatch returned error: %v", err)
+ }
+ if !ok {
+ t.Fatal("subsequence match was not found")
+ }
+ if exact.score <= fuzzy.score {
+ t.Fatalf("contiguous score %d must exceed subsequence score %d", exact.score, fuzzy.score)
+ }
+}
+
+func TestBestSubsequenceContentMatchChoosesTightestOverlappingCandidate(t *testing.T) {
+ line := []rune("a___aa")
+ match, ok, err := bestSubsequenceContentMatch(
+ context.Background(),
+ line,
+ foldRunes(line),
+ foldRunes([]rune("aa")),
+ )
+ if err != nil {
+ t.Fatalf("bestSubsequenceContentMatch returned error: %v", err)
+ }
+ if !ok {
+ t.Fatal("subsequence match was not found")
+ }
+ if got := match.positions; len(got) != 2 || got[0] != 4 || got[1] != 5 {
+ t.Fatalf("positions = %v, want tightest overlapping match [4 5]", got)
+ }
+}
+
+func TestBestSubsequenceContentMatchChecksCancellationDuringMatching(t *testing.T) {
+ base, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ ctx := &cancelOnErrCheckContext{
+ Context: base,
+ cancel: cancel,
+ checksRemaining: 2,
+ }
+ line := []rune(strings.Repeat("a_", 2_000))
+
+ _, _, err := bestSubsequenceContentMatch(
+ ctx,
+ line,
+ foldRunes(line),
+ foldRunes([]rune("aa")),
+ )
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("bestSubsequenceContentMatch error = %v, want context.Canceled", err)
+ }
+}
+
+func TestBestSubsequenceContentMatchBoundsPathologicalLongLineWork(t *testing.T) {
+ const expectedFuzzyWorkLimit = 250_000
+ line := []rune(strings.Repeat("x", expectedFuzzyWorkLimit+1) + "a_b")
+
+ _, ok, err := bestSubsequenceContentMatch(
+ context.Background(),
+ line,
+ foldRunes(line),
+ foldRunes([]rune("ab")),
+ )
+ if err != nil {
+ t.Fatalf("bestSubsequenceContentMatch returned error: %v", err)
+ }
+ if ok {
+ t.Fatal("fuzzy match beyond the bounded work limit should be skipped")
+ }
+}
+
+type cancelOnErrCheckContext struct {
+ context.Context
+ cancel context.CancelFunc
+ checksRemaining int
+}
+
+func (ctx *cancelOnErrCheckContext) Err() error {
+ ctx.checksRemaining--
+ if ctx.checksRemaining <= 0 {
+ ctx.cancel()
+ }
+ return ctx.Context.Err()
+}
+
+func TestWorkspaceTrackerSearchContentUsesGitFileSetAndSkipsUnsafeOrBinaryFiles(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, ".gitignore", "ignored.txt\n")
+ writeFile(t, repoDir, "tracked.txt", "tracked secret\n")
+ runGit(t, repoDir, "add", ".gitignore", "tracked.txt")
+ runGit(t, repoDir, "commit", "-m", "add searchable files")
+ writeFile(t, repoDir, "untracked.txt", "untracked secret\n")
+ writeFile(t, repoDir, "ignored.txt", "ignored secret\n")
+ if err := os.WriteFile(filepath.Join(repoDir, "binary.bin"), []byte("binary\x00secret"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repoDir, "invalid.txt"), []byte("invalid secret\xff"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ largePath := filepath.Join(repoDir, "large.txt")
+ if err := os.WriteFile(largePath, []byte("large secret"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Truncate(largePath, maxFileSize+1); err != nil {
+ t.Fatal(err)
+ }
+
+ outsideDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(outsideDir, "outside.txt"), []byte("outside secret"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(outsideDir, "outside.txt"), filepath.Join(repoDir, "unsafe-link.txt")); err == nil {
+ runGit(t, repoDir, "add", "unsafe-link.txt")
+ runGit(t, repoDir, "commit", "-m", "add unsafe link")
+ }
+
+ tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
+ results, err := tracker.SearchContent(context.Background(), "secret", 50)
+ if err != nil {
+ t.Fatalf("SearchContent returned error: %v", err)
+ }
+ gotPaths := make([]string, 0, len(results))
+ for _, result := range results {
+ gotPaths = append(gotPaths, result.Path)
+ }
+ want := []string{"tracked.txt", "untracked.txt"}
+ if strings.Join(gotPaths, ",") != strings.Join(want, ",") {
+ t.Fatalf("searchable paths = %v, want %v", gotPaths, want)
+ }
+}
+
+func TestWorkspaceTrackerSearchContentBoundsPreviewAroundUTF16Match(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ line := strings.Repeat("x", 400) + "🙂 Needle" + strings.Repeat("y", 400)
+ writeFile(t, repoDir, "long.txt", line+"\n")
+
+ tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
+ results, err := tracker.SearchContent(context.Background(), "needle", 10)
+ if err != nil {
+ t.Fatalf("SearchContent returned error: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("results = %#v, want one", results)
+ }
+ result := results[0]
+ if utf8.RuneCountInString(result.Preview) > workspaceContentSearchPreviewRunes+2 {
+ t.Fatalf("preview has %d runes, want at most %d",
+ utf8.RuneCountInString(result.Preview), workspaceContentSearchPreviewRunes+2)
+ }
+ if result.Column != 404 {
+ t.Fatalf("column = %d, want UTF-16 column 404", result.Column)
+ }
+ if len(result.MatchRanges) != 1 ||
+ result.MatchRanges[0].Start != 77 || result.MatchRanges[0].End != 83 {
+ t.Fatalf("preview match ranges = %#v, want [{77 83}]", result.MatchRanges)
+ }
+}
+
+func TestManagerSearchWorkspaceContentGroupsResultsByRepository(t *testing.T) {
+ taskRoot := t.TempDir()
+ for _, name := range []string{"zeta", "alpha"} {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, "match.txt", "first token\nsecond token\n")
+ if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil {
+ t.Fatalf("place %s: %v", name, err)
+ }
+ }
+
+ manager := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t))
+ response, err := manager.SearchWorkspaceContent(context.Background(), "token", 1)
+ if err != nil {
+ t.Fatalf("SearchWorkspaceContent returned error: %v", err)
+ }
+ if len(response.Results) != 2 {
+ t.Fatalf("result count = %d, want one per repo: %#v", len(response.Results), response.Results)
+ }
+ if response.Results[0].RepositoryName != "alpha" ||
+ response.Results[1].RepositoryName != "zeta" {
+ t.Fatalf("repository order = %q, %q; want alpha, zeta",
+ response.Results[0].RepositoryName, response.Results[1].RepositoryName)
+ }
+}
+
+func TestManagerSearchWorkspaceContentClampsLimitPerRepository(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, "many.txt", strings.Repeat("needle\n", 60))
+
+ manager := NewManager(&config.InstanceConfig{WorkDir: repoDir}, newTestLogger(t))
+ response, err := manager.SearchWorkspaceContent(context.Background(), "needle", 500)
+ if err != nil {
+ t.Fatalf("SearchWorkspaceContent returned error: %v", err)
+ }
+ if len(response.Results) != workspaceContentSearchMaxLimit {
+ t.Fatalf("result count = %d, want clamp %d", len(response.Results), workspaceContentSearchMaxLimit)
+ }
+}
+
+func TestManagerSearchWorkspaceContentRejectsLongQuery(t *testing.T) {
+ manager := NewManager(&config.InstanceConfig{WorkDir: t.TempDir()}, newTestLogger(t))
+ _, err := manager.SearchWorkspaceContent(
+ context.Background(),
+ strings.Repeat("界", WorkspaceContentSearchMaxQueryRunes+1),
+ 10,
+ )
+ if !errors.Is(err, ErrContentSearchQueryTooLong) {
+ t.Fatalf("SearchWorkspaceContent error = %v, want ErrContentSearchQueryTooLong", err)
+ }
+}
+
+func TestWorkspaceTrackerSearchContentHonorsCancellation(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, "match.txt", "needle\n")
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
+ _, err := tracker.SearchContent(ctx, "needle", 10)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("SearchContent error = %v, want context.Canceled", err)
+ }
+}
+
+func TestReadContentSearchFilePropagatesCancellation(t *testing.T) {
+ repoDir, cleanup := setupTestRepo(t)
+ t.Cleanup(cleanup)
+ writeFile(t, repoDir, "match.txt", "needle\n")
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
+ _, searchable, err := tracker.readContentSearchFile(ctx, "match.txt")
+ if searchable {
+ t.Fatal("canceled read unexpectedly reported searchable content")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("readContentSearchFile error = %v, want context.Canceled", err)
+ }
+}
diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go
index 26fb72441a..fde4edb96a 100644
--- a/apps/backend/internal/agentctl/server/process/workspace_files.go
+++ b/apps/backend/internal/agentctl/server/process/workspace_files.go
@@ -862,10 +862,18 @@ func calculateSHA256(content string) string {
return hex.EncodeToString(hash[:])
}
-// scoredMatch holds a file path and its match score for sorting
+// scoredMatch holds a file path, repository identity, and match score for sorting.
type scoredMatch struct {
- path string
- score int
+ path string
+ matchPath string
+ repositoryName string
+ score int
+}
+
+type fileSearchCandidate struct {
+ path string
+ matchPath string
+ repositoryName string
}
// GetFileContentAtRef returns the content of a file at a specific git ref (branch, commit, HEAD, etc).
@@ -928,8 +936,75 @@ func (wt *WorkspaceTracker) GetFileContentAtRef(ctx context.Context, reqPath str
// 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 {
+ wt.mu.RLock()
+ candidates := make([]fileSearchCandidate, 0, len(wt.currentFiles.Files))
+ for _, file := range wt.currentFiles.Files {
+ candidates = append(candidates, fileSearchCandidate{
+ path: file.Path,
+ matchPath: file.Path,
+ })
+ }
+ wt.mu.RUnlock()
+
+ results := searchFileCandidates(candidates, query, limit)
+ paths := make([]string, 0, len(results))
+ for _, result := range results {
+ paths = append(paths, result.Path)
+ }
+ return paths
+}
+
+// SearchWorkspaceFileResults searches every repository represented by the manager.
+// Multi-repo results retain their task-root-relative repository prefix so
+// existing file consumers can open the returned path without extra metadata.
+func (m *Manager) SearchWorkspaceFileResults(query string, limit int) []types.FileSearchResult {
+ root, repositories := m.snapshotTrackers()
+ if len(repositories) == 0 {
+ if root == nil {
+ return []types.FileSearchResult{}
+ }
+ candidates := appendTrackerFileSearchCandidates(nil, root)
+ return searchFileCandidates(candidates, query, limit)
+ }
+
+ candidates := make([]fileSearchCandidate, 0)
+ if root != nil && root.RepositoryName() != "" {
+ candidates = appendTrackerFileSearchCandidates(candidates, root)
+ }
+ for _, tracker := range repositories {
+ candidates = appendTrackerFileSearchCandidates(candidates, tracker)
+ }
+ return searchFileCandidates(candidates, query, limit)
+}
+
+func appendTrackerFileSearchCandidates(
+ candidates []fileSearchCandidate,
+ tracker *WorkspaceTracker,
+) []fileSearchCandidate {
+ tracker.mu.RLock()
+ defer tracker.mu.RUnlock()
+ repository := tracker.RepositoryName()
+ for _, file := range tracker.currentFiles.Files {
+ path := file.Path
+ if repository != "" {
+ path = filepath.ToSlash(filepath.Join(repository, path))
+ }
+ candidates = append(candidates, fileSearchCandidate{
+ path: path,
+ matchPath: file.Path,
+ repositoryName: repository,
+ })
+ }
+ return candidates
+}
+
+func searchFileCandidates(
+ candidates []fileSearchCandidate,
+ query string,
+ limit int,
+) []types.FileSearchResult {
if query == "" {
- return []string{}
+ return []types.FileSearchResult{}
}
if limit <= 0 {
limit = 20
@@ -938,16 +1013,15 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string {
query = strings.ToLower(query)
var matches []scoredMatch
- wt.mu.RLock()
- files := wt.currentFiles.Files
- wt.mu.RUnlock()
-
- for _, file := range files {
- path := file.Path
- if isRootOwnershipMarkerPath(path) {
+ for _, candidate := range candidates {
+ if isRootOwnershipMarkerPath(candidate.path) {
continue
}
- lowerPath := strings.ToLower(path)
+ matchPath := candidate.matchPath
+ if matchPath == "" {
+ matchPath = candidate.path
+ }
+ lowerPath := strings.ToLower(matchPath)
name := filepath.Base(lowerPath)
score := 0
@@ -963,7 +1037,12 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string {
}
if score > 0 {
- matches = append(matches, scoredMatch{path: path, score: score})
+ matches = append(matches, scoredMatch{
+ path: candidate.path,
+ matchPath: matchPath,
+ repositoryName: candidate.repositoryName,
+ score: score,
+ })
}
}
@@ -973,13 +1052,16 @@ func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string {
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 len(matches[i].matchPath) < len(matches[j].matchPath)
})
// Return top limit results
- result := make([]string, 0, limit)
+ result := make([]types.FileSearchResult, 0, limit)
for i := 0; i < len(matches) && i < limit; i++ {
- result = append(result, matches[i].path)
+ result = append(result, types.FileSearchResult{
+ RepositoryName: matches[i].repositoryName,
+ Path: matches[i].path,
+ })
}
return result
diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go
index 993ef6df01..9111b12ff0 100644
--- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go
+++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go
@@ -479,6 +479,44 @@ func TestSearchFiles_HidesOnlyRootOwnershipMarker(t *testing.T) {
}
}
+func TestSearchFileCandidatesDoesNotMatchRepositoryName(t *testing.T) {
+ results := searchFileCandidates([]fileSearchCandidate{
+ {
+ path: "web/docs/unrelated.txt",
+ repositoryName: "web",
+ matchPath: "docs/unrelated.txt",
+ },
+ {
+ path: "backend/src/web-client.ts",
+ repositoryName: "backend",
+ matchPath: "src/web-client.ts",
+ },
+ }, "web", 20)
+
+ if len(results) != 1 || results[0].Path != "backend/src/web-client.ts" {
+ t.Fatalf("search results = %#v, want only the repo-relative filename match", results)
+ }
+}
+
+func TestSearchFileCandidatesBreaksTiesByRepositoryRelativePathLength(t *testing.T) {
+ results := searchFileCandidates([]fileSearchCandidate{
+ {
+ path: "long-repository-name/a/query.go",
+ repositoryName: "long-repository-name",
+ matchPath: "a/query.go",
+ },
+ {
+ path: "x/much-longer/query.go",
+ repositoryName: "x",
+ matchPath: "much-longer/query.go",
+ },
+ }, "query.go", 20)
+
+ if len(results) != 2 || results[0].Path != "long-repository-name/a/query.go" {
+ t.Fatalf("search results = %#v, want shortest repo-relative path first", results)
+ }
+}
+
func TestGetFileTree_Symlinks(t *testing.T) {
tmpDir := t.TempDir()
wt := &WorkspaceTracker{workDir: tmpDir}
diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go
index 078dc63435..b296e49b86 100644
--- a/apps/backend/internal/agentctl/types/streams/file.go
+++ b/apps/backend/internal/agentctl/types/streams/file.go
@@ -159,15 +159,65 @@ type FileSearchRequest struct {
Limit int `json:"limit"`
}
+// FileSearchResult identifies a matching file and its repository.
+type FileSearchResult struct {
+ // RepositoryName is empty for single-repository workspaces.
+ RepositoryName string `json:"repository_name,omitempty"`
+
+ // Path is task-root-relative so existing file APIs can open it directly.
+ Path string `json:"path"`
+}
+
// FileSearchResponse represents a response with matching files.
type FileSearchResponse struct {
- // Files is the list of matching file paths.
+ // Files is the legacy list of matching task-root-relative paths.
Files []string `json:"files"`
+ // Results carries repository identity for grouping-aware consumers.
+ Results []FileSearchResult `json:"results"`
+
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
+// WorkspaceContentSearchMaxQueryRunes is the largest accepted content query.
+const WorkspaceContentSearchMaxQueryRunes = 200
+
+// WorkspaceContentSearchRequest searches file contents in every repository in
+// the active task workspace.
+//
+// HTTP endpoint: GET /api/v1/workspace/content-search
+type WorkspaceContentSearchRequest struct {
+ // Query is matched case-insensitively against individual text lines.
+ Query string `json:"query"`
+
+ // LimitPerRepo is the maximum number of results returned per repository.
+ LimitPerRepo int `json:"limit_per_repo"`
+}
+
+// WorkspaceContentMatchRange is a half-open UTF-16 range in a result preview.
+type WorkspaceContentMatchRange struct {
+ Start int `json:"start"`
+ End int `json:"end"`
+}
+
+// WorkspaceContentSearchResult identifies one matching line in a repository.
+type WorkspaceContentSearchResult struct {
+ RepositoryName string `json:"repository_name"`
+ Path string `json:"path"`
+ Line int `json:"line"`
+ Column int `json:"column"`
+ Preview string `json:"preview"`
+ MatchRanges []WorkspaceContentMatchRange `json:"match_ranges"`
+}
+
+// WorkspaceContentSearchResponse contains content matches grouped in
+// deterministic repository order.
+type WorkspaceContentSearchResponse struct {
+ Results []WorkspaceContentSearchResult `json:"results"`
+ Error string `json:"error,omitempty"`
+}
+
// FileUpdateRequest represents a request to update file content using a diff.
//
// HTTP endpoint: POST /api/v1/workspace/file/content
diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go
index 6cc34de850..88a91d3658 100644
--- a/apps/backend/internal/agentctl/types/types.go
+++ b/apps/backend/internal/agentctl/types/types.go
@@ -34,16 +34,21 @@ type (
FileInfo = streams.FileInfo
// File stream types
- FileChangeNotification = streams.FileChangeNotification
- FileListUpdate = streams.FileListUpdate
- FileEntry = streams.FileEntry
- FileTreeNode = streams.FileTreeNode
- FileTreeRequest = streams.FileTreeRequest
- FileTreeResponse = streams.FileTreeResponse
- FileContentRequest = streams.FileContentRequest
- FileContentResponse = streams.FileContentResponse
- FileSearchRequest = streams.FileSearchRequest
- FileSearchResponse = streams.FileSearchResponse
+ FileChangeNotification = streams.FileChangeNotification
+ FileListUpdate = streams.FileListUpdate
+ FileEntry = streams.FileEntry
+ FileTreeNode = streams.FileTreeNode
+ FileTreeRequest = streams.FileTreeRequest
+ FileTreeResponse = streams.FileTreeResponse
+ FileContentRequest = streams.FileContentRequest
+ FileContentResponse = streams.FileContentResponse
+ FileSearchRequest = streams.FileSearchRequest
+ FileSearchResult = streams.FileSearchResult
+ FileSearchResponse = streams.FileSearchResponse
+ WorkspaceContentSearchRequest = streams.WorkspaceContentSearchRequest
+ WorkspaceContentMatchRange = streams.WorkspaceContentMatchRange
+ WorkspaceContentSearchResult = streams.WorkspaceContentSearchResult
+ WorkspaceContentSearchResponse = streams.WorkspaceContentSearchResponse
// Shell stream types
ShellMessage = streams.ShellMessage
@@ -84,6 +89,8 @@ const (
FileOpChmod = streams.FileOpChmod
FileOpRefresh = streams.FileOpRefresh
+ WorkspaceContentSearchMaxQueryRunes = streams.WorkspaceContentSearchMaxQueryRunes
+
// Shell message types
ShellMsgTypeInput = streams.ShellMsgTypeInput
ShellMsgTypeOutput = streams.ShellMsgTypeOutput
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index 099f2cb299..8ad3eb4fe0 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -259,6 +259,7 @@ const (
ActionWorkspaceFileDelete = "workspace.file.delete"
ActionWorkspaceFileRename = "workspace.file.rename"
ActionWorkspaceFilesSearch = "workspace.files.search"
+ ActionWorkspaceContentSearch = "workspace.content.search"
ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification
// Shell actions
diff --git a/apps/web/components/command-panel-content-search.test.tsx b/apps/web/components/command-panel-content-search.test.tsx
new file mode 100644
index 0000000000..bbcce49eb3
--- /dev/null
+++ b/apps/web/components/command-panel-content-search.test.tsx
@@ -0,0 +1,328 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+vi.mock("@kandev/ui/command", () => ({
+ Command: ({ children, shouldFilter }: { children: ReactNode; shouldFilter?: boolean }) => (
+
+ {children}
+
+ ),
+ CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
+ open ? {children}
: null,
+ CommandEmpty: ({ children }: { children: ReactNode }) => {children}
,
+ CommandGroup: ({
+ children,
+ heading,
+ "data-testid": testId,
+ "data-repository": repository,
+ }: {
+ children: ReactNode;
+ heading?: ReactNode;
+ "data-testid"?: string;
+ "data-repository"?: string;
+ }) => (
+
+ {heading && {heading}
}
+ {children}
+
+ ),
+ CommandInput: ({
+ placeholder,
+ value,
+ onKeyDown,
+ }: {
+ placeholder: string;
+ value: string;
+ onKeyDown: (event: React.KeyboardEvent) => void;
+ }) => (
+
+
+
+ ),
+ CommandItem: ({ children }: { children: ReactNode }) => {children}
,
+ CommandList: ({ children }: { children: ReactNode }) => {children}
,
+ CommandShortcut: ({ children }: { children: ReactNode }) => {children},
+}));
+
+vi.mock("@kandev/ui/kbd", () => ({
+ Kbd: ({ children }: { children: ReactNode }) => {children},
+ KbdGroup: ({ children }: { children: ReactNode }) => {children},
+}));
+
+vi.mock("@kandev/ui/badge", () => ({
+ Badge: ({ children }: { children: ReactNode }) => {children},
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ userSettings: { keyboardShortcuts: {} },
+ taskSessions: { items: {} },
+ kanban: { tasks: [] },
+ repositories: { itemsByWorkspaceId: {} },
+ }),
+}));
+
+type MockContentSearchProps = {
+ onSelect: (result: WorkspaceContentSearchResult) => void;
+ results: WorkspaceContentSearchResult[];
+};
+
+const mockContentSearch = vi.fn(({ onSelect, results }: MockContentSearchProps) => (
+
+));
+
+vi.mock("./workspace-content-search", () => ({
+ WorkspaceContentSearch: (props: MockContentSearchProps) => mockContentSearch(props),
+}));
+
+import {
+ CommandPanelView,
+ MODE_SEARCH_CONTENT,
+ type CommandPanelViewProps,
+} from "./command-panel-footer";
+
+const result: WorkspaceContentSearchResult = {
+ repository_name: "web",
+ path: "src/app.tsx",
+ line: 5,
+ column: 3,
+ preview: "needle",
+ match_ranges: [{ start: 0, end: 6 }],
+};
+
+const ARIA_SELECTED_ATTRIBUTE = "aria-selected";
+
+function viewProps(overrides: Partial = {}): CommandPanelViewProps {
+ return {
+ open: true,
+ setOpen: vi.fn(),
+ mode: MODE_SEARCH_CONTENT,
+ inputCommand: null,
+ selectedValue: "",
+ setSelectedValue: vi.fn(),
+ search: "needle",
+ setSearch: vi.fn(),
+ handleKeyDown: vi.fn(),
+ onScopeChange: vi.fn(),
+ goBack: vi.fn(),
+ fileResults: [],
+ isSearchingFiles: false,
+ handleFileSelect: vi.fn(),
+ contentResults: [result],
+ isSearchingContent: false,
+ contentSearchError: null,
+ activeSessionId: "session-1",
+ workspaceSearchAvailable: true,
+ handleContentSelect: vi.fn(),
+ commands: [],
+ grouped: [],
+ handleSelect: vi.fn(),
+ isSearching: false,
+ taskResults: [],
+ stepMap: new Map(),
+ repoMap: new Map(),
+ handleTaskSelect: vi.fn(),
+ ...overrides,
+ };
+}
+
+afterEach(() => {
+ cleanup();
+ mockContentSearch.mockClear();
+});
+
+describe("CommandPanelView task content search mode", () => {
+ it("renders a dedicated input and forwards repository results", () => {
+ const props = viewProps();
+ render();
+
+ expect(screen.getByPlaceholderText("Search task contents…")).toBeTruthy();
+ expect(screen.getByText("Contents")).toBeTruthy();
+ expect(mockContentSearch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ results: [result],
+ isSearching: false,
+ error: null,
+ search: "needle",
+ sessionId: "session-1",
+ }),
+ );
+
+ fireEvent.click(screen.getByTestId("mock-content-search"));
+ expect(props.handleContentSelect).toHaveBeenCalledWith(result);
+ });
+
+ it("makes all palette scopes visible and switches without clearing the query", () => {
+ const onScopeChange = vi.fn();
+ const props = viewProps({ onScopeChange });
+ render();
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(3);
+ expect(
+ screen.getByRole("tab", { name: "Commands" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
+ ).toBe("false");
+ expect(screen.getByRole("tab", { name: "Files" }).getAttribute(ARIA_SELECTED_ATTRIBUTE)).toBe(
+ "false",
+ );
+ expect(
+ screen.getByRole("tab", { name: "Contents" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
+ ).toBe("true");
+
+ fireEvent.click(screen.getByRole("tab", { name: "Files" }));
+
+ expect(onScopeChange).toHaveBeenCalledWith("search-files");
+ expect(props.setSearch).not.toHaveBeenCalled();
+ expect((screen.getByRole("combobox") as HTMLInputElement).value).toBe("needle");
+ });
+
+ it("keeps the mode selector inline with the search input", () => {
+ render();
+
+ const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
+ const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
+
+ expect(switcher.parentElement).toBe(inputWrapper?.parentElement);
+ });
+
+ it("keeps balanced breathing room between the input and header divider", () => {
+ render();
+
+ const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
+
+ expect(inputWrapper?.parentElement?.className).toContain(
+ "[&>[data-slot=command-input-wrapper]]:pb-1",
+ );
+ });
+
+ it("uses a low-chrome text selector with an active underline", () => {
+ render();
+
+ const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
+ expect(switcher.querySelector("kbd")).toBeNull();
+ expect(switcher.querySelectorAll("svg")).toHaveLength(0);
+ expect(screen.queryByTestId("command-panel-scope-indicator")).toBeNull();
+ for (const tab of screen.getAllByRole("tab")) {
+ expect(tab.getAttribute("tabindex")).toBe("-1");
+ expect(tab.className).toContain(
+ tab.getAttribute(ARIA_SELECTED_ATTRIBUTE) === "true"
+ ? "after:opacity-100"
+ : "after:opacity-0",
+ );
+ }
+ });
+
+ it("cycles palette scopes with Tab and Shift+Tab", () => {
+ const onScopeChange = vi.fn();
+ const props = viewProps({ onScopeChange });
+ render();
+ const input = screen.getByRole("combobox");
+ input.focus();
+
+ expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
+ expect(onScopeChange).toHaveBeenLastCalledWith("commands");
+ expect(document.activeElement).toBe(input);
+
+ expect(fireEvent.keyDown(input, { key: "Tab", shiftKey: true })).toBe(false);
+ expect(onScopeChange).toHaveBeenLastCalledWith("search-files");
+ expect(document.activeElement).toBe(input);
+ });
+
+ it("hides workspace modes and leaves Tab alone outside a task workbench", () => {
+ const onScopeChange = vi.fn();
+ render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+
+ expect(screen.queryByRole("tablist", { name: "Command palette mode" })).toBeNull();
+ expect(screen.queryByText("Switch mode")).toBeNull();
+ expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
+ expect(onScopeChange).not.toHaveBeenCalled();
+ });
+});
+
+describe("CommandPanelView mode result safety", () => {
+ it("does not delegate filtering to cmdk while palette mode groups swap", () => {
+ render();
+
+ expect(screen.getByTestId("command-root").getAttribute("data-should-filter")).toBe("false");
+ });
+
+ it("filters command results before rendering them", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Needle command")).toBeTruthy();
+ expect(screen.queryByText("Unrelated action")).toBeNull();
+ });
+
+ it("returns a stale workspace-search mode to commands when context disappears", () => {
+ const onScopeChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(onScopeChange).toHaveBeenCalledWith("commands");
+ });
+
+ it("groups file matches by repository", () => {
+ render(
+ ,
+ );
+
+ const groups = screen.getAllByTestId("file-search-repo-group");
+ expect(groups).toHaveLength(2);
+ expect(groups[0].getAttribute("data-repository")).toBe("backend");
+ expect(groups[1].getAttribute("data-repository")).toBe("frontend");
+ });
+});
diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx
index 22db973663..0e64f6e1c5 100644
--- a/apps/web/components/command-panel-footer.tsx
+++ b/apps/web/components/command-panel-footer.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { Dispatch, SetStateAction } from "react";
+import { useEffect, type Dispatch, type SetStateAction } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react";
import {
@@ -22,14 +22,25 @@ import {
sortCommandsForSearch,
} from "@/lib/commands/search";
import { formatShortcut } from "@/lib/keyboard/utils";
-import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
-import { useAppStore } from "@/components/state-provider";
import type { Task } from "@/lib/types/http";
+import type { FileSearchResult } from "@/lib/types/backend";
import { FileIcon } from "@/components/ui/file-icon";
+import { WorkspaceContentSearch } from "@/components/workspace-content-search";
+import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
+import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo";
+import {
+ CommandPanelScopeSwitcher,
+ getAdjacentCommandPanelScope,
+ isCommandPanelScopeMode,
+ type CommandPanelScopeMode,
+} from "@/components/command-panel-scope-switcher";
+import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
export const MODE_COMMANDS: CommandPanelMode = "commands";
export const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
+export const MODE_SEARCH_CONTENT: CommandPanelMode = "search-content";
const STEP_COLOR_MAP: Record = {
"bg-slate-500": "#64748b",
@@ -185,10 +196,11 @@ function CommandsListContent({
)}
{search.trim() ? (
- {/* cmdk preserves this priority pre-sort when filter scores tie. */}
- {sortCommandsForSearch(commands, search).map((cmd) => (
-
- ))}
+ {sortCommandsForSearch(commands, search)
+ .filter((cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0)
+ .map((cmd) => (
+
+ ))}
) : (
grouped.map(([group, items]) => (
@@ -204,13 +216,21 @@ function CommandsListContent({
}
type FileSearchContentProps = {
- files: string[];
+ files: FileSearchResult[];
isSearching: boolean;
search: string;
+ sessionId: string | null;
onSelect: (path: string) => void;
};
-function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchContentProps) {
+function FileSearchContent({
+ files,
+ isSearching,
+ search,
+ sessionId,
+ onSelect,
+}: FileSearchContentProps) {
+ const getRepoDisplayName = useRepoDisplayName(sessionId);
if (isSearching && files.length === 0) {
return (
@@ -220,39 +240,59 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC
}
if (search.trim() && files.length === 0) return
No files found.;
if (!search.trim()) return
Type to search files...;
- return (
-
- {files.map((filePath) => {
- const fileName = getFileName(filePath);
- const lastSlash = filePath.lastIndexOf("/");
- const dir = lastSlash > 0 ? filePath.slice(0, lastSlash) : "";
+
+ const groups = groupByRepositoryName(files, (file) => file.repository_name);
+ const singleRepo = isSingleRepoGroup(groups);
+ return groups.map((group) => (
+
+ {group.items.map((file) => {
+ const repositoryPrefix = file.repository_name ? `${file.repository_name}/` : "";
+ const displayPath = file.path.startsWith(repositoryPrefix)
+ ? file.path.slice(repositoryPrefix.length)
+ : file.path;
+ const fileName = getFileName(displayPath);
+ const lastSlash = displayPath.lastIndexOf("/");
+ const dir = lastSlash > 0 ? displayPath.slice(0, lastSlash) : "";
return (
onSelect(filePath)}
+ key={file.path}
+ value={getFileResultValue(file.path)}
+ onSelect={() => onSelect(file.path)}
forceMount
>
- {fileName}
- {dir && {dir}}
+ {fileName}
+ {dir && {dir}}
);
})}
- );
+ ));
}
function getInputPlaceholder(mode: CommandPanelMode, inputCommand: CommandItemType | null) {
if (mode === "input") return inputCommand?.inputPlaceholder ?? "Enter value...";
if (mode === "search-tasks") return "Search for tasks...";
if (mode === MODE_SEARCH_FILES) return "Search for files...";
+ if (mode === MODE_SEARCH_CONTENT) return "Search task contents…";
return "Type a command...";
}
function getEnterLabel(mode: CommandPanelMode) {
if (mode === "input") return "Confirm";
- if (mode === "search-tasks" || mode === MODE_SEARCH_FILES) return "Open";
+ if (mode === "search-tasks" || mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT) {
+ return "Open";
+ }
return "Select";
}
@@ -260,31 +300,41 @@ function getModeLabel(mode: CommandPanelMode, inputCommand: CommandItemType | nu
if (mode === "input") return inputCommand?.label;
if (mode === "search-tasks") return "Tasks";
if (mode === MODE_SEARCH_FILES) return "Files";
+ if (mode === MODE_SEARCH_CONTENT) return "Contents";
return null;
}
-function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) {
- const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
+function CommandPanelFooter({
+ mode,
+ workspaceSearchAvailable,
+}: {
+ mode: CommandPanelMode;
+ workspaceSearchAvailable: boolean;
+}) {
+ const isScopeMode = isCommandPanelScopeMode(mode);
+ const canSwitchScope = workspaceSearchAvailable && isScopeMode;
return (
- {mode === MODE_COMMANDS && (
+ {isScopeMode && (
<>
↑
↓
Navigate
-
- {formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))}
- File Search
-
+ {canSwitchScope && (
+
+ Tab
+ Switch mode
+
+ )}
>
)}
↵
{getEnterLabel(mode)}
- {mode !== MODE_COMMANDS && (
+ {!isScopeMode && (
⌫
Back
@@ -308,10 +358,17 @@ export type CommandPanelViewProps = {
search: string;
setSearch: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
+ onScopeChange: (mode: CommandPanelScopeMode) => void;
goBack: () => void;
- fileResults: string[];
+ fileResults: FileSearchResult[];
isSearchingFiles: boolean;
handleFileSelect: (filePath: string) => void;
+ contentResults: WorkspaceContentSearchResult[];
+ isSearchingContent: boolean;
+ contentSearchError: WorkspaceContentSearchError | null;
+ activeSessionId: string | null;
+ workspaceSearchAvailable: boolean;
+ handleContentSelect: (result: WorkspaceContentSearchResult) => void;
commands: CommandItemType[];
grouped: Array<[string, CommandItemType[]]>;
handleSelect: (cmd: CommandItemType) => void;
@@ -322,30 +379,141 @@ export type CommandPanelViewProps = {
handleTaskSelect: (task: Task) => void;
};
-export function CommandPanelView({
- open,
- setOpen,
+function CommandPanelInputHeader({
mode,
inputCommand,
- selectedValue,
- setSelectedValue,
search,
setSearch,
handleKeyDown,
+ onScopeChange,
goBack,
- fileResults,
- isSearchingFiles,
- handleFileSelect,
- commands,
- grouped,
- handleSelect,
- isSearching,
- taskResults,
- stepMap,
- repoMap,
- handleTaskSelect,
+ workspaceSearchAvailable,
}: CommandPanelViewProps) {
+ const isTopLevelMode = isCommandPanelScopeMode(mode);
+ const canSwitchScope = workspaceSearchAvailable && isTopLevelMode;
const modeLabel = getModeLabel(mode, inputCommand);
+ const onInputKeyDown = (event: React.KeyboardEvent) => {
+ if (
+ canSwitchScope &&
+ event.key === "Tab" &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ event.preventDefault();
+ onScopeChange(getAdjacentCommandPanelScope(mode, event.shiftKey));
+ return;
+ }
+ handleKeyDown(event);
+ };
+ return (
+
+ {!isTopLevelMode && (
+
+ )}
+
+ {canSwitchScope && }
+
+ );
+}
+
+function CommandPanelResultList(props: CommandPanelViewProps) {
+ const {
+ mode,
+ inputCommand,
+ search,
+ fileResults,
+ isSearchingFiles,
+ handleFileSelect,
+ contentResults,
+ isSearchingContent,
+ contentSearchError,
+ activeSessionId,
+ handleContentSelect,
+ commands,
+ grouped,
+ handleSelect,
+ isSearching,
+ taskResults,
+ stepMap,
+ repoMap,
+ handleTaskSelect,
+ } = props;
+ return (
+
+ {mode === MODE_COMMANDS && (
+
+ )}
+ {mode === MODE_SEARCH_FILES && (
+
+ )}
+ {mode === MODE_SEARCH_CONTENT && (
+
+ )}
+ {mode === "input" &&
+ (!search.trim() ? (
+ {inputCommand?.inputPlaceholder ?? "Enter a value..."}
+ ) : (
+ Press Enter to confirm
+ ))}
+
+ );
+}
+
+export function CommandPanelView(props: CommandPanelViewProps) {
+ const {
+ open,
+ setOpen,
+ mode,
+ selectedValue,
+ setSelectedValue,
+ workspaceSearchAvailable,
+ onScopeChange,
+ } = props;
+ const workspaceModeUnavailable =
+ !workspaceSearchAvailable && (mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT);
+ const renderedProps = workspaceModeUnavailable ? { ...props, mode: MODE_COMMANDS } : props;
+
+ useEffect(() => {
+ if (workspaceModeUnavailable) onScopeChange("commands");
+ }, [onScopeChange, workspaceModeUnavailable]);
+
return (
-
- {mode !== MODE_COMMANDS && (
-
- )}
-
-
-
- {mode === MODE_COMMANDS && (
-
- )}
- {mode === MODE_SEARCH_FILES && (
-
- )}
- {mode === "input" &&
- (!search.trim() ? (
- {inputCommand?.inputPlaceholder ?? "Enter a value..."}
- ) : (
- Press Enter to confirm
- ))}
-
-
+
+
+
);
diff --git a/apps/web/components/command-panel-scope-switcher.tsx b/apps/web/components/command-panel-scope-switcher.tsx
new file mode 100644
index 0000000000..6f1586ee06
--- /dev/null
+++ b/apps/web/components/command-panel-scope-switcher.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useAppStore } from "@/components/state-provider";
+import type { CommandPanelMode } from "@/lib/commands/types";
+import type { ConfigurableShortcutId } from "@/lib/keyboard/shortcut-overrides";
+import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
+import { formatShortcut } from "@/lib/keyboard/utils";
+import { cn } from "@/lib/utils";
+
+export type CommandPanelScopeMode = "commands" | "search-files" | "search-content";
+
+type ScopeOption = {
+ mode: CommandPanelScopeMode;
+ label: string;
+ shortcutId: ConfigurableShortcutId;
+};
+
+const SCOPE_OPTIONS: ScopeOption[] = [
+ { mode: "commands", label: "Commands", shortcutId: "SEARCH" },
+ { mode: "search-files", label: "Files", shortcutId: "FILE_SEARCH" },
+ {
+ mode: "search-content",
+ label: "Contents",
+ shortcutId: "CONTENT_SEARCH",
+ },
+];
+
+export function isCommandPanelScopeMode(mode: CommandPanelMode): mode is CommandPanelScopeMode {
+ return SCOPE_OPTIONS.some((scope) => scope.mode === mode);
+}
+
+export function getAdjacentCommandPanelScope(
+ mode: CommandPanelScopeMode,
+ reverse = false,
+): CommandPanelScopeMode {
+ const currentIndex = SCOPE_OPTIONS.findIndex((scope) => scope.mode === mode);
+ const offset = reverse ? -1 : 1;
+ const nextIndex = (currentIndex + offset + SCOPE_OPTIONS.length) % SCOPE_OPTIONS.length;
+ return SCOPE_OPTIONS[nextIndex].mode;
+}
+
+export function CommandPanelScopeSwitcher({
+ mode,
+ onScopeChange,
+}: {
+ mode: CommandPanelScopeMode;
+ onScopeChange: (mode: CommandPanelScopeMode) => void;
+}) {
+ const keyboardShortcuts = useAppStore((state) => state.userSettings.keyboardShortcuts);
+
+ return (
+
+ {SCOPE_OPTIONS.map((scope) => {
+ const active = mode === scope.mode;
+ const shortcut = formatShortcut(getShortcut(scope.shortcutId, keyboardShortcuts));
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx
index 994da9bef7..9cd1b4c21f 100644
--- a/apps/web/components/command-panel.tsx
+++ b/apps/web/components/command-panel.tsx
@@ -2,39 +2,44 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
+import { usePathname } from "@/lib/routing/client-router";
import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry";
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
import { findFirstMatchingCommand, selectCommandSearchResult } from "@/lib/commands/search";
-import { SHORTCUTS } from "@/lib/keyboard/constants";
-import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
-import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
+import { useCommandPanelShortcuts } from "@/hooks/use-command-panel-shortcuts";
+import { useContentSearchResultOpener } from "@/hooks/use-content-search-result-opener";
+import { useWorkspaceContentSearch } from "@/hooks/domains/session/use-workspace-content-search";
import { useAppStore } from "@/components/state-provider";
+import {
+ isCommandPanelScopeMode,
+ type CommandPanelScopeMode,
+} from "@/components/command-panel-scope-switcher";
import { listTasksByWorkspace } from "@/lib/api";
import { linkToTask } from "@/lib/links";
import type { Task } from "@/lib/types/http";
+import type { FileSearchResult } from "@/lib/types/backend";
import { getWebSocketClient } from "@/lib/ws/connection";
import { searchWorkspaceFiles } from "@/lib/ws/workspace-files";
import { useDockviewStore } from "@/lib/state/dockview-store";
+import { getContentSearchResultValue } from "@/components/workspace-content-search";
+import { getFileName } from "@/lib/utils/file-path";
+import { isTaskWorkspaceSearchAvailable } from "@/lib/commands/task-workspace-search";
import {
CommandPanelView,
MODE_COMMANDS,
+ MODE_SEARCH_CONTENT,
MODE_SEARCH_FILES,
getFileResultValue,
getTaskResultValue,
} from "@/components/command-panel-footer";
-function getFileName(filePath: string) {
- return filePath.split("/").pop() ?? filePath;
-}
-
-function useCommandPanelState() {
- const [mode, setMode] = useState(MODE_COMMANDS);
+function useCommandPanelState(mode: CommandPanelMode, setMode: (mode: CommandPanelMode) => void) {
const [search, setSearch] = useState("");
const [inputCommand, setInputCommand] = useState(null);
const [taskResults, setTaskResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
- const [fileResults, setFileResults] = useState([]);
+ const [fileResults, setFileResults] = useState([]);
const [isSearchingFiles, setIsSearchingFiles] = useState(false);
const [selectedValue, setSelectedValue] = useState("");
return {
@@ -57,27 +62,33 @@ function useCommandPanelState() {
};
}
-function useCommandPanelEffectRefs() {
- const debounceRef = useRef | null>(null);
- const abortRef = useRef(null);
- const fileDebounceRef = useRef | null>(null);
- return { debounceRef, abortRef, fileDebounceRef };
-}
-
type FileSearchEffectOptions = {
mode: CommandPanelMode;
search: string;
+ workspaceSearchAvailable: boolean;
activeSessionId: string | null;
- setFileResults: (files: string[]) => void;
+ setFileResults: (files: FileSearchResult[]) => void;
setIsSearchingFiles: (searching: boolean) => void;
fileDebounceRef: React.RefObject | null>;
};
function useFileSearchEffect(opts: FileSearchEffectOptions) {
- const { mode, search, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef } =
- opts;
+ const {
+ mode,
+ search,
+ workspaceSearchAvailable,
+ activeSessionId,
+ setFileResults,
+ setIsSearchingFiles,
+ fileDebounceRef,
+ } = opts;
useEffect(() => {
- if (mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId) {
+ if (
+ !workspaceSearchAvailable ||
+ mode !== MODE_SEARCH_FILES ||
+ !search.trim() ||
+ !activeSessionId
+ ) {
setFileResults([]);
setIsSearchingFiles(false);
return;
@@ -94,8 +105,8 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) {
try {
const res = await searchWorkspaceFiles(client, activeSessionId, search.trim(), 10);
if (!cancelled) {
- const files = res.files ?? [];
- setFileResults(files);
+ const results = res.results ?? (res.files ?? []).map((path) => ({ path }));
+ setFileResults(results);
}
} catch {
if (!cancelled) setFileResults([]);
@@ -107,7 +118,15 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) {
cancelled = true;
if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current);
};
- }, [activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles]);
+ }, [
+ activeSessionId,
+ fileDebounceRef,
+ mode,
+ search,
+ setFileResults,
+ setIsSearchingFiles,
+ workspaceSearchAvailable,
+ ]);
}
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
@@ -242,13 +261,26 @@ function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) {
]);
}
-function useCommandPanelEffects(
- open: boolean,
- state: ReturnType,
- workspaceId: string | null,
- activeSessionId: string | null,
- steps: { id: string; position: number; show_in_command_panel?: boolean }[],
-) {
+type CommandPanelEffectsOptions = {
+ open: boolean;
+ state: ReturnType;
+ workspaceId: string | null;
+ activeSessionId: string | null;
+ workspaceSearchAvailable: boolean;
+ steps: { id: string; position: number; show_in_command_panel?: boolean }[];
+ modeRequestVersion: number;
+};
+
+function useCommandPanelEffects(options: CommandPanelEffectsOptions) {
+ const {
+ open,
+ state,
+ workspaceId,
+ activeSessionId,
+ workspaceSearchAvailable,
+ steps,
+ modeRequestVersion,
+ } = options;
const {
mode,
search,
@@ -261,7 +293,24 @@ function useCommandPanelEffects(
setIsSearchingFiles,
setSelectedValue,
} = state;
- const { fileDebounceRef } = useCommandPanelEffectRefs();
+ const fileDebounceRef = useRef | null>(null);
+ const previousRequestVersion = useRef(modeRequestVersion);
+ useEffect(() => {
+ if (previousRequestVersion.current === modeRequestVersion) return;
+ previousRequestVersion.current = modeRequestVersion;
+ setSearch("");
+ setInputCommand(null);
+ setTaskResults([]);
+ setFileResults([]);
+ setSelectedValue("");
+ }, [
+ modeRequestVersion,
+ setFileResults,
+ setInputCommand,
+ setSearch,
+ setSelectedValue,
+ setTaskResults,
+ ]);
useEffect(() => {
if (!open) {
const t = setTimeout(() => {
@@ -289,6 +338,7 @@ function useCommandPanelEffects(
useFileSearchEffect({
mode,
search,
+ workspaceSearchAvailable,
activeSessionId,
setFileResults,
setIsSearchingFiles,
@@ -300,6 +350,7 @@ function useFirstResultSelection(
open: boolean,
state: ReturnType,
commands: CommandItemType[],
+ contentResults: ReturnType["results"],
) {
const { mode, search, taskResults, fileResults, setSelectedValue } = state;
const previousCommandsRef = useRef(commands);
@@ -333,12 +384,18 @@ function useFirstResultSelection(
if (mode === MODE_SEARCH_FILES) {
const firstFile = fileResults[0];
- setSelectedValue(firstFile ? getFileResultValue(firstFile) : "");
+ setSelectedValue(firstFile ? getFileResultValue(firstFile.path) : "");
+ return;
+ }
+
+ if (mode === MODE_SEARCH_CONTENT) {
+ const firstResult = contentResults[0];
+ setSelectedValue(firstResult ? getContentSearchResultValue(firstResult) : "");
return;
}
setSelectedValue("");
- }, [commands, fileResults, mode, open, search, setSelectedValue, taskResults]);
+ }, [commands, contentResults, fileResults, mode, open, search, setSelectedValue, taskResults]);
}
function useCommandPanelHandlers(
@@ -416,7 +473,7 @@ function useCommandPanelHandlers(
inputCommand.onInputSubmit(search.trim());
return;
}
- if (mode !== MODE_COMMANDS && e.key === "Backspace" && !search) {
+ if (!isCommandPanelScopeMode(mode) && e.key === "Backspace" && !search) {
e.preventDefault();
setMode(MODE_COMMANDS);
setSearch("");
@@ -426,6 +483,11 @@ function useCommandPanelHandlers(
[mode, search, inputCommand, setOpen, setMode, setSearch, setInputCommand],
);
+ const onScopeChange = (nextMode: CommandPanelScopeMode) => {
+ setMode(nextMode);
+ setInputCommand(null);
+ };
+
const goBack = useCallback(() => {
setMode(MODE_COMMANDS);
setSearch("");
@@ -440,20 +502,28 @@ function useCommandPanelHandlers(
handleTaskSelect,
handleFileSelect,
handleKeyDown,
+ onScopeChange,
goBack,
};
}
export function CommandPanel() {
- const { open, setOpen } = useCommandPanelOpen();
+ const { open, setOpen, mode: panelMode, setMode, modeRequestVersion } = useCommandPanelOpen();
const commands = useCommands();
+ const pathname = usePathname();
const kanbanSteps = useAppStore((state) => state.kanban.steps);
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const activeSessionId = useAppStore((s) => s.tasks.activeSessionId);
+ const workspaceSearchAvailable = useAppStore((state) =>
+ isTaskWorkspaceSearchAvailable(state, pathname),
+ );
+ const worktreePath = useAppStore((s) =>
+ activeSessionId ? (s.taskSessions.items[activeSessionId]?.worktree_path ?? null) : null,
+ );
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
const repositories = workspaceId ? (reposByWorkspace[workspaceId] ?? []) : [];
- const state = useCommandPanelState();
+ const state = useCommandPanelState(panelMode, setMode);
const {
mode,
search,
@@ -467,35 +537,34 @@ export function CommandPanel() {
setSearch,
} = state;
- useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps);
- useFirstResultSelection(open, state, commands);
+ useCommandPanelEffects({
+ open,
+ state,
+ workspaceId,
+ activeSessionId,
+ workspaceSearchAvailable,
+ steps: kanbanSteps,
+ modeRequestVersion,
+ });
+ const {
+ results: contentResults,
+ isSearching: isSearchingContent,
+ error: contentSearchError,
+ } = useWorkspaceContentSearch({
+ enabled: open && workspaceSearchAvailable && mode === MODE_SEARCH_CONTENT,
+ query: search,
+ sessionId: activeSessionId,
+ });
+ useFirstResultSelection(open, state, commands, contentResults);
- const openRef = useRef(open);
- useEffect(() => {
- openRef.current = open;
- }, [open]);
-
- const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]);
-
- const openFileSearch = useCallback(() => {
- if (openRef.current && state.mode === MODE_SEARCH_FILES) {
- setOpen(false);
- } else {
- state.setMode(MODE_SEARCH_FILES);
- state.setSearch("");
- setOpen(true);
- }
- }, [setOpen, state]);
-
- const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
- const searchShortcut = getShortcut("SEARCH", keyboardShortcuts);
- const fileSearchShortcut = getShortcut("FILE_SEARCH", keyboardShortcuts);
-
- const commandPanelShortcut = getShortcut("COMMAND_PANEL", keyboardShortcuts);
- useKeyboardShortcut(searchShortcut, toggleCommands);
- useKeyboardShortcut(commandPanelShortcut, toggleCommands);
- useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggleCommands);
- useKeyboardShortcut(fileSearchShortcut, openFileSearch);
+ useCommandPanelShortcuts({
+ open,
+ setOpen,
+ mode,
+ workspaceSearchAvailable,
+ setMode,
+ setSearch,
+ });
const {
grouped,
@@ -505,8 +574,10 @@ export function CommandPanel() {
handleTaskSelect,
handleFileSelect,
handleKeyDown,
+ onScopeChange,
goBack,
} = useCommandPanelHandlers(state, setOpen, commands, kanbanSteps, repositories);
+ const handleContentSelect = useContentSearchResultOpener(setOpen, worktreePath);
return (
({
+ onCreateEditor: null as ((view: unknown) => void) | null,
+}));
+const getMonacoInstance = vi.hoisted(() => vi.fn());
+
+vi.mock("@/components/editors/monaco/monaco-init", () => ({
+ getMonacoInstance,
+}));
+
+vi.mock("@uiw/react-codemirror", () => ({
+ default: ({ onCreateEditor }: { onCreateEditor: (view: unknown) => void }) => {
+ editorHarness.onCreateEditor = onCreateEditor;
+ return ;
+ },
+}));
+
+vi.mock("./use-codemirror-editor-state", () => ({
+ useCodeMirrorEditorState: () => ({
+ comments: [],
+ diffStats: null,
+ extensions: [],
+ floatingButtonPos: null,
+ textSelection: null,
+ commentView: null,
+ wrapEnabled: false,
+ setWrapEnabled: vi.fn(),
+ handleChange: vi.fn(),
+ }),
+}));
+
+vi.mock("./use-codemirror-walkthrough-range", () => ({
+ useCodeMirrorWalkthroughRange: () => null,
+}));
+
+vi.mock("@/components/editors/external-vcs-file-link", () => ({
+ ExternalVcsFileLink: () => null,
+ useExternalVcsFileStatus: () => null,
+}));
+
+import { CodeMirrorCodeEditor } from "./codemirror-code-editor";
+
+const FILE_PATH = "src/app.ts";
+const FRONTEND_REPO = "frontend";
+const BACKEND_REPO = "backend";
+const CONTENT = "alpha\nbravo\ncharlie";
+
+const baseProps = {
+ path: FILE_PATH,
+ content: CONTENT,
+ originalContent: CONTENT,
+ isDirty: false,
+ isSaving: false,
+ repo: FRONTEND_REPO,
+ onChange: vi.fn(),
+ onSave: vi.fn(),
+};
+
+function createEditorView(content = CONTENT) {
+ return {
+ state: EditorState.create({ doc: content }),
+ dispatch: vi.fn(),
+ focus: vi.fn(),
+ } as unknown as EditorView;
+}
+
+function renderEditor(props = baseProps) {
+ return render(
+
+
+ ,
+ );
+}
+
+function mountEditorView(view: EditorView) {
+ act(() => editorHarness.onCreateEditor?.(view));
+}
+
+afterEach(() => {
+ cleanup();
+ editorHarness.onCreateEditor = null;
+ getMonacoInstance.mockReset();
+ consumePendingCursorPosition(FILE_PATH);
+ consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO);
+ consumePendingCursorPosition(FILE_PATH, BACKEND_REPO);
+});
+
+describe("CodeMirrorCodeEditor pending cursor navigation", () => {
+ it("reveals the pending 1-based line and column for its repository", () => {
+ setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO);
+ const view = createEditorView();
+
+ renderEditor();
+ mountEditorView(view);
+
+ expect(view.dispatch).toHaveBeenCalledWith({
+ selection: { anchor: 8 },
+ effects: expect.anything(),
+ });
+ expect(view.focus).toHaveBeenCalledTimes(1);
+ expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toBeUndefined();
+ });
+
+ it("leaves a pending position for the same path in another repository untouched", () => {
+ setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO);
+ setPendingCursorPosition(FILE_PATH, 3, 2, BACKEND_REPO);
+ const view = createEditorView();
+
+ renderEditor();
+ mountEditorView(view);
+
+ expect(view.dispatch).toHaveBeenCalledWith({
+ selection: { anchor: 8 },
+ effects: expect.anything(),
+ });
+ expect(consumePendingCursorPosition(FILE_PATH, BACKEND_REPO)).toEqual({
+ line: 3,
+ column: 2,
+ });
+ });
+
+ it("clamps a pending position to the end of the document", () => {
+ setPendingCursorPosition(FILE_PATH, 99, 99, FRONTEND_REPO);
+ const view = createEditorView();
+
+ renderEditor();
+ mountEditorView(view);
+
+ expect(view.dispatch).toHaveBeenCalledWith({
+ selection: { anchor: CONTENT.length },
+ effects: expect.anything(),
+ });
+ });
+
+ it("consumes a pending position when an already-mounted editor updates", () => {
+ const view = createEditorView();
+ const rendered = renderEditor();
+ mountEditorView(view);
+ setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO);
+
+ rendered.rerender(
+
+
+ ,
+ );
+
+ expect(view.dispatch).toHaveBeenCalledWith({
+ selection: { anchor: 8 },
+ effects: expect.anything(),
+ });
+ expect(view.focus).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("CodeMirrorCodeEditor mounted cursor broker", () => {
+ it("synchronously reveals a pending position in an already-mounted pinned editor", () => {
+ const view = createEditorView();
+ const rendered = renderEditor();
+ mountEditorView(view);
+ getMonacoInstance.mockReturnValue({
+ editor: {
+ getEditors: () => [
+ {
+ getModel: () => ({ uri: { path: "/worktree/src/other.ts" } }),
+ },
+ ],
+ },
+ });
+ setPendingCursorPosition(FILE_PATH, 2, 3, FRONTEND_REPO);
+
+ const revealed = scrollEditorIfMounted(FILE_PATH, null, 2, 3, FRONTEND_REPO);
+
+ expect(revealed).toBe(true);
+ expect(view.dispatch).toHaveBeenCalledWith({
+ selection: { anchor: 8 },
+ effects: expect.anything(),
+ });
+ expect(view.focus).toHaveBeenCalledTimes(1);
+ expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toBeUndefined();
+
+ rendered.unmount();
+ vi.mocked(view.dispatch).mockClear();
+ setPendingCursorPosition(FILE_PATH, 3, 1, FRONTEND_REPO);
+
+ expect(scrollEditorIfMounted(FILE_PATH, null, 3, 1, FRONTEND_REPO)).toBe(false);
+ expect(view.dispatch).not.toHaveBeenCalled();
+ expect(consumePendingCursorPosition(FILE_PATH, FRONTEND_REPO)).toEqual({
+ line: 3,
+ column: 1,
+ });
+ });
+
+ it("does not reveal the same path from another repository", () => {
+ const view = createEditorView();
+ renderEditor();
+ mountEditorView(view);
+ setPendingCursorPosition(FILE_PATH, 3, 2, BACKEND_REPO);
+
+ const revealed = scrollEditorIfMounted(FILE_PATH, null, 3, 2, BACKEND_REPO);
+
+ expect(revealed).toBe(false);
+ expect(view.dispatch).not.toHaveBeenCalled();
+ expect(view.focus).not.toHaveBeenCalled();
+ expect(consumePendingCursorPosition(FILE_PATH, BACKEND_REPO)).toEqual({
+ line: 3,
+ column: 2,
+ });
+ });
+});
diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
index 38f2b1ef92..008441c1d1 100644
--- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
+++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import type { EditorView } from "@codemirror/view";
import { Button } from "@kandev/ui/button";
@@ -26,8 +26,13 @@ import {
ExternalVcsFileLink,
useExternalVcsFileStatus,
} from "@/components/editors/external-vcs-file-link";
+import { registerCodeMirrorCursorRevealer } from "@/hooks/file-editor-cursor";
import { useCodeMirrorEditorState } from "./use-codemirror-editor-state";
import { useCodeMirrorWalkthroughRange } from "./use-codemirror-walkthrough-range";
+import {
+ revealCodeMirrorCursor,
+ revealPendingCodeMirrorCursor,
+} from "./codemirror-cursor-navigation";
const SAVE_SHORTCUT =
typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl";
@@ -356,6 +361,15 @@ function useCodeMirrorCodeEditorSetup(props: FileEditorContentProps) {
path,
repo,
});
+ useEffect(() => {
+ if (editorView) revealPendingCodeMirrorCursor(editorView, path, repo);
+ });
+ useEffect(() => {
+ if (!editorView) return;
+ return registerCodeMirrorCursorRevealer(path, repo, (line, column) =>
+ revealCodeMirrorCursor(editorView, line, column),
+ );
+ }, [editorView, path, repo]);
const handleCreateEditor = useCallback((view: EditorView) => setEditorView(view), []);
return { wrapperRef, editorAreaRef, editorRef, state, walkthroughRange, handleCreateEditor };
}
diff --git a/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts
new file mode 100644
index 0000000000..80719568eb
--- /dev/null
+++ b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.test.ts
@@ -0,0 +1,19 @@
+import { Text } from "@codemirror/state";
+import { describe, expect, it } from "vitest";
+import { getCodeMirrorCursorOffset } from "./codemirror-cursor-navigation";
+
+const DOCUMENT = Text.of(["alpha", "bravo", "charlie"]);
+
+describe("getCodeMirrorCursorOffset", () => {
+ it("translates a 1-based line and column into a document offset", () => {
+ expect(getCodeMirrorCursorOffset(DOCUMENT, 2, 3)).toBe(8);
+ });
+
+ it("clamps a position before the document to its start", () => {
+ expect(getCodeMirrorCursorOffset(DOCUMENT, -4, 0)).toBe(0);
+ });
+
+ it("clamps a position after the document to its end", () => {
+ expect(getCodeMirrorCursorOffset(DOCUMENT, 99, 99)).toBe(DOCUMENT.length);
+ });
+});
diff --git a/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts
new file mode 100644
index 0000000000..670ef30c51
--- /dev/null
+++ b/apps/web/components/editors/codemirror/codemirror-cursor-navigation.ts
@@ -0,0 +1,34 @@
+import type { Text } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { consumePendingCursorPosition } from "@/hooks/file-editor-cursor";
+
+function clampOneBased(value: number, maximum: number): number {
+ const integer = Number.isNaN(value) ? 1 : Math.trunc(value);
+ return Math.min(Math.max(integer, 1), maximum);
+}
+
+export function getCodeMirrorCursorOffset(doc: Text, line: number, column: number): number {
+ const targetLine = doc.line(clampOneBased(line, doc.lines));
+ const targetColumn = clampOneBased(column, targetLine.length + 1);
+ return targetLine.from + targetColumn - 1;
+}
+
+export function revealCodeMirrorCursor(view: EditorView, line: number, column: number): boolean {
+ const anchor = getCodeMirrorCursorOffset(view.state.doc, line, column);
+ view.dispatch({
+ selection: { anchor },
+ effects: EditorView.scrollIntoView(anchor, { y: "center" }),
+ });
+ view.focus();
+ return true;
+}
+
+export function revealPendingCodeMirrorCursor(
+ view: EditorView,
+ path: string,
+ repo?: string,
+): boolean {
+ const pending = consumePendingCursorPosition(path, repo);
+ if (!pending) return false;
+ return revealCodeMirrorCursor(view, pending.line, pending.column);
+}
diff --git a/apps/web/components/workspace-content-search.test.tsx b/apps/web/components/workspace-content-search.test.tsx
new file mode 100644
index 0000000000..239f902ef2
--- /dev/null
+++ b/apps/web/components/workspace-content-search.test.tsx
@@ -0,0 +1,220 @@
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+vi.mock("@kandev/ui/command", () => ({
+ CommandEmpty: ({ children }: { children: ReactNode }) => {children}
,
+ CommandGroup: ({
+ children,
+ heading,
+ forceMount: _forceMount,
+ ...props
+ }: {
+ children: ReactNode;
+ heading: ReactNode;
+ forceMount?: boolean;
+ }) => (
+
+ {heading}
+ {children}
+
+ ),
+ CommandItem: ({
+ children,
+ onSelect,
+ forceMount: _forceMount,
+ value: _value,
+ ...props
+ }: {
+ children: ReactNode;
+ onSelect: () => void;
+ forceMount?: boolean;
+ value?: string;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/hooks/domains/session/use-repo-display-name", () => ({
+ useRepoDisplayName: () => (repositoryName: string) =>
+ repositoryName ? `Display ${repositoryName}` : undefined,
+}));
+
+vi.mock("@/components/ui/file-icon", () => ({
+ FileIcon: ({ fileName }: { fileName: string }) => ,
+}));
+
+import {
+ WorkspaceContentSearch,
+ getContentSearchResultValue,
+ splitPreviewByMatches,
+} from "./workspace-content-search";
+
+const REPO_A = "repo-a";
+const REPO_B = "repo-b";
+const SHARED_PATH = "src/shared.ts";
+const REPOSITORY_ATTRIBUTE = "data-repository";
+
+const results: WorkspaceContentSearchResult[] = [
+ {
+ repository_name: REPO_A,
+ path: SHARED_PATH,
+ line: 12,
+ column: 4,
+ preview: "const needle = true",
+ match_ranges: [{ start: 6, end: 12 }],
+ },
+ {
+ repository_name: REPO_B,
+ path: SHARED_PATH,
+ line: 8,
+ column: 2,
+ preview: "return needle",
+ match_ranges: [{ start: 7, end: 13 }],
+ },
+];
+
+afterEach(() => cleanup());
+
+describe("content search result identity", () => {
+ it("keeps same-path results in different repositories distinct", () => {
+ expect(getContentSearchResultValue(results[0])).not.toBe(
+ getContentSearchResultValue(results[1]),
+ );
+ });
+});
+
+describe("splitPreviewByMatches", () => {
+ it("uses backend UTF-16 offsets without splitting an emoji surrogate pair", () => {
+ expect(splitPreviewByMatches("😀 needle", [{ start: 3, end: 9 }])).toEqual([
+ { text: "😀 ", matched: false },
+ { text: "needle", matched: true },
+ ]);
+ });
+
+ it("clamps, sorts, and merges unsafe ranges", () => {
+ expect(
+ splitPreviewByMatches("abcdef", [
+ { start: 4, end: 99 },
+ { start: -2, end: 2 },
+ { start: 1, end: 3 },
+ { start: 3, end: 3 },
+ ]),
+ ).toEqual([
+ { text: "abc", matched: true },
+ { text: "d", matched: false },
+ { text: "ef", matched: true },
+ ]);
+ });
+});
+
+describe("WorkspaceContentSearch", () => {
+ it("groups results by raw repository while showing display labels", () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ const groups = screen.getAllByTestId("content-search-repo-group");
+ expect(groups).toHaveLength(2);
+ expect(groups[0].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_A);
+ expect(groups[1].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_B);
+ expect(within(groups[0]).getByText(`Display ${REPO_A}`)).toBeTruthy();
+
+ const resultRows = screen.getAllByTestId("content-search-result");
+ expect(resultRows[0].getAttribute("data-path")).toBe(SHARED_PATH);
+ expect(resultRows[0].getAttribute(REPOSITORY_ATTRIBUTE)).toBe(REPO_A);
+ expect(resultRows[0].getAttribute("data-line")).toBe("12");
+ expect(within(resultRows[0]).getByText("12").classList.contains("tabular-nums")).toBe(true);
+ expect(within(resultRows[0]).getByTestId("content-search-match").textContent).toBe("needle");
+
+ fireEvent.click(resultRows[1]);
+ expect(onSelect).toHaveBeenCalledWith(results[1]);
+ });
+
+ it("uses a non-empty label for a single repository with an empty transport key", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("content-search-repo-group").getAttribute(REPOSITORY_ATTRIBUTE)).toBe(
+ "",
+ );
+ expect(screen.getByText("Results")).toBeTruthy();
+ });
+
+ it("shows distinct empty, searching, and no-results states", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText("Type to search task contents…")).toBeTruthy();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText("Searching task workspace…")).toBeTruthy();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText("No content matches found.")).toBeTruthy();
+ });
+
+ it.each([
+ ["session-unavailable", "Content search needs an active task session."],
+ ["query-too-long", "Search queries are limited to 200 characters."],
+ ["transport-error", "Search failed. Edit the query or reopen search to retry."],
+ ] as const)("shows the %s state instead of no matches", (error, message) => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(message)).toBeTruthy();
+ expect(screen.queryByText("No content matches found.")).toBeNull();
+ });
+});
diff --git a/apps/web/components/workspace-content-search.tsx b/apps/web/components/workspace-content-search.tsx
new file mode 100644
index 0000000000..b3f293c94e
--- /dev/null
+++ b/apps/web/components/workspace-content-search.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import { IconLoader2 } from "@tabler/icons-react";
+import { CommandEmpty, CommandGroup, CommandItem } from "@kandev/ui/command";
+import { FileIcon } from "@/components/ui/file-icon";
+import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
+import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search";
+import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo";
+import type { ContentSearchMatchRange, WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+type PreviewPart = { text: string; matched: boolean };
+
+function normalizeMatchRanges(
+ previewLength: number,
+ ranges: ContentSearchMatchRange[],
+): ContentSearchMatchRange[] {
+ const normalized = ranges
+ .filter(({ start, end }) => Number.isFinite(start) && Number.isFinite(end) && end > start)
+ .map(({ start, end }) => ({
+ start: Math.max(0, Math.min(previewLength, Math.floor(start))),
+ end: Math.max(0, Math.min(previewLength, Math.floor(end))),
+ }))
+ .filter(({ start, end }) => end > start)
+ .sort((a, b) => a.start - b.start || a.end - b.end);
+
+ const merged: ContentSearchMatchRange[] = [];
+ for (const range of normalized) {
+ const previous = merged.at(-1);
+ if (previous && range.start <= previous.end) {
+ previous.end = Math.max(previous.end, range.end);
+ } else {
+ merged.push({ ...range });
+ }
+ }
+ return merged;
+}
+
+export function splitPreviewByMatches(
+ preview: string,
+ ranges: ContentSearchMatchRange[],
+): PreviewPart[] {
+ const parts: PreviewPart[] = [];
+ let cursor = 0;
+ for (const range of normalizeMatchRanges(preview.length, ranges)) {
+ if (range.start > cursor) {
+ parts.push({ text: preview.slice(cursor, range.start), matched: false });
+ }
+ parts.push({ text: preview.slice(range.start, range.end), matched: true });
+ cursor = range.end;
+ }
+ if (cursor < preview.length) parts.push({ text: preview.slice(cursor), matched: false });
+ return parts;
+}
+
+export function getContentSearchResultValue(result: WorkspaceContentSearchResult): string {
+ return `__content:${JSON.stringify([
+ result.repository_name,
+ result.path,
+ result.line,
+ result.column,
+ ])}`;
+}
+
+function getFileName(path: string): string {
+ return path.split("/").pop() || path;
+}
+
+function HighlightedPreview({ result }: { result: WorkspaceContentSearchResult }) {
+ return (
+
+ {splitPreviewByMatches(result.preview, result.match_ranges).map((part, index) =>
+ part.matched ? (
+
+ {part.text}
+
+ ) : (
+ part.text
+ ),
+ )}
+
+ );
+}
+
+function SearchResultRow({
+ result,
+ onSelect,
+}: {
+ result: WorkspaceContentSearchResult;
+ onSelect: (result: WorkspaceContentSearchResult) => void;
+}) {
+ return (
+ onSelect(result)}
+ forceMount
+ data-testid="content-search-result"
+ data-path={result.path}
+ data-repository={result.repository_name}
+ data-line={result.line}
+ className="min-h-10 cursor-pointer items-start py-2"
+ >
+
+
+
+ {result.path}
+
+ {result.line}
+
+
+
+
+
+ );
+}
+
+type WorkspaceContentSearchProps = {
+ results: WorkspaceContentSearchResult[];
+ isSearching: boolean;
+ error: WorkspaceContentSearchError | null;
+ search: string;
+ sessionId: string | null;
+ onSelect: (result: WorkspaceContentSearchResult) => void;
+};
+
+export function WorkspaceContentSearch({
+ results,
+ isSearching,
+ error,
+ search,
+ sessionId,
+ onSelect,
+}: WorkspaceContentSearchProps) {
+ const getRepoDisplayName = useRepoDisplayName(sessionId);
+ if (error === "session-unavailable") {
+ return Content search needs an active task session.;
+ }
+ if (error === "query-too-long") {
+ return Search queries are limited to 200 characters.;
+ }
+ if (error === "transport-error") {
+ return Search failed. Edit the query or reopen search to retry.;
+ }
+ if (isSearching && results.length === 0) {
+ return (
+
+
+ Searching task workspace…
+
+ );
+ }
+ if (!search.trim()) return Type to search task contents…;
+ if (results.length === 0) return No content matches found.;
+
+ const groups = groupByRepositoryName(results, (result) => result.repository_name);
+ const singleRepo = isSingleRepoGroup(groups);
+ return groups.map((group) => (
+
+ {group.items.map((result) => (
+
+ ))}
+
+ ));
+}
diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts
index bd89069170..85430185bd 100644
--- a/apps/web/e2e/tests/command-panel.spec.ts
+++ b/apps/web/e2e/tests/command-panel.spec.ts
@@ -156,16 +156,33 @@ test.describe("Command Panel", () => {
await expect(testPage.getByRole("heading", { name: /Create Pull Request/ })).toBeVisible();
});
- test("Cmd+Shift+K opens file search mode with appropriate placeholder", async ({ testPage }) => {
- const kanban = new KanbanPage(testPage);
- await kanban.goto();
+ test("Cmd+Shift+K opens file search inside a task workbench", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "File Search Shortcut E2E",
+ seedData.agentProfileId,
+ {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ executor_profile_id: seedData.worktreeExecutorProfileId,
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+ await new SessionPage(testPage).waitForLoad();
await openFileSearch(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
- // Should show the "Files" back-button breadcrumb
- await expect(dialog.getByRole("button", { name: /Files/ })).toBeVisible();
+ await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
// Should show empty state for file search
await expect(dialog.getByText("Type to search files...")).toBeVisible();
@@ -280,9 +297,24 @@ test.describe("Command Panel", () => {
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
});
- test("Backspace in file search mode returns to commands mode", async ({ testPage }) => {
- const kanban = new KanbanPage(testPage);
- await kanban.goto();
+ test("Backspace keeps the empty file-search scope active", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "File Search Backspace E2E",
+ seedData.agentProfileId,
+ {
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ executor_profile_id: seedData.worktreeExecutorProfileId,
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+ await new SessionPage(testPage).waitForLoad();
// Open file search mode
await openFileSearch(testPage);
@@ -290,14 +322,18 @@ test.describe("Command Panel", () => {
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText("Type to search files...")).toBeVisible();
- // Focus the input and press backspace on empty input to go back to commands mode
+ // Files is a peer scope, not a nested command mode, so an empty Backspace
+ // should remain in place instead of acting as hidden "back" navigation.
const input = dialog.getByRole("combobox");
await input.focus();
await input.press("Backspace");
- // Should now show the commands mode (navigation commands visible)
- await expect(dialog.getByText("Navigation")).toBeVisible({ timeout: 5_000 });
- await expect(dialog.getByText("Go to Home")).toBeVisible({ timeout: 3_000 });
+ await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(dialog.getByText("Type to search files...")).toBeVisible();
+ await expect(dialog.getByText("Navigation")).toHaveCount(0);
});
test("Cmd+K toggles the panel open and closed", async ({ testPage }) => {
diff --git a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts
new file mode 100644
index 0000000000..3e757a3513
--- /dev/null
+++ b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts
@@ -0,0 +1,185 @@
+import fs from "node:fs";
+import path from "node:path";
+import { execSync } from "node:child_process";
+import { expect, test } from "../../fixtures/test-base";
+import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
+import { SessionPage } from "../../pages/session-page";
+import { MODIFIER } from "./shared";
+
+const SEARCH_TERM = "TaskWorkspaceNeedle";
+const EXTRA_REPOSITORY_NAME = "content-search-extra";
+const TARGET_LINE = 9;
+
+function fileContent(marker: string, targetLine: number): string {
+ const lines = Array.from(
+ { length: targetLine - 1 },
+ (_, index) => `export const filler${index + 1} = ${index + 1};`,
+ );
+ lines.push(`export const ${marker} = "${SEARCH_TERM}";`);
+ return `${lines.join("\n")}\n`;
+}
+
+function initializeRepository(directory: string, gitEnv: NodeJS.ProcessEnv): GitHelper {
+ fs.mkdirSync(directory, { recursive: true });
+ execSync("git init -b main", { cwd: directory, env: gitEnv });
+ return new GitHelper(directory, gitEnv);
+}
+
+test("@search keeps workspace modes out of non-task routes", async ({ testPage }) => {
+ await testPage.goto("/settings/general");
+
+ const dialog = testPage.getByRole("dialog");
+ await testPage.keyboard.press(`${MODIFIER}+Shift+f`);
+ await expect(dialog).not.toBeVisible();
+ await testPage.keyboard.press(`${MODIFIER}+Shift+k`);
+ await expect(dialog).not.toBeVisible();
+
+ await testPage.keyboard.press(`${MODIFIER}+k`);
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole("tablist", { name: "Command palette mode" })).toHaveCount(0);
+ await expect(dialog).not.toContainText("Switch mode");
+});
+
+test("@search searches all task repositories and opens the selected match", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+}) => {
+ test.setTimeout(120_000);
+
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const sharedPath = `src/content-search-${suffix}.ts`;
+ const gitEnv = makeGitEnv(backend.tmpDir);
+
+ const primaryGit = new GitHelper(path.join(backend.tmpDir, "repos", "e2e-repo"), gitEnv);
+ primaryGit.createFile(sharedPath, fileContent("PRIMARY_REPOSITORY_MATCH", 3));
+ primaryGit.stageAll();
+ primaryGit.commit("seed primary workspace content search");
+
+ const extraRepoDir = path.join(backend.tmpDir, "repos", `${EXTRA_REPOSITORY_NAME}-${suffix}`);
+ const extraGit = initializeRepository(extraRepoDir, gitEnv);
+ extraGit.createFile(sharedPath, fileContent("EXTRA_REPOSITORY_MATCH", TARGET_LINE));
+ extraGit.stageAll();
+ extraGit.commit("seed extra workspace content search");
+ const extraRepository = await apiClient.createRepository(
+ seedData.workspaceId,
+ extraRepoDir,
+ "main",
+ { name: EXTRA_REPOSITORY_NAME },
+ );
+
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Multi-repository workspace content search",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId, extraRepository.id],
+ executor_profile_id: seedData.worktreeExecutorProfileId,
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 30_000 });
+
+ // The global content-search shortcut must win even when an editable chat
+ // surface owns focus.
+ const composer = session.activeChat().getByTestId("chat-input-editor");
+ await composer.click();
+ await testPage.keyboard.press(`${MODIFIER}+Shift+f`);
+
+ const dialog = testPage.getByRole("dialog");
+ await expect(dialog).toBeVisible({ timeout: 5_000 });
+ const input = dialog.getByRole("combobox");
+ await expect(input).toBeFocused();
+ await input.fill(SEARCH_TERM);
+
+ const groups = dialog.getByTestId("content-search-repo-group");
+ await expect(groups).toHaveCount(2, { timeout: 15_000 });
+ await expect(
+ dialog.locator(
+ `[data-testid="content-search-repo-group"][data-repository="${EXTRA_REPOSITORY_NAME}"]`,
+ ),
+ ).toHaveCount(1);
+
+ const commandsTab = dialog.getByRole("tab", { name: "Commands" });
+ const filesTab = dialog.getByRole("tab", { name: "Files" });
+ const contentsTab = dialog.getByRole("tab", { name: "Contents" });
+ await expect(contentsTab).toHaveAttribute("aria-selected", "true");
+
+ await commandsTab.click();
+ await expect(input).toBeFocused();
+ await expect(input).toHaveValue(SEARCH_TERM);
+ await expect(commandsTab).toHaveAttribute("aria-selected", "true");
+
+ await testPage.keyboard.press("Tab");
+ await expect(filesTab).toHaveAttribute("aria-selected", "true");
+ await expect(input).toHaveValue(SEARCH_TERM);
+
+ const fileQuery = path.basename(sharedPath);
+ await input.fill(fileQuery);
+ const fileMatches = dialog.locator("[cmdk-item]").filter({ hasText: fileQuery });
+ await expect(fileMatches).toHaveCount(2, { timeout: 10_000 });
+ const fileGroups = dialog.getByTestId("file-search-repo-group");
+ await expect(fileGroups).toHaveCount(2);
+ const extraFileGroup = fileGroups.filter({ hasText: EXTRA_REPOSITORY_NAME });
+ await expect(extraFileGroup).toHaveCount(1);
+ await expect(extraFileGroup.locator("[cmdk-item]").filter({ hasText: fileQuery })).toHaveCount(1);
+
+ await input.fill(SEARCH_TERM);
+ await testPage.keyboard.press("Tab");
+ await expect(contentsTab).toHaveAttribute("aria-selected", "true");
+ await expect(input).toBeFocused();
+ await testPage.keyboard.press("Tab");
+ await expect(commandsTab).toHaveAttribute("aria-selected", "true");
+ await testPage.keyboard.press("Shift+Tab");
+ await expect(contentsTab).toHaveAttribute("aria-selected", "true");
+ await expect(input).toBeFocused();
+ await testPage.keyboard.press("Shift+Tab");
+ await expect(filesTab).toHaveAttribute("aria-selected", "true");
+ await expect(dialog).toBeVisible();
+ await expect(input).toBeFocused();
+
+ await testPage.keyboard.press("Tab");
+ await expect(contentsTab).toHaveAttribute("aria-selected", "true");
+ await expect(groups).toHaveCount(2, { timeout: 15_000 });
+
+ const extraResult = dialog
+ .locator(`[data-testid="content-search-result"][data-repository="${EXTRA_REPOSITORY_NAME}"]`)
+ .filter({ hasText: sharedPath });
+ await expect(extraResult).toBeVisible();
+ await expect(extraResult).toContainText(`${TARGET_LINE}`);
+ await extraResult.click();
+
+ await expect(dialog).not.toBeVisible({ timeout: 5_000 });
+ const selectedEditor = testPage
+ .locator(".monaco-editor:visible .view-lines")
+ .filter({ hasText: "EXTRA_REPOSITORY_MATCH" });
+ await expect(selectedEditor).toBeVisible({ timeout: 15_000 });
+
+ await expect
+ .poll(
+ () =>
+ testPage.evaluate((marker) => {
+ type Editor = {
+ getModel: () => { getValue: () => string } | null;
+ getPosition: () => { lineNumber: number } | null;
+ };
+ type MonacoWindow = Window & {
+ monaco?: { editor: { getEditors: () => Editor[] } };
+ };
+ const editors = (window as MonacoWindow).monaco?.editor.getEditors() ?? [];
+ return (
+ editors.find((editor) => editor.getModel()?.getValue().includes(marker))?.getPosition()
+ ?.lineNumber ?? null
+ );
+ }, "EXTRA_REPOSITORY_MATCH"),
+ { timeout: 10_000 },
+ )
+ .toBe(TARGET_LINE);
+});
diff --git a/apps/web/hooks/domains/session/use-workspace-content-search.test.ts b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts
new file mode 100644
index 0000000000..38faa47677
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts
@@ -0,0 +1,194 @@
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockSearchWorkspaceContent = vi.fn();
+const mockClient = { request: vi.fn() };
+
+vi.mock("@/lib/ws/connection", () => ({
+ getWebSocketClient: () => mockClient,
+}));
+
+vi.mock("@/lib/ws/workspace-files", () => ({
+ searchWorkspaceContent: (...args: unknown[]) => mockSearchWorkspaceContent(...args),
+}));
+
+import { useWorkspaceContentSearch } from "./use-workspace-content-search";
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ mockSearchWorkspaceContent.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+describe("useWorkspaceContentSearch request lifecycle", () => {
+ it("debounces a trimmed query for the active session", async () => {
+ const results = [
+ {
+ repository_name: "web",
+ path: "src/app.tsx",
+ line: 4,
+ column: 2,
+ preview: "needle",
+ match_ranges: [{ start: 0, end: 6 }],
+ },
+ ];
+ mockSearchWorkspaceContent.mockResolvedValue({ results });
+ const { result } = renderHook(() =>
+ useWorkspaceContentSearch({
+ enabled: true,
+ query: " needle ",
+ sessionId: "session-1",
+ }),
+ );
+
+ expect(result.current.isSearching).toBe(true);
+ await act(async () => vi.advanceTimersByTimeAsync(249));
+ expect(mockSearchWorkspaceContent).not.toHaveBeenCalled();
+
+ await act(async () => vi.advanceTimersByTimeAsync(1));
+
+ expect(mockSearchWorkspaceContent).toHaveBeenCalledWith(mockClient, "session-1", "needle", 50);
+ expect(result.current.results).toEqual(results);
+ expect(result.current.isSearching).toBe(false);
+ });
+
+ it("clears the previous query results while the next search is pending", async () => {
+ const previousResults = [
+ {
+ repository_name: "web",
+ path: "src/old.ts",
+ line: 1,
+ column: 1,
+ preview: "previous",
+ match_ranges: [],
+ },
+ ];
+ mockSearchWorkspaceContent
+ .mockResolvedValueOnce({ results: previousResults })
+ .mockResolvedValueOnce({ results: [] });
+ const { result, rerender } = renderHook(
+ ({ query }) => useWorkspaceContentSearch({ enabled: true, query, sessionId: "session-1" }),
+ { initialProps: { query: "previous" } },
+ );
+ await act(async () => vi.advanceTimersByTimeAsync(250));
+ expect(result.current.results).toEqual(previousResults);
+
+ rerender({ query: "next" });
+
+ expect(result.current.results).toEqual([]);
+ expect(result.current.isSearching).toBe(true);
+ });
+
+ it("clears results without sending when search is disabled", async () => {
+ mockSearchWorkspaceContent.mockResolvedValue({ results: [] });
+ const { result, rerender } = renderHook(
+ ({ enabled, query }) => useWorkspaceContentSearch({ enabled, query, sessionId: "session-1" }),
+ { initialProps: { enabled: true, query: "needle" } },
+ );
+
+ rerender({ enabled: false, query: "needle" });
+ await act(async () => vi.runAllTimersAsync());
+
+ expect(mockSearchWorkspaceContent).not.toHaveBeenCalled();
+ expect(result.current.results).toEqual([]);
+ expect(result.current.isSearching).toBe(false);
+ });
+});
+
+describe("useWorkspaceContentSearch validation and failures", () => {
+ it("reports when the task has no active session", () => {
+ const { result } = renderHook(() =>
+ useWorkspaceContentSearch({
+ enabled: true,
+ query: "needle",
+ sessionId: null,
+ }),
+ );
+
+ expect(mockSearchWorkspaceContent).not.toHaveBeenCalled();
+ expect(result.current.error).toBe("session-unavailable");
+ });
+
+ it("rejects queries over 200 Unicode code points without sending", async () => {
+ const { result } = renderHook(() =>
+ useWorkspaceContentSearch({
+ enabled: true,
+ query: "😀".repeat(201),
+ sessionId: "session-1",
+ }),
+ );
+ await act(async () => vi.runAllTimersAsync());
+
+ expect(mockSearchWorkspaceContent).not.toHaveBeenCalled();
+ expect(result.current.error).toBe("query-too-long");
+ expect(result.current.isSearching).toBe(false);
+ });
+
+ it("distinguishes a transport failure from an empty result", async () => {
+ mockSearchWorkspaceContent.mockRejectedValue(new Error("offline"));
+ const { result } = renderHook(() =>
+ useWorkspaceContentSearch({
+ enabled: true,
+ query: "needle",
+ sessionId: "session-1",
+ }),
+ );
+
+ await act(async () => vi.advanceTimersByTimeAsync(250));
+
+ expect(result.current.results).toEqual([]);
+ expect(result.current.error).toBe("transport-error");
+ expect(result.current.isSearching).toBe(false);
+ });
+});
+
+describe("useWorkspaceContentSearch stale responses", () => {
+ it("ignores a response from an outdated query", async () => {
+ const firstResults = [
+ {
+ repository_name: "",
+ path: "old.ts",
+ line: 1,
+ column: 1,
+ preview: "first",
+ match_ranges: [],
+ },
+ ];
+ const secondResults = [
+ {
+ repository_name: "",
+ path: "new.ts",
+ line: 1,
+ column: 1,
+ preview: "second",
+ match_ranges: [],
+ },
+ ];
+ let resolveFirst: ((value: { results: typeof firstResults }) => void) | undefined;
+ mockSearchWorkspaceContent
+ .mockImplementationOnce(
+ () =>
+ new Promise<{ results: typeof firstResults }>((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockResolvedValueOnce({ results: secondResults });
+ const { result, rerender } = renderHook(
+ ({ query }) => useWorkspaceContentSearch({ enabled: true, query, sessionId: "session-1" }),
+ { initialProps: { query: "first" } },
+ );
+ await act(async () => vi.advanceTimersByTimeAsync(250));
+
+ rerender({ query: "second" });
+ await act(async () => vi.advanceTimersByTimeAsync(250));
+ await act(async () => resolveFirst?.({ results: firstResults }));
+
+ expect(mockSearchWorkspaceContent).toHaveBeenCalledTimes(2);
+ expect(result.current.results).toEqual(secondResults);
+ expect(result.current.isSearching).toBe(false);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-workspace-content-search.ts b/apps/web/hooks/domains/session/use-workspace-content-search.ts
new file mode 100644
index 0000000000..08007ea79d
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-workspace-content-search.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+import { getWebSocketClient } from "@/lib/ws/connection";
+import { searchWorkspaceContent } from "@/lib/ws/workspace-files";
+
+const CONTENT_SEARCH_DEBOUNCE_MS = 250;
+const CONTENT_SEARCH_LIMIT_PER_REPO = 50;
+const MAX_CONTENT_SEARCH_CODE_POINTS = 200;
+
+export type WorkspaceContentSearchError =
+ | "session-unavailable"
+ | "query-too-long"
+ | "transport-error";
+
+type WorkspaceContentSearchOptions = {
+ enabled: boolean;
+ query: string;
+ sessionId: string | null;
+};
+
+export function useWorkspaceContentSearch({
+ enabled,
+ query,
+ sessionId,
+}: WorkspaceContentSearchOptions) {
+ const [results, setResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const normalizedQuery = query.trim();
+ if (!enabled) {
+ setResults([]);
+ setIsSearching(false);
+ setError(null);
+ return;
+ }
+ if (!sessionId) {
+ setResults([]);
+ setIsSearching(false);
+ setError("session-unavailable");
+ return;
+ }
+ if (!normalizedQuery) {
+ setResults([]);
+ setIsSearching(false);
+ setError(null);
+ return;
+ }
+ if (Array.from(normalizedQuery).length > MAX_CONTENT_SEARCH_CODE_POINTS) {
+ setResults([]);
+ setIsSearching(false);
+ setError("query-too-long");
+ return;
+ }
+
+ let cancelled = false;
+ setResults([]);
+ setIsSearching(true);
+ setError(null);
+ const timer = setTimeout(async () => {
+ const client = getWebSocketClient();
+ if (!client) {
+ if (!cancelled) {
+ setResults([]);
+ setError("transport-error");
+ setIsSearching(false);
+ }
+ return;
+ }
+
+ try {
+ const response = await searchWorkspaceContent(
+ client,
+ sessionId,
+ normalizedQuery,
+ CONTENT_SEARCH_LIMIT_PER_REPO,
+ );
+ if (!cancelled) {
+ setResults(response.results ?? []);
+ setError(null);
+ }
+ } catch {
+ if (!cancelled) {
+ setResults([]);
+ setError("transport-error");
+ }
+ } finally {
+ if (!cancelled) setIsSearching(false);
+ }
+ }, CONTENT_SEARCH_DEBOUNCE_MS);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [enabled, query, sessionId]);
+
+ return { results, isSearching, error };
+}
diff --git a/apps/web/hooks/file-editor-cursor.ts b/apps/web/hooks/file-editor-cursor.ts
index 2394204b19..c4c5ba44cf 100644
--- a/apps/web/hooks/file-editor-cursor.ts
+++ b/apps/web/hooks/file-editor-cursor.ts
@@ -3,6 +3,8 @@ import { walkthroughFileMatches } from "@/lib/diff/walkthrough-match";
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
const pendingCursorPositions = new Map();
+type CodeMirrorCursorRevealer = (line: number, column: number) => boolean;
+const codeMirrorCursorRevealers = new Map>();
function pendingCursorKey(path: string, repo?: string): string {
return buildRepoScopedItemId(path, repo);
@@ -27,6 +29,39 @@ export function consumePendingCursorPosition(
return pos;
}
+export function registerCodeMirrorCursorRevealer(
+ path: string,
+ repo: string | undefined,
+ reveal: CodeMirrorCursorRevealer,
+): () => void {
+ const key = pendingCursorKey(path, repo);
+ const revealers = codeMirrorCursorRevealers.get(key) ?? new Set();
+ revealers.add(reveal);
+ codeMirrorCursorRevealers.set(key, revealers);
+
+ return () => {
+ revealers.delete(reveal);
+ if (revealers.size === 0) codeMirrorCursorRevealers.delete(key);
+ };
+}
+
+function revealMountedCodeMirror(
+ path: string,
+ repo: string | undefined,
+ line: number,
+ column: number,
+): boolean {
+ const revealers = codeMirrorCursorRevealers.get(pendingCursorKey(path, repo));
+ if (!revealers) return false;
+
+ for (const reveal of [...revealers].reverse()) {
+ if (!reveal(line, column)) continue;
+ consumePendingCursorPosition(path, repo);
+ return true;
+ }
+ return false;
+}
+
function pathSegments(path: string): string[] {
return path.trim().replaceAll("\\", "/").split("/").filter(Boolean);
}
@@ -47,6 +82,19 @@ function editorModelMatches(modelPath: string, monacoPath: string, path: string,
return exactMatch || walkthroughFileMatches(modelPath, path);
}
+function mountedMonacoModelMatches(
+ modelPath: string,
+ monacoPath: string,
+ path: string,
+ worktreePath: string | null,
+ repo?: string,
+): boolean {
+ if (worktreePath && (modelPath === `/${monacoPath}` || modelPath === monacoPath)) return true;
+ if (repo) return editorModelMatches(modelPath, monacoPath, path, repo);
+ if (worktreePath) return false;
+ return editorModelMatches(modelPath, monacoPath, path);
+}
+
export function scrollEditorIfMounted(
path: string,
worktreePath: string | null,
@@ -55,23 +103,21 @@ export function scrollEditorIfMounted(
repo?: string,
): boolean {
const monaco = getMonacoInstance();
- if (!monaco) return false;
-
- const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
- for (const editor of monaco.editor.getEditors()) {
- const model = editor.getModel();
- if (!model) continue;
- const modelPath = model.uri.path;
- const matches = worktreePath
- ? modelPath === `/${monacoPath}` || modelPath === monacoPath
- : editorModelMatches(modelPath, monacoPath, path, repo);
- if (matches) {
- consumePendingCursorPosition(path, repo);
- editor.setPosition({ lineNumber: line, column });
- editor.revealLineInCenter(line);
- editor.focus();
- return true;
+ if (monaco) {
+ const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
+ for (const editor of monaco.editor.getEditors()) {
+ const model = editor.getModel();
+ if (!model) continue;
+ const modelPath = model.uri.path;
+ const matches = mountedMonacoModelMatches(modelPath, monacoPath, path, worktreePath, repo);
+ if (matches) {
+ consumePendingCursorPosition(path, repo);
+ editor.setPosition({ lineNumber: line, column });
+ editor.revealLineInCenter(line);
+ editor.focus();
+ return true;
+ }
}
}
- return false;
+ return revealMountedCodeMirror(path, repo, line, column);
}
diff --git a/apps/web/hooks/use-app-shortcuts.test.ts b/apps/web/hooks/use-app-shortcuts.test.ts
index 37b40e0687..e3725cbf8f 100644
--- a/apps/web/hooks/use-app-shortcuts.test.ts
+++ b/apps/web/hooks/use-app-shortcuts.test.ts
@@ -3,12 +3,27 @@ import { renderHook, cleanup } from "@testing-library/react";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
const mockToggleAppSidebar = vi.fn();
+const mockOpenMode = vi.fn();
let mockShortcuts: StoredShortcutOverrides = {};
+let mockPathname = "/t/task-1";
+let mockActiveTaskId: string | null = "task-1";
+let mockActiveSessionId: string | null = "session-1";
+let mockSessionTaskId: string | null = "task-1";
function buildState() {
return {
userSettings: { keyboardShortcuts: mockShortcuts },
toggleAppSidebar: mockToggleAppSidebar,
+ tasks: {
+ activeTaskId: mockActiveTaskId,
+ activeSessionId: mockActiveSessionId,
+ },
+ taskSessions: {
+ items:
+ mockActiveSessionId && mockSessionTaskId
+ ? { [mockActiveSessionId]: { task_id: mockSessionTaskId } }
+ : {},
+ },
};
}
@@ -16,6 +31,14 @@ vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({ getState: () => buildState() }),
}));
+vi.mock("@/lib/commands/command-registry", () => ({
+ useCommandPanelOpen: () => ({ openMode: mockOpenMode }),
+}));
+
+vi.mock("@/lib/routing/client-router", () => ({
+ usePathname: () => mockPathname,
+}));
+
import { useAppShortcuts } from "./use-app-shortcuts";
/** Bind TOGGLE_SIDEBAR to Cmd/Ctrl+B (it is unbound by default). */
@@ -31,6 +54,22 @@ function pressB(modifier: "ctrlKey" | "metaKey", target: EventTarget = window):
return event;
}
+function pressContentSearch(
+ modifier: "ctrlKey" | "metaKey",
+ target: EventTarget = window,
+): KeyboardEvent {
+ const init: KeyboardEventInit = {
+ key: "f",
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ };
+ init[modifier] = true;
+ const event = new KeyboardEvent("keydown", init);
+ target.dispatchEvent(event);
+ return event;
+}
+
// Cover both `ctrlOrCmd` branches: Ctrl on Windows/Linux, Cmd (metaKey) on macOS.
const pressCtrlB = (target?: EventTarget) => pressB("ctrlKey", target);
const pressMetaB = (target?: EventTarget) => pressB("metaKey", target);
@@ -38,7 +77,12 @@ const pressMetaB = (target?: EventTarget) => pressB("metaKey", target);
describe("useAppShortcuts", () => {
beforeEach(() => {
mockToggleAppSidebar.mockClear();
+ mockOpenMode.mockClear();
mockShortcuts = {};
+ mockPathname = "/t/task-1";
+ mockActiveTaskId = "task-1";
+ mockActiveSessionId = "session-1";
+ mockSessionTaskId = "task-1";
});
// renderHook attaches a window listener; unmount it between tests so stale
@@ -88,4 +132,72 @@ describe("useAppShortcuts", () => {
pressCtrlB();
expect(mockToggleAppSidebar).not.toHaveBeenCalled();
});
+
+ it("opens task content search before an editable target handles Cmd/Ctrl+Shift+F", () => {
+ renderHook(() => useAppShortcuts());
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+
+ const event = pressContentSearch("ctrlKey", input);
+
+ expect(mockOpenMode).toHaveBeenCalledWith("search-content");
+ expect(event.defaultPrevented).toBe(true);
+ input.remove();
+ });
+
+ it("opens task content search with Cmd on macOS", () => {
+ renderHook(() => useAppShortcuts());
+
+ const event = pressContentSearch("metaKey");
+
+ expect(mockOpenMode).toHaveBeenCalledWith("search-content");
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("does not intercept content search outside an active task workbench", () => {
+ mockPathname = "/";
+ renderHook(() => useAppShortcuts());
+
+ const event = pressContentSearch("ctrlKey");
+
+ expect(mockOpenMode).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it("uses a stored content-search override instead of its default", () => {
+ mockShortcuts = {
+ CONTENT_SEARCH: { key: "g", modifiers: { ctrlOrCmd: true, shift: true } },
+ };
+ renderHook(() => useAppShortcuts());
+
+ pressContentSearch("ctrlKey");
+ expect(mockOpenMode).not.toHaveBeenCalled();
+
+ const event = new KeyboardEvent("keydown", {
+ key: "g",
+ ctrlKey: true,
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(event);
+
+ expect(mockOpenMode).toHaveBeenCalledWith("search-content");
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("leaves panel-local Cmd/Ctrl+F untouched", () => {
+ renderHook(() => useAppShortcuts());
+ const event = new KeyboardEvent("keydown", {
+ key: "f",
+ ctrlKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+
+ window.dispatchEvent(event);
+
+ expect(mockOpenMode).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
});
diff --git a/apps/web/hooks/use-app-shortcuts.ts b/apps/web/hooks/use-app-shortcuts.ts
index 340df90302..f19d855b5c 100644
--- a/apps/web/hooks/use-app-shortcuts.ts
+++ b/apps/web/hooks/use-app-shortcuts.ts
@@ -4,6 +4,9 @@ import { useEffect } from "react";
import { useAppStoreApi } from "@/components/state-provider";
import { isEditableKeydownTarget, matchesShortcut } from "@/lib/keyboard/utils";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
+import { useCommandPanelOpen } from "@/lib/commands/command-registry";
+import { isTaskWorkspaceSearchAvailable } from "@/lib/commands/task-workspace-search";
+import { usePathname } from "@/lib/routing/client-router";
/**
* App-root keyboard shortcuts that must fire on every route — not just inside
@@ -33,13 +36,23 @@ import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
*/
export function useAppShortcuts() {
const appStore = useAppStoreApi();
+ const { openMode } = useCommandPanelOpen();
+ const pathname = usePathname();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
- if (isEditableKeydownTarget(e)) return;
const overrides = appStore.getState().userSettings.keyboardShortcuts;
+ if (matchesShortcut(e, getShortcut("CONTENT_SEARCH", overrides))) {
+ if (!isTaskWorkspaceSearchAvailable(appStore.getState(), pathname)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ openMode("search-content");
+ return;
+ }
+ if (isEditableKeydownTarget(e)) return;
+
if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) {
e.preventDefault();
e.stopPropagation();
@@ -51,5 +64,5 @@ export function useAppShortcuts() {
// xterm.js) can swallow it — mirrors useEditorKeybinds.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
- }, [appStore]);
+ }, [appStore, openMode, pathname]);
}
diff --git a/apps/web/hooks/use-command-panel-shortcuts.test.ts b/apps/web/hooks/use-command-panel-shortcuts.test.ts
new file mode 100644
index 0000000000..8f587a585f
--- /dev/null
+++ b/apps/web/hooks/use-command-panel-shortcuts.test.ts
@@ -0,0 +1,77 @@
+import { act, cleanup, renderHook } from "@testing-library/react";
+import type { KeyboardShortcut } from "@/lib/keyboard/constants";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+type ShortcutRegistration = {
+ shortcut: KeyboardShortcut;
+ callback: () => void;
+ enabled: boolean;
+};
+
+const registrations: ShortcutRegistration[] = [];
+
+vi.mock("@/hooks/use-keyboard-shortcut", () => ({
+ useKeyboardShortcut: (
+ shortcut: KeyboardShortcut,
+ callback: () => void,
+ options?: { enabled?: boolean },
+ ) => {
+ registrations.push({ shortcut, callback, enabled: options?.enabled ?? true });
+ },
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({ userSettings: { keyboardShortcuts: {} } }),
+}));
+
+import { useCommandPanelShortcuts } from "./use-command-panel-shortcuts";
+
+function findShortcut(key: string, shift = false) {
+ return registrations.find(
+ ({ shortcut }) => shortcut.key === key && Boolean(shortcut.modifiers?.shift) === shift,
+ );
+}
+
+beforeEach(() => registrations.splice(0));
+afterEach(() => cleanup());
+
+describe("useCommandPanelShortcuts", () => {
+ it("switches an open file palette to commands instead of closing it", () => {
+ const setMode = vi.fn();
+ const setOpen = vi.fn();
+ const setSearch = vi.fn();
+ renderHook(() =>
+ useCommandPanelShortcuts({
+ open: true,
+ mode: "search-files",
+ workspaceSearchAvailable: true,
+ setMode,
+ setOpen,
+ setSearch,
+ }),
+ );
+
+ act(() => findShortcut("k")?.callback());
+
+ expect(setMode).toHaveBeenCalledWith("commands");
+ expect(setSearch).toHaveBeenCalledWith("");
+ expect(setOpen).toHaveBeenCalledWith(true);
+ });
+
+ it("does not register file search outside an active task workbench", () => {
+ const options = {
+ open: false,
+ mode: "commands" as const,
+ workspaceSearchAvailable: false,
+ setMode: vi.fn(),
+ setOpen: vi.fn(),
+ setSearch: vi.fn(),
+ };
+
+ renderHook(() => useCommandPanelShortcuts(options));
+
+ expect(findShortcut("k", true)?.enabled).toBe(false);
+ expect(findShortcut("k")?.enabled).toBe(true);
+ });
+});
diff --git a/apps/web/hooks/use-command-panel-shortcuts.ts b/apps/web/hooks/use-command-panel-shortcuts.ts
new file mode 100644
index 0000000000..c9909ddbf2
--- /dev/null
+++ b/apps/web/hooks/use-command-panel-shortcuts.ts
@@ -0,0 +1,59 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { useAppStore } from "@/components/state-provider";
+import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
+import type { CommandPanelMode } from "@/lib/commands/types";
+import { SHORTCUTS } from "@/lib/keyboard/constants";
+import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
+
+type CommandPanelShortcutOptions = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ mode: CommandPanelMode;
+ workspaceSearchAvailable: boolean;
+ setMode: (mode: CommandPanelMode) => void;
+ setSearch: (search: string) => void;
+};
+
+export function useCommandPanelShortcuts({
+ open,
+ setOpen,
+ mode,
+ workspaceSearchAvailable,
+ setMode,
+ setSearch,
+}: CommandPanelShortcutOptions) {
+ const openRef = useRef(open);
+ useEffect(() => {
+ openRef.current = open;
+ }, [open]);
+
+ const openCommands = useCallback(() => {
+ if (openRef.current && mode === "commands") {
+ setOpen(false);
+ return;
+ }
+ setMode("commands");
+ setSearch("");
+ setOpen(true);
+ }, [mode, setMode, setOpen, setSearch]);
+
+ const openFileSearch = useCallback(() => {
+ if (openRef.current && mode === "search-files") {
+ setOpen(false);
+ return;
+ }
+ setMode("search-files");
+ setSearch("");
+ setOpen(true);
+ }, [mode, setMode, setOpen, setSearch]);
+
+ const keyboardShortcuts = useAppStore((state) => state.userSettings.keyboardShortcuts);
+ useKeyboardShortcut(getShortcut("SEARCH", keyboardShortcuts), openCommands);
+ useKeyboardShortcut(getShortcut("COMMAND_PANEL", keyboardShortcuts), openCommands);
+ useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, openCommands);
+ useKeyboardShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts), openFileSearch, {
+ enabled: workspaceSearchAvailable,
+ });
+}
diff --git a/apps/web/hooks/use-content-search-result-opener.test.ts b/apps/web/hooks/use-content-search-result-opener.test.ts
new file mode 100644
index 0000000000..74e3a5c28c
--- /dev/null
+++ b/apps/web/hooks/use-content-search-result-opener.test.ts
@@ -0,0 +1,31 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+const mockOpenContentSearchResult = vi.fn();
+
+vi.mock("@/lib/commands/content-search-selection", () => ({
+ openContentSearchResult: (...args: unknown[]) => mockOpenContentSearchResult(...args),
+}));
+
+import { useContentSearchResultOpener } from "./use-content-search-result-opener";
+
+describe("useContentSearchResultOpener", () => {
+ it("closes the palette and opens the selected workspace result", () => {
+ const setOpen = vi.fn();
+ const selected = {
+ repository_name: "web",
+ path: "src/app.tsx",
+ line: 3,
+ column: 2,
+ preview: "needle",
+ match_ranges: [],
+ } satisfies WorkspaceContentSearchResult;
+ const { result } = renderHook(() => useContentSearchResultOpener(setOpen, "/tasks/task-1"));
+
+ act(() => result.current(selected));
+
+ expect(setOpen).toHaveBeenCalledWith(false);
+ expect(mockOpenContentSearchResult).toHaveBeenCalledWith(selected, "/tasks/task-1");
+ });
+});
diff --git a/apps/web/hooks/use-content-search-result-opener.ts b/apps/web/hooks/use-content-search-result-opener.ts
new file mode 100644
index 0000000000..34837bd79f
--- /dev/null
+++ b/apps/web/hooks/use-content-search-result-opener.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { useCallback } from "react";
+import { openContentSearchResult } from "@/lib/commands/content-search-selection";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+export function useContentSearchResultOpener(
+ setOpen: (open: boolean) => void,
+ worktreePath: string | null,
+) {
+ return useCallback(
+ (result: WorkspaceContentSearchResult) => {
+ setOpen(false);
+ openContentSearchResult(result, worktreePath);
+ },
+ [setOpen, worktreePath],
+ );
+}
diff --git a/apps/web/hooks/use-file-editors.test.tsx b/apps/web/hooks/use-file-editors.test.tsx
index 8d3eb2fd44..387812df2b 100644
--- a/apps/web/hooks/use-file-editors.test.tsx
+++ b/apps/web/hooks/use-file-editors.test.tsx
@@ -77,6 +77,34 @@ describe("scrollEditorIfMounted", () => {
expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined();
});
+ it("uses repo-scoped matching when a worktree path is also available", () => {
+ const wrongRepoEditor = createEditor("/worktree/backend/src/app.ts");
+ const repoEditor = createEditor("/worktree/frontend/src/app.ts");
+ getMonacoInstance.mockReturnValue({
+ editor: { getEditors: () => [wrongRepoEditor, repoEditor] },
+ });
+ setPendingCursorPosition(APP_PATH, 12, 1, REPO);
+
+ expect(scrollEditorIfMounted(APP_PATH, WORKTREE_PATH, 42, 3, REPO)).toBe(true);
+
+ expect(wrongRepoEditor.setPosition).not.toHaveBeenCalled();
+ expect(repoEditor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 });
+ expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined();
+ });
+
+ it("scrolls a repo-scoped request in Monaco's worktree-relative model", () => {
+ const editor = createEditor(WORKTREE_APP_PATH);
+ getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } });
+ setPendingCursorPosition(APP_PATH, 12, 1, REPO);
+
+ expect(scrollEditorIfMounted(APP_PATH, WORKTREE_PATH, 42, 3, REPO)).toBe(true);
+
+ expect(editor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 });
+ expect(editor.revealLineInCenter).toHaveBeenCalledWith(42);
+ expect(editor.focus).toHaveBeenCalledTimes(1);
+ expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined();
+ });
+
it("does not suffix-scroll a repo-scoped request into an unscoped model path", () => {
const editor = createEditor(WORKTREE_APP_PATH);
getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } });
diff --git a/apps/web/hooks/use-plugin-shortcuts.test.ts b/apps/web/hooks/use-plugin-shortcuts.test.ts
index 9b651f3024..3d97431a98 100644
--- a/apps/web/hooks/use-plugin-shortcuts.test.ts
+++ b/apps/web/hooks/use-plugin-shortcuts.test.ts
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
+import { createElement, type ReactNode } from "react";
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
import type { PluginRecord } from "@/lib/types/plugins";
import { pluginRegistry } from "@/lib/plugins/registry";
+import { CommandRegistryProvider } from "@/lib/commands/command-registry";
let mockOverrides: StoredShortcutOverrides = {};
let mockItems: PluginRecord[] = [];
@@ -61,6 +63,10 @@ const TOGGLE_KEYBINDING = { id: "toggle", default: "mod+shift+e", description: "
/** A free combo (not used by any core shortcut) shared by two plugins bound to the same "open" id. */
const OPEN_COMBO = "mod+shift+o";
+function commandRegistryWrapper({ children }: { children: ReactNode }) {
+ return createElement(CommandRegistryProvider, null, children);
+}
+
function withToggleKeybinding(overrides: Partial = {}): PluginRecord {
return makePlugin({ ui: { keybindings: [TOGGLE_KEYBINDING] }, ...overrides });
}
@@ -243,10 +249,13 @@ describe("core vs plugin shortcut precedence", () => {
const pluginHandler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
- renderHook(() => {
- useAppShortcuts();
- usePluginShortcuts();
- });
+ renderHook(
+ () => {
+ useAppShortcuts();
+ usePluginShortcuts();
+ },
+ { wrapper: commandRegistryWrapper },
+ );
const event = pressKey("b", { ctrlKey: true });
expect(mockToggleAppSidebar).toHaveBeenCalledTimes(1);
@@ -259,10 +268,13 @@ describe("core vs plugin shortcut precedence", () => {
const handler = vi.fn();
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
- renderHook(() => {
- useAppShortcuts();
- usePluginShortcuts();
- });
+ renderHook(
+ () => {
+ useAppShortcuts();
+ usePluginShortcuts();
+ },
+ { wrapper: commandRegistryWrapper },
+ );
pressKey("e", { ctrlKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
diff --git a/apps/web/lib/commands/command-registry.test.tsx b/apps/web/lib/commands/command-registry.test.tsx
new file mode 100644
index 0000000000..c3edd135eb
--- /dev/null
+++ b/apps/web/lib/commands/command-registry.test.tsx
@@ -0,0 +1,30 @@
+import { act, cleanup, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it } from "vitest";
+import { CommandRegistryProvider, useCommandPanelOpen } from "./command-registry";
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+afterEach(() => cleanup());
+
+describe("command panel mode requests", () => {
+ it("opens the panel in a requested mode", () => {
+ const { result } = renderHook(() => useCommandPanelOpen(), { wrapper });
+
+ act(() => result.current.openMode("search-content"));
+
+ expect(result.current.open).toBe(true);
+ expect(result.current.mode).toBe("search-content");
+ });
+
+ it("keeps direct mode changes available to the mounted panel", () => {
+ const { result } = renderHook(() => useCommandPanelOpen(), { wrapper });
+
+ act(() => result.current.setMode("search-files"));
+
+ expect(result.current.mode).toBe("search-files");
+ expect(result.current.open).toBe(false);
+ });
+});
diff --git a/apps/web/lib/commands/command-registry.tsx b/apps/web/lib/commands/command-registry.tsx
index 15cd17bc88..da4958fca5 100644
--- a/apps/web/lib/commands/command-registry.tsx
+++ b/apps/web/lib/commands/command-registry.tsx
@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import type { CommandItem } from "./types";
+import type { CommandPanelMode } from "./types";
type CommandRegistryContextValue = {
register: (sourceId: string, commands: CommandItem[]) => void;
@@ -18,6 +19,10 @@ type CommandRegistryContextValue = {
getSnapshot: () => CommandItem[];
open: boolean;
setOpen: (open: boolean) => void;
+ mode: CommandPanelMode;
+ setMode: (mode: CommandPanelMode) => void;
+ openMode: (mode: CommandPanelMode) => void;
+ modeRequestVersion: number;
};
const CommandRegistryContext = createContext(null);
@@ -27,6 +32,14 @@ export function CommandRegistryProvider({ children }: { children: ReactNode }) {
const listenersRef = useRef(new Set<() => void>());
const snapshotRef = useRef([]);
const [open, setOpen] = useState(false);
+ const [mode, setMode] = useState("commands");
+ const [modeRequestVersion, setModeRequestVersion] = useState(0);
+
+ const openMode = useCallback((nextMode: CommandPanelMode) => {
+ setMode(nextMode);
+ setOpen(true);
+ setModeRequestVersion((version) => version + 1);
+ }, []);
const notify = useCallback(() => {
// Rebuild snapshot
@@ -67,7 +80,18 @@ export function CommandRegistryProvider({ children }: { children: ReactNode }) {
return (
{children}
@@ -86,6 +110,6 @@ export function useCommands(): CommandItem[] {
}
export function useCommandPanelOpen() {
- const { open, setOpen } = useCommandRegistry();
- return { open, setOpen };
+ const { open, setOpen, mode, setMode, openMode, modeRequestVersion } = useCommandRegistry();
+ return { open, setOpen, mode, setMode, openMode, modeRequestVersion };
}
diff --git a/apps/web/lib/commands/content-search-selection.test.ts b/apps/web/lib/commands/content-search-selection.test.ts
new file mode 100644
index 0000000000..7e146a5e22
--- /dev/null
+++ b/apps/web/lib/commands/content-search-selection.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+
+const mockSetPendingCursorPosition = vi.fn();
+const mockScrollEditorIfMounted = vi.fn();
+const mockAddFileEditorPanel = vi.fn();
+
+vi.mock("@/hooks/file-editor-cursor", () => ({
+ setPendingCursorPosition: (...args: unknown[]) => mockSetPendingCursorPosition(...args),
+ scrollEditorIfMounted: (...args: unknown[]) => mockScrollEditorIfMounted(...args),
+}));
+
+vi.mock("@/lib/state/dockview-store", () => ({
+ useDockviewStore: {
+ getState: () => ({ addFileEditorPanel: mockAddFileEditorPanel }),
+ },
+}));
+
+import { openContentSearchResult } from "./content-search-selection";
+
+const FILE_PATH = "src/components/search.tsx";
+const FILE_NAME = "search.tsx";
+
+const result: WorkspaceContentSearchResult = {
+ repository_name: "frontend",
+ path: FILE_PATH,
+ line: 42,
+ column: 7,
+ preview: "function search()",
+ match_ranges: [{ start: 9, end: 15 }],
+};
+
+describe("openContentSearchResult", () => {
+ beforeEach(() => {
+ mockSetPendingCursorPosition.mockReset();
+ mockScrollEditorIfMounted.mockReset();
+ mockAddFileEditorPanel.mockReset();
+ });
+
+ it("opens the matching repository file at its exact cursor position", () => {
+ openContentSearchResult(result, "/tasks/task-1");
+
+ expect(mockSetPendingCursorPosition).toHaveBeenCalledWith(FILE_PATH, 42, 7, "frontend");
+ expect(mockScrollEditorIfMounted).toHaveBeenCalledWith(
+ FILE_PATH,
+ "/tasks/task-1",
+ 42,
+ 7,
+ "frontend",
+ );
+ expect(mockAddFileEditorPanel).toHaveBeenCalledWith(FILE_PATH, FILE_NAME, {
+ repo: "frontend",
+ });
+ });
+
+ it("omits an empty repository key for a single-repo workspace", () => {
+ openContentSearchResult({ ...result, repository_name: "" }, null);
+
+ expect(mockSetPendingCursorPosition).toHaveBeenCalledWith(FILE_PATH, 42, 7, undefined);
+ expect(mockAddFileEditorPanel).toHaveBeenCalledWith(FILE_PATH, FILE_NAME, {
+ repo: undefined,
+ });
+ });
+});
diff --git a/apps/web/lib/commands/content-search-selection.ts b/apps/web/lib/commands/content-search-selection.ts
new file mode 100644
index 0000000000..6915c7186e
--- /dev/null
+++ b/apps/web/lib/commands/content-search-selection.ts
@@ -0,0 +1,14 @@
+import { scrollEditorIfMounted, setPendingCursorPosition } from "@/hooks/file-editor-cursor";
+import { useDockviewStore } from "@/lib/state/dockview-store";
+import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
+import { getFileName } from "@/lib/utils/file-path";
+
+export function openContentSearchResult(
+ result: WorkspaceContentSearchResult,
+ worktreePath: string | null,
+): void {
+ const repo = result.repository_name || undefined;
+ setPendingCursorPosition(result.path, result.line, result.column, repo);
+ scrollEditorIfMounted(result.path, worktreePath, result.line, result.column, repo);
+ useDockviewStore.getState().addFileEditorPanel(result.path, getFileName(result.path), { repo });
+}
diff --git a/apps/web/lib/commands/task-workspace-search.test.ts b/apps/web/lib/commands/task-workspace-search.test.ts
new file mode 100644
index 0000000000..e159b6ef20
--- /dev/null
+++ b/apps/web/lib/commands/task-workspace-search.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import type { AppState } from "@/lib/state/store";
+import { isTaskWorkspaceSearchAvailable } from "./task-workspace-search";
+
+const TASK_ID = "task-1";
+const SESSION_ID = "session-1";
+
+function searchState({
+ activeTaskId = TASK_ID,
+ activeSessionId = SESSION_ID,
+ sessionTaskId = TASK_ID,
+}: {
+ activeTaskId?: string | null;
+ activeSessionId?: string | null;
+ sessionTaskId?: string;
+} = {}): AppState {
+ return {
+ tasks: {
+ activeTaskId,
+ activeSessionId,
+ },
+ taskSessions: {
+ items: activeSessionId
+ ? {
+ [activeSessionId]: {
+ id: activeSessionId,
+ task_id: sessionTaskId,
+ },
+ }
+ : {},
+ },
+ } as unknown as AppState;
+}
+
+describe("isTaskWorkspaceSearchAvailable", () => {
+ it("allows search for the active session inside its task detail route", () => {
+ expect(isTaskWorkspaceSearchAvailable(searchState(), `/t/${TASK_ID}`)).toBe(true);
+ });
+
+ it.each([
+ ["missing active task", searchState({ activeTaskId: null }), `/t/${TASK_ID}`],
+ ["missing active session", searchState({ activeSessionId: null }), `/t/${TASK_ID}`],
+ ["non-task route", searchState(), "/tasks"],
+ ["different task route", searchState(), "/t/task-2"],
+ [
+ "session belonging to another task",
+ searchState({ sessionTaskId: "task-2" }),
+ `/t/${TASK_ID}`,
+ ],
+ ])("rejects %s", (_label, state, pathname) => {
+ expect(isTaskWorkspaceSearchAvailable(state, pathname)).toBe(false);
+ });
+});
diff --git a/apps/web/lib/commands/task-workspace-search.ts b/apps/web/lib/commands/task-workspace-search.ts
new file mode 100644
index 0000000000..883d84df86
--- /dev/null
+++ b/apps/web/lib/commands/task-workspace-search.ts
@@ -0,0 +1,8 @@
+import { isTaskDetailPath } from "@/lib/links";
+import type { AppState } from "@/lib/state/store";
+
+export function isTaskWorkspaceSearchAvailable(state: AppState, pathname: string): boolean {
+ const { activeTaskId, activeSessionId } = state.tasks;
+ if (!activeTaskId || !activeSessionId || !isTaskDetailPath(pathname, activeTaskId)) return false;
+ return state.taskSessions.items[activeSessionId]?.task_id === activeTaskId;
+}
diff --git a/apps/web/lib/commands/types.ts b/apps/web/lib/commands/types.ts
index 224b9ed41b..bf9fd5f164 100644
--- a/apps/web/lib/commands/types.ts
+++ b/apps/web/lib/commands/types.ts
@@ -1,7 +1,12 @@
import type { ReactNode } from "react";
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
-export type CommandPanelMode = "commands" | "search-tasks" | "search-files" | "input";
+export type CommandPanelMode =
+ | "commands"
+ | "search-tasks"
+ | "search-files"
+ | "search-content"
+ | "input";
export type CommandItem = {
id: string;
diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts
index 5981db326c..27c7d61e2e 100644
--- a/apps/web/lib/keyboard/constants.ts
+++ b/apps/web/lib/keyboard/constants.ts
@@ -137,6 +137,10 @@ export const SHORTCUTS = {
key: KEYS.K,
modifiers: { ctrlOrCmd: true, shift: true },
},
+ CONTENT_SEARCH: {
+ key: KEYS.F,
+ modifiers: { ctrlOrCmd: true, shift: true },
+ },
TOGGLE_PLAN_MODE: {
key: KEYS.TAB,
modifiers: { shift: true },
diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts
index 2285aa9689..3f20054690 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.test.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts
@@ -14,6 +14,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
const ids = Object.keys(CONFIGURABLE_SHORTCUTS);
expect(ids).toContain("SEARCH");
expect(ids).toContain("FILE_SEARCH");
+ expect(ids).toContain("CONTENT_SEARCH");
expect(ids).toContain("QUICK_CHAT");
expect(ids).toContain("BOTTOM_TERMINAL");
expect(ids).toContain("TOGGLE_SIDEBAR");
@@ -27,10 +28,12 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(ids).toContain("VOICE_INPUT_TOGGLE");
expect(ids).toContain("REVERSE_SEARCH");
expect(ids).toContain("OPEN_TASK_PR");
- expect(ids).toHaveLength(15);
+ expect(ids).toHaveLength(16);
});
it("each entry has a label and default matching SHORTCUTS", () => {
+ expect(CONFIGURABLE_SHORTCUTS.CONTENT_SEARCH.label).toBe("Search Task Contents");
+ expect(CONFIGURABLE_SHORTCUTS.CONTENT_SEARCH.default).toBe(SHORTCUTS.CONTENT_SEARCH);
expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.label).toBe("Toggle Bottom Terminal");
expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.default).toBe(SHORTCUTS.BOTTOM_TERMINAL);
diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts
index 2fcc3be325..5094726357 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.ts
@@ -3,6 +3,7 @@ import { SHORTCUTS, type KeyboardShortcut } from "./constants";
export type ConfigurableShortcutId =
| "SEARCH"
| "FILE_SEARCH"
+ | "CONTENT_SEARCH"
| "QUICK_CHAT"
| "BOTTOM_TERMINAL"
| "TOGGLE_SIDEBAR"
@@ -39,6 +40,7 @@ export const CONFIGURABLE_SHORTCUTS: Record<
> = {
SEARCH: { label: "Command Panel", default: SHORTCUTS.SEARCH },
FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH },
+ CONTENT_SEARCH: { label: "Search Task Contents", default: SHORTCUTS.CONTENT_SEARCH },
QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT },
BOTTOM_TERMINAL: { label: "Toggle Bottom Terminal", default: SHORTCUTS.BOTTOM_TERMINAL },
TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: UNBOUND_SHORTCUT },
diff --git a/apps/web/lib/types/workspace-files.ts b/apps/web/lib/types/workspace-files.ts
index 32e5a48601..78fa0697a5 100644
--- a/apps/web/lib/types/workspace-files.ts
+++ b/apps/web/lib/types/workspace-files.ts
@@ -23,8 +23,37 @@ export type FileContentResponse = {
error?: string;
};
+export type FileSearchResult = {
+ repository_name?: string;
+ /** Task-root-relative path accepted by the workspace file APIs. */
+ path: string;
+};
+
export type FileSearchResponse = {
files: string[];
+ /** Structured results for repository-aware consumers. */
+ results?: FileSearchResult[];
+ error?: string;
+};
+
+export type ContentSearchMatchRange = {
+ /** Inclusive UTF-16 offset into `preview`. */
+ start: number;
+ /** Exclusive UTF-16 offset into `preview`. */
+ end: number;
+};
+
+export type WorkspaceContentSearchResult = {
+ repository_name: string;
+ path: string;
+ line: number;
+ column: number;
+ preview: string;
+ match_ranges: ContentSearchMatchRange[];
+};
+
+export type WorkspaceContentSearchResponse = {
+ results: WorkspaceContentSearchResult[];
error?: string;
};
diff --git a/apps/web/lib/ws/workspace-content-search.test.ts b/apps/web/lib/ws/workspace-content-search.test.ts
new file mode 100644
index 0000000000..b3d71bb80a
--- /dev/null
+++ b/apps/web/lib/ws/workspace-content-search.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it, vi } from "vitest";
+import type { WebSocketClient } from "./client";
+import { searchWorkspaceContent } from "./workspace-files";
+
+describe("searchWorkspaceContent", () => {
+ it("sends a repository-scoped result request for the active session", async () => {
+ const response = {
+ results: [
+ {
+ repository_name: "frontend",
+ path: "src/app.tsx",
+ line: 12,
+ column: 8,
+ preview: "const searchable = true",
+ match_ranges: [{ start: 6, end: 16 }],
+ },
+ ],
+ };
+ const request = vi.fn().mockResolvedValue(response);
+ const client = { request } as unknown as WebSocketClient;
+
+ await expect(searchWorkspaceContent(client, "session-1", "searchable", 50)).resolves.toEqual(
+ response,
+ );
+ expect(request).toHaveBeenCalledWith("workspace.content.search", {
+ session_id: "session-1",
+ query: "searchable",
+ limit_per_repo: 50,
+ });
+ });
+});
diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts
index 0586435dea..5c0a0cd62c 100644
--- a/apps/web/lib/ws/workspace-files.ts
+++ b/apps/web/lib/ws/workspace-files.ts
@@ -3,6 +3,7 @@ import type {
FileTreeResponse,
FileContentResponse,
FileSearchResponse,
+ WorkspaceContentSearchResponse,
} from "@/lib/types/backend";
/**
@@ -75,6 +76,20 @@ export async function searchWorkspaceFiles(
});
}
+/** Search text content across every repository in the active task workspace. */
+export async function searchWorkspaceContent(
+ client: WebSocketClient,
+ sessionId: string,
+ query: string,
+ limitPerRepo: number = 50,
+): Promise {
+ return client.request("workspace.content.search", {
+ session_id: sessionId,
+ query,
+ limit_per_repo: limitPerRepo,
+ });
+}
+
/**
* File update response from backend
*/
diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md
index 7610d888d6..6ce3a2166c 100644
--- a/docs/public/sessions-and-review.md
+++ b/docs/public/sessions-and-review.md
@@ -86,6 +86,20 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl
Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. Its task switcher opens as an inset bottom card, and the current-session control shows the active agent's icon and name.
+Press **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere to search the
+contents of every file in the active task workspace. Results are grouped by
+repository and show the repository-relative path, line number, and matching
+line; selecting one opens that repository's file at the match. Content search
+includes tracked files and untracked files that are not ignored. Use
+**Cmd/Ctrl+Shift+K** when you want to search only file names and paths across
+all repositories in the active task. The palette keeps **Commands**, **Files**,
+and **Contents** visible as compact tabs beside the search field while an active
+task workbench is open; elsewhere the palette remains command-only and leaves
+the workspace-search shortcuts untouched. Click a mode or press **Tab** /
+**Shift+Tab** to switch without clearing your query or moving focus from the
+search field. File matches are grouped by repository. Hover a mode to see its
+direct shortcut.
+
Open **Settings > General > Layouts** to configure reusable desktop workbench profiles. Select a tab in a built-in layout to reveal its nearby edit controls, arrange or remove tabs and splits, then use the floating **Save changes** control. Kandev keeps the built-in row visible, marks it **Customized**, and stores your override without requiring a duplicate. Choose **Reset** beside a customized built-in to restore its original definition. Removing Terminal from the Default layout also prevents Kandev from creating its initial user shell. Changing the default does not replace a layout already saved for a task; choose **Reset Layout** from the workbench layout menu when you want that task to adopt the latest default.
All panels for a task point at the same task environment. In a multi-repository task, check the repository label before editing, committing, or reviewing. A preview also requires the application to listen on a reachable interface and expose a forwarded port.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 2779254784..a4bf6d80ac 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -136,6 +136,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [agent-message-comments](ui/agent-message-comments.md) | shipped |
| [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped |
| [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped |
+| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped |
## system-page/ — operational diagnostics & maintenance UI
diff --git a/docs/specs/ui/task-workspace-content-search.md b/docs/specs/ui/task-workspace-content-search.md
new file mode 100644
index 0000000000..37c78020fb
--- /dev/null
+++ b/docs/specs/ui/task-workspace-content-search.md
@@ -0,0 +1,159 @@
+---
+status: shipped
+created: 2026-07-27
+owner: kandev
+---
+
+# Task Workspace Content Search
+
+## Why
+
+People working inside a task need to find code by what it says, not only by its
+file name. Opening a terminal and running a repository-specific search breaks
+the workbench flow, and it is especially awkward when a task contains several
+repositories.
+
+## What
+
+- On a task route, **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere opens
+ the command palette directly in workspace content-search mode.
+- The shortcut works while a workbench editor or text input has focus and takes
+ precedence over that surface's local find shortcut. It prevents the browser's
+ default search action.
+- The command palette visibly exposes **Commands**, **Files**, and **Contents**
+ as low-chrome text tabs beside the search field. The active mode uses a subtle
+ underline, and each mode's direct shortcut remains discoverable from its
+ tooltip without adding a separate selector row.
+- **Files** and **Contents** are available only on the active task-detail route
+ when its selected session belongs to that task. Elsewhere the palette is
+ command-only, does not intercept either workspace-search shortcut, and
+ normalizes a previously open workspace-search palette back to **Commands**.
+- Clicking a mode or pressing **Tab** / **Shift+Tab** switches among those modes
+ without discarding the current query. The existing **Cmd/Ctrl+Shift+K**
+ shortcut still opens file-name and path search directly.
+- File-name and path search covers every repository materialized for the active
+ task. Multi-repository results use task-root-relative, repository-prefixed
+ paths so same-named files remain distinguishable and open in the correct
+ repository. The palette groups those matches by repository and shows paths
+ relative to each group.
+- Search covers every repository materialized for the active task session.
+ Tracked files and untracked, non-ignored files are eligible; ignored files,
+ directories, and workspace metadata are not.
+- Matching is case-insensitive and fuzzy within one line. A contiguous match
+ ranks ahead of a non-contiguous subsequence match. Adjacency, word boundaries,
+ and shorter gaps improve a fuzzy match's rank.
+- Each result shows its repository, repository-relative path, one-based line
+ number, and a single-line content preview. Matched characters are highlighted
+ in the preview.
+- Results are grouped by repository. A repository's display label may be
+ shortened for readability, but selecting a result retains the repository's
+ raw transport identity.
+- Selecting a result opens or activates that repository's file and places the
+ cursor at the result's one-based line and column. Both Monaco and CodeMirror
+ reveal the location, including when the editor is mounted after selection.
+- Empty-query, searching, no-match, unavailable-session, and failed-search
+ states are distinguishable without closing the palette.
+- Search is bounded: a query contains at most 200 characters, each repository
+ returns at most 50 results, and files larger than the existing 10 MiB
+ workspace-file limit are skipped.
+- Binary files, invalid UTF-8, unreadable files, paths that escape the
+ repository, and files removed during a search are skipped without failing
+ the entire query.
+- Match ranges use UTF-16 offsets into the returned preview so browser
+ highlighting remains correct for non-BMP Unicode text.
+- Search does not require an external executable such as ripgrep, so it behaves
+ consistently in local, container, remote, and Windows executors.
+
+## API surface
+
+The browser requests workspace content search over the session-scoped WebSocket
+action:
+
+```text
+workspace.content.search
+```
+
+The request contains `session_id`, `query`, and an optional
+`limit_per_repo`. The response contains ordered result records:
+
+| Field | Meaning |
+| ----------------- | ------------------------------------------------------------- |
+| `repository_name` | Raw repository identity used by workspace file APIs |
+| `path` | Repository-relative slash-separated path |
+| `line` | One-based source line |
+| `column` | One-based UTF-16 source column of the first matched character |
+| `preview` | Searchable source line presented by the palette |
+| `match_ranges` | Half-open UTF-16 ranges into `preview` |
+
+The backend-to-agentctl HTTP transport exposes the same result shape. Workspace
+file-name search keeps its legacy `files` array and additionally returns
+structured `results` entries containing `repository_name` and the
+task-root-relative `path`, allowing grouping-aware consumers to separate
+repositories without parsing path strings.
+
+## Permissions and isolation
+
+- A request is authorized through the session ID before its execution
+ workspace is accessed.
+- Only repositories attached to that session's task execution are searched.
+- Repository-relative paths are resolved through the existing workspace path
+ containment rules; symlinks or traversal cannot expand search scope.
+- Result data contains no absolute host path.
+
+## Failure modes
+
+- Without an active task session, the palette explains that content search is
+ unavailable and sends no request.
+- A blank query clears prior results and sends no request.
+- A query over the maximum length is rejected consistently rather than scanning
+ with a silently different value.
+- If one eligible file disappears, becomes unreadable, or is invalid while the
+ search runs, other files and repositories still return results.
+- If the execution cannot be recovered or the transport request fails, the
+ palette presents a retryable failure state and keeps the entered query.
+- Cancellation stops outstanding repository work and stale responses do not
+ replace results for a newer query.
+
+## Scenarios
+
+- **GIVEN** a task editor has focus, **WHEN** the user presses
+ **Cmd/Ctrl+Shift+F**, **THEN** task content search opens and the browser or
+ editor find UI does not.
+- **GIVEN** the palette is open with a query, **WHEN** the user clicks another
+ top-level mode or presses **Tab**, **THEN** the mode changes, the query remains,
+ and the input keeps focus.
+- **GIVEN** the user leaves the active task workbench, **WHEN** the palette is
+ opened or a workspace-search shortcut is pressed, **THEN** only
+ **Commands** is offered and the workspace-search shortcut keeps its native
+ behavior.
+- **GIVEN** a line containing an exact occurrence and another containing only a
+ fuzzy subsequence, **WHEN** both match the query, **THEN** the exact occurrence
+ ranks first.
+- **GIVEN** a task with two repositories containing the same search term,
+ **WHEN** the user searches, **THEN** both repositories have clearly separated
+ result groups with repository-relative paths.
+- **GIVEN** a task with two repositories containing matching file names,
+ **WHEN** the user searches in **Files** mode, **THEN** matching paths from both
+ repositories appear with their repository prefixes.
+- **GIVEN** two repositories with the same relative path, **WHEN** the user
+ selects the result from the second repository, **THEN** Kandev opens the
+ second repository's file at the returned line and column.
+- **GIVEN** an untracked non-ignored text file and an ignored text file,
+ **WHEN** both contain the query, **THEN** only the untracked non-ignored file
+ appears.
+- **GIVEN** a preview containing non-BMP Unicode before and inside a match,
+ **WHEN** results render, **THEN** the supplied match ranges highlight the
+ intended characters without splitting a surrogate pair.
+- **GIVEN** a search is followed quickly by another query, **WHEN** the first
+ response arrives last, **THEN** the palette retains the second query's
+ results.
+
+## Out of scope
+
+- Replacing file-name and path search.
+- Searching tasks other than the task currently open.
+- Searching Git history, ignored files, generated artifacts, agent messages,
+ terminal output, or files outside attached repositories.
+- Regular-expression, whole-word, case-sensitive, replace, or persistent search
+ options.
+- A persistent full-text index or external search service.