diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 1e977dddcd..743147247f 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -120,6 +120,8 @@ fn generate_types_content() -> String { server::routes::tasks::ShareTaskResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::pr::CreatePrApiRequest::decl(), + server::routes::tasks::BulkDeleteTasksRequest::decl(), + server::routes::tasks::BulkDeleteTasksResponse::decl(), server::routes::images::ImageResponse::decl(), server::routes::images::ImageMetadata::decl(), server::routes::task_attempts::CreateTaskAttemptBody::decl(), diff --git a/crates/server/src/routes/tasks.rs b/crates/server/src/routes/tasks.rs index c194033c56..0b28209172 100644 --- a/crates/server/src/routes/tasks.rs +++ b/crates/server/src/routes/tasks.rs @@ -16,7 +16,7 @@ use db::models::{ image::TaskImage, project::{Project, ProjectError}, repo::Repo, - task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask}, + task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask}, workspace::{CreateWorkspace, Workspace}, workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo}, }; @@ -424,6 +424,171 @@ pub async fn delete_task( Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) } +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct BulkDeleteTasksRequest { + pub project_id: Uuid, + pub status: TaskStatus, +} + +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct BulkDeleteTasksResponse { + pub deleted_count: u64, +} + +pub async fn bulk_delete_tasks( + State(deployment): State, + Json(payload): Json, +) -> Result<(StatusCode, ResponseJson>), ApiError> { + let pool = &deployment.db().pool; + + // Fetch all tasks with the specified status for the project + let all_tasks = + Task::find_by_project_id_with_attempt_status(pool, payload.project_id).await?; + + let tasks_to_delete: Vec<_> = all_tasks + .into_iter() + .filter(|t| t.status == payload.status) + .collect(); + + if tasks_to_delete.is_empty() { + return Ok(( + StatusCode::OK, + ResponseJson(ApiResponse::success(BulkDeleteTasksResponse { + deleted_count: 0, + })), + )); + } + + // Check for any running processes + for task in &tasks_to_delete { + if deployment + .container() + .has_running_processes(task.id) + .await? + { + return Err(ApiError::Conflict(format!( + "Task '{}' has running execution processes. Please wait for them to complete or stop them first.", + task.title + ))); + } + } + + // Gather all data BEFORE starting the transaction to minimize lock time + let mut all_workspace_dirs: Vec = Vec::new(); + let mut all_repositories: Vec = Vec::new(); + let mut task_attempts: Vec<(Uuid, Vec)> = Vec::new(); + let mut shared_task_ids: Vec = Vec::new(); + + for task in &tasks_to_delete { + // Gather task attempts data + let attempts = Workspace::fetch_all(pool, Some(task.id)) + .await + .map_err(|e| { + tracing::error!("Failed to fetch task attempts for task {}: {}", task.id, e); + ApiError::Workspace(e) + })?; + + let repositories = WorkspaceRepo::find_unique_repos_for_task(pool, task.id).await?; + all_repositories.extend(repositories); + + // Collect workspace directories + let workspace_dirs: Vec = attempts + .iter() + .filter_map(|attempt| attempt.container_ref.as_ref().map(PathBuf::from)) + .collect(); + all_workspace_dirs.extend(workspace_dirs); + + // Collect shared task IDs for deletion + if let Some(shared_task_id) = task.shared_task_id { + shared_task_ids.push(shared_task_id); + } + + task_attempts.push((task.id, attempts)); + } + + // Handle shared task deletions (external API calls, not DB writes) + if let Ok(publisher) = deployment.share_publisher() { + for shared_task_id in &shared_task_ids { + if let Err(e) = publisher.delete_shared_task(*shared_task_id).await { + tracing::warn!("Failed to delete shared task {}: {}", shared_task_id, e); + } + } + } + + // Now do all DB writes in a single transaction + let mut deleted_count = 0u64; + let mut tx = pool.begin().await?; + + for (task_id, attempts) in &task_attempts { + // Nullify parent_workspace_id for child tasks + for attempt in attempts { + Task::nullify_children_by_workspace_id(&mut *tx, attempt.id).await?; + } + + // Delete the task + let rows_affected = Task::delete(&mut *tx, *task_id).await?; + deleted_count += rows_affected; + } + + tx.commit().await?; + + deployment + .track_if_analytics_allowed( + "tasks_bulk_deleted", + serde_json::json!({ + "project_id": payload.project_id.to_string(), + "status": payload.status.to_string(), + "deleted_count": deleted_count, + }), + ) + .await; + + // Background cleanup + let project_id = payload.project_id; + let pool = pool.clone(); + tokio::spawn(async move { + tracing::info!( + "Starting background cleanup for {} deleted tasks in project {}", + deleted_count, + project_id + ); + + for workspace_dir in &all_workspace_dirs { + if let Err(e) = + WorkspaceManager::cleanup_workspace(workspace_dir, &all_repositories).await + { + tracing::error!( + "Background workspace cleanup failed at {}: {}", + workspace_dir.display(), + e + ); + } + } + + match Repo::delete_orphaned(&pool).await { + Ok(count) if count > 0 => { + tracing::info!("Deleted {} orphaned repo records", count); + } + Err(e) => { + tracing::error!("Failed to delete orphaned repos: {}", e); + } + _ => {} + } + + tracing::info!( + "Background cleanup completed for bulk delete in project {}", + project_id + ); + }); + + Ok(( + StatusCode::ACCEPTED, + ResponseJson(ApiResponse::success(BulkDeleteTasksResponse { + deleted_count, + })), + )) +} + #[derive(Debug, Serialize, Deserialize, TS)] pub struct ShareTaskResponse { pub shared_task_id: Uuid, @@ -471,6 +636,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/", get(get_tasks).post(create_task)) .route("/stream/ws", get(stream_tasks_ws)) .route("/create-and-start", post(create_task_and_start)) + .route("/bulk-delete", post(bulk_delete_tasks)) .nest("/{task_id}", task_id_router); // mount under /projects/:project_id/tasks diff --git a/frontend/src/components/dialogs/tasks/BulkDeleteTasksDialog.tsx b/frontend/src/components/dialogs/tasks/BulkDeleteTasksDialog.tsx new file mode 100644 index 0000000000..1a140fdb4e --- /dev/null +++ b/frontend/src/components/dialogs/tasks/BulkDeleteTasksDialog.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Alert } from '@/components/ui/alert'; +import { tasksApi } from '@/lib/api'; +import type { TaskStatus } from 'shared/types'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; +import { statusLabels } from '@/utils/statusLabels'; + +export interface BulkDeleteTasksDialogProps { + projectId: string; + status: TaskStatus; + count: number; +} + +const BulkDeleteTasksDialogImpl = + NiceModal.create( + ({ projectId, status, count }) => { + const modal = useModal(); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const statusLabel = statusLabels[status]; + + const handleConfirmDelete = async () => { + setIsDeleting(true); + setError(null); + + try { + await tasksApi.bulkDelete({ project_id: projectId, status }); + modal.resolve(); + modal.hide(); + } catch (err: unknown) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to delete tasks'; + setError(errorMessage); + } finally { + setIsDeleting(false); + } + }; + + const handleCancelDelete = () => { + modal.reject(); + modal.hide(); + }; + + return ( + !open && handleCancelDelete()} + > + + + Clear {statusLabel} Tasks + + Are you sure you want to delete{' '} + + {count} {count === 1 ? 'task' : 'tasks'} + {' '} + from {statusLabel}? + + + + + Warning: This action will permanently delete all{' '} + {statusLabel.toLowerCase()} tasks and cannot be undone. + + + {error && ( + + {error} + + )} + + + + + + + + ); + } + ); + +export const BulkDeleteTasksDialog = defineModal< + BulkDeleteTasksDialogProps, + void +>(BulkDeleteTasksDialogImpl); diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index 3abe18c6d0..1e68f5fe61 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -1,4 +1,5 @@ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAuth } from '@/hooks'; import { type DragEndEvent, @@ -12,6 +13,9 @@ import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; import { statusBoardColors, statusLabels } from '@/utils/statusLabels'; import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import { SharedTaskCard } from './SharedTaskCard'; +import { BulkDeleteTasksDialog } from '@/components/dialogs/tasks/BulkDeleteTasksDialog'; +import { taskKeys } from '@/hooks/useTask'; +import { taskRelationshipsKeys } from '@/hooks/useTaskRelationships'; export type KanbanColumnItem = | { @@ -48,17 +52,44 @@ function TaskKanbanBoard({ projectId, }: TaskKanbanBoardProps) { const { userId } = useAuth(); + const queryClient = useQueryClient(); + + const handleClearColumn = useCallback( + async (status: TaskStatus) => { + const taskCount = + columns[status]?.filter((item) => item.type === 'task').length ?? 0; + if (taskCount === 0) return; + + try { + await BulkDeleteTasksDialog.show({ + projectId, + status, + count: taskCount, + }); + // Dialog resolved successfully (API already called), invalidate queries + queryClient.invalidateQueries({ queryKey: taskKeys.all }); + queryClient.invalidateQueries({ queryKey: taskRelationshipsKeys.all }); + } catch { + // User cancelled + } + }, + [columns, projectId, queryClient] + ); return ( {Object.entries(columns).map(([status, items]) => { const statusKey = status as TaskStatus; + const hasOwnTasks = items.some((item) => item.type === 'task'); return ( handleClearColumn(statusKey) : undefined + } /> {items.map((item, index) => { diff --git a/frontend/src/components/ui/shadcn-io/kanban/index.tsx b/frontend/src/components/ui/shadcn-io/kanban/index.tsx index 7cfa319741..f5af97565e 100644 --- a/frontend/src/components/ui/shadcn-io/kanban/index.tsx +++ b/frontend/src/components/ui/shadcn-io/kanban/index.tsx @@ -21,7 +21,7 @@ import { import { type ReactNode, type Ref, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus } from 'lucide-react'; +import { Plus, Trash2 } from 'lucide-react'; import type { ClientRect } from '@dnd-kit/core'; import type { Transform } from '@dnd-kit/utilities'; import { Button } from '../../button'; @@ -153,6 +153,7 @@ export type KanbanHeaderProps = color: Status['color']; className?: string; onAddTask?: () => void; + onClearColumn?: () => void; }; export const KanbanHeader = (props: KanbanHeaderProps) => { @@ -181,6 +182,25 @@ export const KanbanHeader = (props: KanbanHeaderProps) => {

{props.name}

+ {props.onClearColumn && ( + + + + + + + {t('actions.clearColumnName', { name: props.name })} + + + + )} diff --git a/frontend/src/hooks/useTaskMutations.ts b/frontend/src/hooks/useTaskMutations.ts index 257b9ab51d..3877a24076 100644 --- a/frontend/src/hooks/useTaskMutations.ts +++ b/frontend/src/hooks/useTaskMutations.ts @@ -5,6 +5,8 @@ import { paths } from '@/lib/paths'; import { taskRelationshipsKeys } from '@/hooks/useTaskRelationships'; import { workspaceSummaryKeys } from '@/components/ui-new/hooks/useWorkspaces'; import type { + BulkDeleteTasksRequest, + BulkDeleteTasksResponse, CreateTask, CreateAndStartTaskRequest, Task, @@ -95,6 +97,17 @@ export function useTaskMutations(projectId?: string) { }, }); + const bulkDeleteTasks = useMutation({ + mutationFn: (data: BulkDeleteTasksRequest) => tasksApi.bulkDelete(data), + onSuccess: (_: BulkDeleteTasksResponse) => { + invalidateQueries(); + queryClient.invalidateQueries({ queryKey: taskRelationshipsKeys.all }); + }, + onError: (err) => { + console.error('Failed to bulk delete tasks:', err); + }, + }); + const shareTask = useMutation({ mutationFn: (taskId: string) => tasksApi.share(taskId), onError: (err) => { @@ -130,6 +143,7 @@ export function useTaskMutations(projectId?: string) { createAndStart, updateTask, deleteTask, + bulkDeleteTasks, shareTask, stopShareTask: unshareSharedTask, linkSharedTaskToLocal, diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 148e164e19..40e98707c8 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -17,7 +17,9 @@ "noSearchResults": "No tasks match your search." }, "actions": { - "addTask": "Add task" + "addTask": "Add task", + "clearColumn": "Clear done tasks", + "clearColumnName": "Clear {{name}} tasks" }, "filters": { "sharedToggleAria": "Toggle shared tasks", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 594619a580..ca309eacd7 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -11,7 +11,8 @@ } }, "actions": { - "addTask": "Agregar tarea" + "addTask": "Agregar tarea", + "clearColumn": "Limpiar tareas completadas" }, "filters": { "sharedToggleAria": "Alternar tareas compartidas", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 49f8b833ed..d18bf3feac 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -11,7 +11,8 @@ } }, "actions": { - "addTask": "タスクを追加" + "addTask": "タスクを追加", + "clearColumn": "完了タスクをクリア" }, "filters": { "sharedToggleAria": "共有タスクを切り替える", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index c3c9e0e89e..4b2ff03852 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -11,7 +11,8 @@ } }, "actions": { - "addTask": "작업 추가" + "addTask": "작업 추가", + "clearColumn": "완료된 작업 삭제" }, "filters": { "sharedToggleAria": "공유 작업 전환", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index e0d48463ee..74fbbe4665 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -17,7 +17,8 @@ "noSearchResults": "没有任务匹配您的搜索。" }, "actions": { - "addTask": "添加任务" + "addTask": "添加任务", + "clearColumn": "清除已完成任务" }, "filters": { "sharedToggleAria": "切换共享任务", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 152358a322..f4302c1fd9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,8 @@ import { ApprovalStatus, ApiResponse, + BulkDeleteTasksRequest, + BulkDeleteTasksResponse, Config, CreateFollowUpAttempt, EditorType, @@ -425,6 +427,16 @@ export const tasksApi = { return handleApiResponse(response); }, + bulkDelete: async ( + data: BulkDeleteTasksRequest + ): Promise => { + const response = await makeRequest(`/api/tasks/bulk-delete`, { + method: 'POST', + body: JSON.stringify(data), + }); + return handleApiResponse(response); + }, + share: async (taskId: string): Promise => { const response = await makeRequest(`/api/tasks/${taskId}/share`, { method: 'POST', diff --git a/shared/types.ts b/shared/types.ts index 7c3004ebb8..0da14be96d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -244,6 +244,10 @@ export type CreateAndStartTaskRequest = { task: CreateTask, executor_profile_id: export type CreatePrApiRequest = { title: string, body: string | null, target_branch: string | null, draft: boolean | null, repo_id: string, auto_generate_description: boolean, }; +export type BulkDeleteTasksRequest = { project_id: string, status: TaskStatus, }; + +export type BulkDeleteTasksResponse = { deleted_count: bigint, }; + export type ImageResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; export type ImageMetadata = { exists: boolean, file_name: string | null, path: string | null, size_bytes: bigint | null, format: string | null, proxy_url: string | null, };