From 7bc8ece068324e950e2404f33285bf937c26d014 Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Tue, 13 Jan 2026 18:01:33 +0000 Subject: [PATCH] workspace file search (#2002) --- crates/server/src/routes/task_attempts.rs | 40 +++++++++++++++++++ .../containers/SessionChatBoxContainer.tsx | 1 + .../ui-new/primitives/ChatBoxBase.tsx | 3 ++ .../ui-new/primitives/SessionChatBox.tsx | 3 ++ frontend/src/components/ui/wysiwyg.tsx | 11 ++++- .../plugins/file-tag-typeahead-plugin.tsx | 12 ++++-- frontend/src/lib/api.ts | 12 ++++++ frontend/src/lib/searchTagsAndFiles.ts | 37 +++++++++++------ 8 files changed, 102 insertions(+), 17 deletions(-) diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 5e5732c3..d0139538 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -26,6 +26,7 @@ use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, merge::{Merge, MergeStatus, PrMerge, PullRequestInfo}, + project::SearchResult, repo::{Repo, RepoError}, session::{CreateSession, Session}, task::{Task, TaskRelationships, TaskStatus}, @@ -45,6 +46,7 @@ use git2::BranchType; use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, + file_search_cache::SearchQuery, git::{ConflictOp, GitCliError, GitServiceError}, workspace_manager::WorkspaceManager, }; @@ -1568,6 +1570,43 @@ pub async fn get_task_attempt_repos( Ok(ResponseJson(ApiResponse::success(repos))) } +pub async fn search_workspace_files( + Extension(workspace): Extension, + State(deployment): State, + Query(search_query): Query, +) -> Result>>, StatusCode> { + if search_query.q.trim().is_empty() { + return Ok(ResponseJson(ApiResponse::error( + "Query parameter 'q' is required and cannot be empty", + ))); + } + + let repos = + match WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Failed to get workspace repos: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + match deployment + .project() + .search_files( + deployment.file_search_cache().as_ref(), + &repos, + &search_query, + ) + .await + { + Ok(results) => Ok(ResponseJson(ApiResponse::success(results))), + Err(e) => { + tracing::error!("Failed to search files: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub async fn get_first_user_message( Extension(workspace): Extension, State(deployment): State, @@ -1724,6 +1763,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/change-target-branch", post(change_target_branch)) .route("/rename-branch", post(rename_branch)) .route("/repos", get(get_task_attempt_repos)) + .route("/search", get(search_workspace_files)) .route("/first-message", get(get_first_user_message)) .route("/mark-seen", put(mark_seen)) .layer(from_fn_with_state( diff --git a/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx b/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx index 9d2252b0..bc72ff32 100644 --- a/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx +++ b/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx @@ -553,6 +553,7 @@ export function SessionChatBoxContainer({ return ( void; disabled?: boolean; + workspaceId?: string; projectId?: string; autoFocus?: boolean; @@ -81,6 +82,7 @@ export function ChatBoxBase({ placeholder, onCmdEnter, disabled, + workspaceId, projectId, autoFocus, variant, @@ -142,6 +144,7 @@ export function ChatBoxBase({ onCmdEnter={onCmdEnter} disabled={disabled} className="min-h-0 max-h-[50vh] overflow-y-auto" + workspaceId={workspaceId} projectId={projectId} autoFocus={autoFocus} onPasteFiles={onPasteFiles} diff --git a/frontend/src/components/ui-new/primitives/SessionChatBox.tsx b/frontend/src/components/ui-new/primitives/SessionChatBox.tsx index 08879a09..f87a5ffb 100644 --- a/frontend/src/components/ui-new/primitives/SessionChatBox.tsx +++ b/frontend/src/components/ui-new/primitives/SessionChatBox.tsx @@ -129,6 +129,7 @@ interface SessionChatBoxProps { reviewComments?: ReviewCommentsProps; toolbarActions?: ToolbarActionsProps; error?: string | null; + workspaceId?: string; projectId?: string; agent?: BaseCodingAgent | null; executor?: ExecutorProps; @@ -153,6 +154,7 @@ export function SessionChatBox({ reviewComments, toolbarActions, error, + workspaceId, projectId, agent, executor, @@ -488,6 +490,7 @@ export function SessionChatBox({ placeholder={placeholder} onCmdEnter={handleCmdEnter} disabled={isDisabled} + workspaceId={workspaceId} projectId={projectId} autoFocus={true} focusKey={focusKey} diff --git a/frontend/src/components/ui/wysiwyg.tsx b/frontend/src/components/ui/wysiwyg.tsx index e57a9c81..424174b6 100644 --- a/frontend/src/components/ui/wysiwyg.tsx +++ b/frontend/src/components/ui/wysiwyg.tsx @@ -56,7 +56,10 @@ type WysiwygProps = { disabled?: boolean; onPasteFiles?: (files: File[]) => void; className?: string; - projectId?: string; // for file search in typeahead + /** Workspace ID for workspace-scoped file search (preferred over projectId) */ + workspaceId?: string; + /** Project ID for file search in typeahead (fallback if workspaceId not provided) */ + projectId?: string; onCmdEnter?: () => void; onShiftCmdEnter?: () => void; /** Task attempt ID for resolving .vibe-images paths (preferred over taskId) */ @@ -85,6 +88,7 @@ function WYSIWYGEditor({ disabled = false, onPasteFiles, className, + workspaceId, projectId, onCmdEnter, onShiftCmdEnter, @@ -251,7 +255,10 @@ function WYSIWYGEditor({ {autoFocus && } - + ([]); const lastMousePositionRef = useRef<{ x: number; y: number } | null>(null); @@ -77,7 +83,7 @@ export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) { } // Here query is a string, including possible empty string '' - searchTagsAndFiles(query, projectId) + searchTagsAndFiles(query, { workspaceId, projectId }) .then((results) => { setOptions(results.map((r) => new FileTagOption(r))); }) @@ -85,7 +91,7 @@ export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) { console.error('Failed to search tags/files', err); }); }, - [projectId] + [workspaceId, projectId] ); return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1dbdad64..5d476527 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -565,6 +565,18 @@ export const attemptsApi = { return handleApiResponse(response); }, + searchFiles: async ( + workspaceId: string, + query: string, + mode?: string + ): Promise => { + const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : ''; + const response = await makeRequest( + `/api/task-attempts/${workspaceId}/search?q=${encodeURIComponent(query)}${modeParam}` + ); + return handleApiResponse(response); + }, + runAgentSetup: async ( attemptId: string, data: RunAgentSetupRequest diff --git a/frontend/src/lib/searchTagsAndFiles.ts b/frontend/src/lib/searchTagsAndFiles.ts index d270413f..2878d9c7 100644 --- a/frontend/src/lib/searchTagsAndFiles.ts +++ b/frontend/src/lib/searchTagsAndFiles.ts @@ -1,4 +1,4 @@ -import { projectsApi, tagsApi } from '@/lib/api'; +import { attemptsApi, projectsApi, tagsApi } from '@/lib/api'; import type { SearchResult, Tag } from 'shared/types'; interface FileSearchResult extends SearchResult { @@ -11,9 +11,14 @@ export interface SearchResultItem { file?: FileSearchResult; } +export interface SearchOptions { + workspaceId?: string; + projectId?: string; +} + export async function searchTagsAndFiles( query: string, - projectId?: string + options?: SearchOptions ): Promise { const results: SearchResultItem[] = []; @@ -24,16 +29,24 @@ export async function searchTagsAndFiles( ); results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag }))); - // Fetch files (if projectId is available and query has content) - if (projectId && query.length > 0) { - const fileResults = await projectsApi.searchFiles(projectId, query); - const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({ - ...item, - name: item.path.split('/').pop() || item.path, - })); - results.push( - ...fileSearchResults.map((file) => ({ type: 'file' as const, file })) - ); + // Fetch files - prefer workspace-scoped if available + if (query.length > 0) { + let fileResults: SearchResult[] = []; + if (options?.workspaceId) { + fileResults = await attemptsApi.searchFiles(options.workspaceId, query); + } else if (options?.projectId) { + fileResults = await projectsApi.searchFiles(options.projectId, query); + } + + if (fileResults.length > 0) { + const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({ + ...item, + name: item.path.split('/').pop() || item.path, + })); + results.push( + ...fileSearchResults.map((file) => ({ type: 'file' as const, file })) + ); + } } return results;