diff --git a/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx b/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx new file mode 100644 index 00000000..1b8bcf98 --- /dev/null +++ b/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx @@ -0,0 +1,167 @@ +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { PlusIcon } from 'lucide-react'; +import { openTaskForm } from '@/lib/openTaskForm'; +import { useTaskRelationships } from '@/hooks/useTaskRelationships'; +import { DataTable, type ColumnDef } from '@/components/ui/table/DataTable'; +import type { Task, TaskAttempt } from 'shared/types'; + +export interface ViewRelatedTasksDialogProps { + attemptId: string; + projectId: string; + attempt: TaskAttempt | null; + onNavigateToTask?: (taskId: string) => void; +} + +export const ViewRelatedTasksDialog = + NiceModal.create( + ({ attemptId, projectId, attempt, onNavigateToTask }) => { + const modal = useModal(); + const { t } = useTranslation('tasks'); + const { + data: relationships, + isLoading, + isError, + refetch, + } = useTaskRelationships(attemptId); + + // Combine parent and children into a single list of related tasks + const relatedTasks: Task[] = []; + if (relationships?.parent_task) { + relatedTasks.push(relationships.parent_task); + } + if (relationships?.children) { + relatedTasks.push(...relationships.children); + } + + const taskColumns: ColumnDef[] = [ + { + id: 'title', + header: t('viewRelatedTasksDialog.columns.title'), + accessor: (task) => ( +
+ {task.title || '—'} +
+ ), + className: 'pr-4', + headerClassName: 'font-medium py-2 pr-4 w-1/2 bg-card', + }, + { + id: 'description', + header: t('viewRelatedTasksDialog.columns.description'), + accessor: (task) => ( +
+ {task.description?.trim() ? task.description : '—'} +
+ ), + className: 'pr-4', + headerClassName: 'font-medium py-2 pr-4 bg-card', + }, + ]; + + const handleOpenChange = (open: boolean) => { + if (!open) { + modal.hide(); + } + }; + + const handleClickTask = (taskId: string) => { + onNavigateToTask?.(taskId); + modal.hide(); + }; + + const handleCreateSubtask = async () => { + if (!projectId || !attempt) return; + + // Close immediately - user intent is to create a subtask + modal.hide(); + + try { + // Yield one microtask for smooth modal transition + await Promise.resolve(); + + await openTaskForm({ + projectId, + parentTaskAttemptId: attempt.id, + initialBaseBranch: attempt.branch || attempt.target_branch, + }); + } catch { + // User cancelled or error occurred + } + }; + + return ( + + { + if (e.key === 'Escape') { + e.stopPropagation(); + modal.hide(); + } + }} + > + + {t('viewRelatedTasksDialog.title')} + + +
+ {isError && ( +
+
+ {t('viewRelatedTasksDialog.error')} +
+ +
+ )} + + {!isError && ( + task.id} + onRowClick={(task) => handleClickTask(task.id)} + isLoading={isLoading} + emptyState={t('viewRelatedTasksDialog.empty')} + headerContent={ +
+ + {t('viewRelatedTasksDialog.tasksCount', { + count: relatedTasks.length, + })} + + + + +
+ } + /> + )} +
+
+
+ ); + } + ); diff --git a/frontend/src/components/panels/TaskPanel.tsx b/frontend/src/components/panels/TaskPanel.tsx index 7da9bb4e..1c0668f7 100644 --- a/frontend/src/components/panels/TaskPanel.tsx +++ b/frontend/src/components/panels/TaskPanel.tsx @@ -1,14 +1,16 @@ import { useTranslation } from 'react-i18next'; import { useProject } from '@/contexts/project-context'; import { useTaskAttempts } from '@/hooks/useTaskAttempts'; +import { useTaskAttempt } from '@/hooks/useTaskAttempt'; import { useNavigateWithSearch } from '@/hooks'; import { paths } from '@/lib/paths'; -import type { TaskWithAttemptStatus } from 'shared/types'; +import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types'; import { NewCardContent } from '../ui/new-card'; import { Button } from '../ui/button'; import { PlusIcon } from 'lucide-react'; import NiceModal from '@ebay/nice-modal-react'; import MarkdownRenderer from '@/components/ui/markdown-renderer'; +import { DataTable, type ColumnDef } from '@/components/ui/table'; interface TaskPanelProps { task: TaskWithAttemptStatus | null; @@ -25,6 +27,10 @@ const TaskPanel = ({ task }: TaskPanelProps) => { isError: isAttemptsError, } = useTaskAttempts(task?.id); + const { data: parentAttempt, isLoading: isParentLoading } = useTaskAttempt( + task?.parent_task_attempt || undefined + ); + const formatTimeAgo = (iso: string) => { const d = new Date(iso); const diffMs = Date.now() - d.getTime(); @@ -71,6 +77,27 @@ const TaskPanel = ({ task }: TaskPanelProps) => { const titleContent = `# ${task.title || 'Task'}`; const descriptionContent = task.description || ''; + const attemptColumns: ColumnDef[] = [ + { + id: 'executor', + header: '', + accessor: (attempt) => attempt.executor || 'Base Agent', + className: 'pr-4', + }, + { + id: 'branch', + header: '', + accessor: (attempt) => attempt.branch || '—', + className: 'pr-4', + }, + { + id: 'time', + header: '', + accessor: (attempt) => formatTimeAgo(attempt.created_at), + className: 'pr-0 text-right', + }, + ]; + return ( <> @@ -82,82 +109,66 @@ const TaskPanel = ({ task }: TaskPanelProps) => { )} -
- {isAttemptsLoading && ( +
+ {task.parent_task_attempt && ( + attempt.id} + onRowClick={(attempt) => { + if (projectId) { + navigate( + paths.attempt(projectId, attempt.task_id, attempt.id) + ); + } + }} + isLoading={isParentLoading} + headerContent="Parent Attempt" + /> + )} + + {isAttemptsLoading ? (
{t('taskPanel.loadingAttempts')}
- )} - {isAttemptsError && ( + ) : isAttemptsError ? (
{t('taskPanel.errorLoadingAttempts')}
- )} - {!isAttemptsLoading && !isAttemptsError && ( - - - - - - - - {displayedAttempts.length === 0 ? ( - - - - ) : ( - displayedAttempts.map((attempt) => ( - { - if (projectId && task.id && attempt.id) { - navigate( - paths.attempt(projectId, task.id, attempt.id) - ); - } - }} - > - - - - - )) - )} - -
-
- - {t('taskPanel.attemptsCount', { - count: displayedAttempts.length, - })} - - - - -
-
attempt.id} + onRowClick={(attempt) => { + if (projectId && task.id) { + navigate(paths.attempt(projectId, task.id, attempt.id)); + } + }} + emptyState={t('taskPanel.noAttempts')} + headerContent={ +
+ + {t('taskPanel.attemptsCount', { + count: displayedAttempts.length, + })} + + +
- {attempt.executor || 'Base Agent'} - {attempt.branch || '—'} - {formatTimeAgo(attempt.created_at)} -
+ + + +
+ } + /> )}
diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index 1605fe5b..b85d01ea 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -1,8 +1,12 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { KanbanCard } from '@/components/ui/shadcn-io/kanban'; -import { CheckCircle, Loader2, XCircle } from 'lucide-react'; +import { CheckCircle, Link, Loader2, XCircle } from 'lucide-react'; import type { TaskWithAttemptStatus } from 'shared/types'; import { ActionsDropdown } from '@/components/ui/ActionsDropdown'; +import { Button } from '@/components/ui/button'; +import { useNavigateWithSearch } from '@/hooks'; +import { paths } from '@/lib/paths'; +import { attemptsApi } from '@/lib/api'; type Task = TaskWithAttemptStatus; @@ -12,6 +16,7 @@ interface TaskCardProps { status: string; onViewDetails: (task: Task) => void; isOpen?: boolean; + projectId: string; } export function TaskCard({ @@ -20,11 +25,38 @@ export function TaskCard({ status, onViewDetails, isOpen, + projectId, }: TaskCardProps) { + const navigate = useNavigateWithSearch(); + const [isNavigatingToParent, setIsNavigatingToParent] = useState(false); + const handleClick = useCallback(() => { onViewDetails(task); }, [task, onViewDetails]); + const handleParentClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!task.parent_task_attempt || isNavigatingToParent) return; + + setIsNavigatingToParent(true); + try { + const parentAttempt = await attemptsApi.get(task.parent_task_attempt); + navigate( + paths.attempt( + projectId, + parentAttempt.task_id, + task.parent_task_attempt + ) + ); + } catch (error) { + console.error('Failed to navigate to parent task attempt:', error); + setIsNavigatingToParent(false); + } + }, + [task.parent_task_attempt, projectId, navigate, isNavigatingToParent] + ); + const localRef = useRef(null); useEffect(() => { @@ -54,27 +86,34 @@ export function TaskCard({

{task.title}

-
+
{/* In Progress Spinner */} {task.has_in_progress_attempt && ( - + )} {/* Merged Indicator */} {task.has_merged_attempt && ( - + )} {/* Failed Indicator */} {task.last_attempt_failed && !task.has_merged_attempt && ( - + + )} + {/* Parent Task Indicator */} + {task.parent_task_attempt && ( + )} {/* Actions Menu */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - -
+
{task.description && ( diff --git a/frontend/src/components/tasks/TaskKanbanBoard.tsx b/frontend/src/components/tasks/TaskKanbanBoard.tsx index 3d8e2119..e8ac26ff 100644 --- a/frontend/src/components/tasks/TaskKanbanBoard.tsx +++ b/frontend/src/components/tasks/TaskKanbanBoard.tsx @@ -20,6 +20,7 @@ interface TaskKanbanBoardProps { onViewTaskDetails: (task: Task) => void; selectedTask?: Task; onCreateTask?: () => void; + projectId: string; } function TaskKanbanBoard({ @@ -28,6 +29,7 @@ function TaskKanbanBoard({ onViewTaskDetails, selectedTask, onCreateTask, + projectId, }: TaskKanbanBoardProps) { return ( @@ -47,6 +49,7 @@ function TaskKanbanBoard({ status={status} onViewDetails={onViewTaskDetails} isOpen={selectedTask?.id === task.id} + projectId={projectId} /> ))} diff --git a/frontend/src/components/ui/ActionsDropdown.tsx b/frontend/src/components/ui/ActionsDropdown.tsx index 49956c74..8b32b750 100644 --- a/frontend/src/components/ui/ActionsDropdown.tsx +++ b/frontend/src/components/ui/ActionsDropdown.tsx @@ -14,6 +14,8 @@ import { useOpenInEditor } from '@/hooks/useOpenInEditor'; import NiceModal from '@ebay/nice-modal-react'; import { useProject } from '@/contexts/project-context'; import { openTaskForm } from '@/lib/openTaskForm'; +import { ViewRelatedTasksDialog } from '@/components/dialogs/tasks/ViewRelatedTasksDialog'; +import { useNavigate } from 'react-router-dom'; interface ActionsDropdownProps { task?: TaskWithAttemptStatus | null; @@ -24,21 +26,25 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) { const { t } = useTranslation('tasks'); const { projectId } = useProject(); const openInEditor = useOpenInEditor(attempt?.id); + const navigate = useNavigate(); const hasAttemptActions = Boolean(attempt); const hasTaskActions = Boolean(task); - const handleEdit = () => { + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); if (!projectId || !task) return; openTaskForm({ projectId, task }); }; - const handleDuplicate = () => { + const handleDuplicate = (e: React.MouseEvent) => { + e.stopPropagation(); if (!projectId || !task) return; openTaskForm({ projectId, initialTask: task }); }; - const handleDelete = async () => { + const handleDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); if (!projectId || !task) return; try { await NiceModal.show('delete-task-confirmation', { @@ -62,6 +68,21 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) { NiceModal.show('view-processes', { attemptId: attempt.id }); }; + const handleViewRelatedTasks = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!attempt?.id || !projectId) return; + NiceModal.show(ViewRelatedTasksDialog, { + attemptId: attempt.id, + projectId, + attempt, + onNavigateToTask: (taskId: string) => { + if (projectId) { + navigate(`/projects/${projectId}/tasks/${taskId}/attempts/latest`); + } + }, + }); + }; + const handleCreateNewAttempt = (e: React.MouseEvent) => { e.stopPropagation(); if (!task?.id) return; @@ -98,6 +119,8 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {