import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { AlertTriangle, Plus, X } from 'lucide-react'; import { Loader } from '@/components/ui/loader'; import { tasksApi } from '@/lib/api'; import type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types'; import { openTaskForm } from '@/lib/openTaskForm'; import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog'; import { showcases } from '@/config/showcases'; import { useUserSystem } from '@/components/ConfigProvider'; import { usePostHog } from 'posthog-js/react'; import { useSearch } from '@/contexts/SearchContext'; import { useProject } from '@/contexts/ProjectContext'; import { useTaskAttempts } from '@/hooks/useTaskAttempts'; import { useTaskAttempt } from '@/hooks/useTaskAttempt'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useBranchStatus, useAttemptExecution } from '@/hooks'; import { projectsApi } from '@/lib/api'; import { paths } from '@/lib/paths'; import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext'; import { ClickedElementsProvider } from '@/contexts/ClickedElementsProvider'; import { ReviewProvider } from '@/contexts/ReviewProvider'; import { GitOperationsProvider, useGitOperationsError, } from '@/contexts/GitOperationsContext'; import { useKeyCreate, useKeyExit, useKeyFocusSearch, useKeyNavUp, useKeyNavDown, useKeyNavLeft, useKeyNavRight, useKeyOpenDetails, Scope, useKeyDeleteTask, useKeyCycleViewBackward, } from '@/keyboard'; import TaskKanbanBoard, { type KanbanColumnItem, } from '@/components/tasks/TaskKanbanBoard'; import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban'; import { useProjectTasks, type SharedTaskRecord, } from '@/hooks/useProjectTasks'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { useHotkeysContext } from 'react-hotkeys-hook'; import { TasksLayout, type LayoutMode } from '@/components/layout/TasksLayout'; import { PreviewPanel } from '@/components/panels/PreviewPanel'; import { DiffsPanel } from '@/components/panels/DiffsPanel'; import TaskAttemptPanel from '@/components/panels/TaskAttemptPanel'; import TaskPanel from '@/components/panels/TaskPanel'; import SharedTaskPanel from '@/components/panels/SharedTaskPanel'; import TodoPanel from '@/components/tasks/TodoPanel'; import { useAuth } from '@/hooks'; import { NewCard, NewCardHeader } from '@/components/ui/new-card'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; import { AttemptHeaderActions } from '@/components/panels/AttemptHeaderActions'; import { TaskPanelHeaderActions } from '@/components/panels/TaskPanelHeaderActions'; import type { TaskWithAttemptStatus, TaskStatus } from 'shared/types'; type Task = TaskWithAttemptStatus; const TASK_STATUSES = [ 'todo', 'inprogress', 'inreview', 'done', 'cancelled', ] as const; const normalizeStatus = (status: string): TaskStatus => status.toLowerCase() as TaskStatus; function GitErrorBanner() { const { error: gitError } = useGitOperationsError(); if (!gitError) return null; return (
{gitError}
); } function DiffsPanelContainer({ attempt, selectedTask, projectId, branchStatus, branches, }: { attempt: TaskAttempt | null; selectedTask: TaskWithAttemptStatus | null; projectId: string; branchStatus: BranchStatus | null; branches: GitBranch[]; }) { const { isAttemptRunning } = useAttemptExecution(attempt?.id); return ( ); } export function ProjectTasks() { const { t } = useTranslation(['tasks', 'common']); const { taskId, attemptId } = useParams<{ projectId: string; taskId?: string; attemptId?: string; }>(); const navigate = useNavigate(); const { enableScope, disableScope, activeScopes } = useHotkeysContext(); const [searchParams, setSearchParams] = useSearchParams(); const isXL = useMediaQuery('(min-width: 1280px)'); const isMobile = !isXL; const posthog = usePostHog(); const [selectedSharedTaskId, setSelectedSharedTaskId] = useState< string | null >(null); const { userId } = useAuth(); const { projectId, isLoading: projectLoading, error: projectError, } = useProject(); useEffect(() => { enableScope(Scope.KANBAN); return () => { disableScope(Scope.KANBAN); }; }, [enableScope, disableScope]); const handleCreateTask = useCallback(() => { if (projectId) { openTaskForm({ mode: 'create', projectId }); } }, [projectId]); const { query: searchQuery, focusInput } = useSearch(); const { tasks, tasksById, sharedTasksById, sharedOnlyByStatus, isLoading, error: streamError, } = useProjectTasks(projectId || ''); const selectedTask = useMemo( () => (taskId ? (tasksById[taskId] ?? null) : null), [taskId, tasksById] ); const selectedSharedTask = useMemo(() => { if (!selectedSharedTaskId) return null; return sharedTasksById[selectedSharedTaskId] ?? null; }, [selectedSharedTaskId, sharedTasksById]); useEffect(() => { if (taskId) { setSelectedSharedTaskId(null); } }, [taskId]); const isTaskPanelOpen = Boolean(taskId && selectedTask); const isSharedPanelOpen = Boolean(selectedSharedTask); const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen; const { config, updateAndSaveConfig, loading } = useUserSystem(); const isLoaded = !loading; const showcaseId = showcases.taskPanel.id; const seenFeatures = useMemo( () => config?.showcases?.seen_features ?? [], [config?.showcases?.seen_features] ); const seen = isLoaded && seenFeatures.includes(showcaseId); useEffect(() => { if (!isLoaded || !isPanelOpen || seen) return; FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => { FeatureShowcaseDialog.hide(); if (seenFeatures.includes(showcaseId)) return; void updateAndSaveConfig({ showcases: { seen_features: [...seenFeatures, showcaseId] }, }); }); }, [ isLoaded, isPanelOpen, seen, showcaseId, updateAndSaveConfig, seenFeatures, ]); const isLatest = attemptId === 'latest'; const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts( taskId, { enabled: !!taskId && isLatest, } ); const latestAttemptId = useMemo(() => { if (!attempts?.length) return undefined; return [...attempts].sort((a, b) => { const diff = new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); if (diff !== 0) return diff; return a.id.localeCompare(b.id); })[0].id; }, [attempts]); const navigateWithSearch = useCallback( (pathname: string, options?: { replace?: boolean }) => { const search = searchParams.toString(); navigate({ pathname, search: search ? `?${search}` : '' }, options); }, [navigate, searchParams] ); useEffect(() => { if (!projectId || !taskId) return; if (!isLatest) return; if (isAttemptsLoading) return; if (!latestAttemptId) { navigateWithSearch(paths.task(projectId, taskId), { replace: true }); return; } navigateWithSearch(paths.attempt(projectId, taskId, latestAttemptId), { replace: true, }); }, [ projectId, taskId, isLatest, isAttemptsLoading, latestAttemptId, navigate, navigateWithSearch, ]); useEffect(() => { if (!projectId || !taskId || isLoading) return; if (selectedTask === null) { navigate(`/projects/${projectId}/tasks`, { replace: true }); } }, [projectId, taskId, isLoading, selectedTask, navigate]); const effectiveAttemptId = attemptId === 'latest' ? undefined : attemptId; const isTaskView = !!taskId && !effectiveAttemptId; const { data: attempt } = useTaskAttempt(effectiveAttemptId); const { data: branchStatus } = useBranchStatus(attempt?.id); const [branches, setBranches] = useState([]); useEffect(() => { if (!projectId) return; projectsApi .getBranches(projectId) .then(setBranches) .catch(() => setBranches([])); }, [projectId]); const rawMode = searchParams.get('view') as LayoutMode; const mode: LayoutMode = rawMode === 'preview' || rawMode === 'diffs' ? rawMode : null; // TODO: Remove this redirect after v0.1.0 (legacy URL support for bookmarked links) // Migrates old `view=logs` to `view=diffs` useEffect(() => { const view = searchParams.get('view'); if (view === 'logs') { const params = new URLSearchParams(searchParams); params.set('view', 'diffs'); setSearchParams(params, { replace: true }); } }, [searchParams, setSearchParams]); const setMode = useCallback( (newMode: LayoutMode) => { const params = new URLSearchParams(searchParams); if (newMode === null) { params.delete('view'); } else { params.set('view', newMode); } setSearchParams(params, { replace: true }); }, [searchParams, setSearchParams] ); const handleCreateNewTask = useCallback(() => { handleCreateTask(); }, [handleCreateTask]); useKeyCreate(handleCreateNewTask, { scope: Scope.KANBAN, preventDefault: true, }); useKeyFocusSearch( () => { focusInput(); }, { scope: Scope.KANBAN, preventDefault: true, } ); useKeyExit( () => { if (isPanelOpen) { handleClosePanel(); } else { navigate('/projects'); } }, { scope: Scope.KANBAN } ); const hasSearch = Boolean(searchQuery.trim()); const normalizedSearch = searchQuery.trim().toLowerCase(); const showSharedTasks = searchParams.get('shared') !== 'off'; useEffect(() => { if (showSharedTasks) return; if (!selectedSharedTaskId) return; const sharedTask = sharedTasksById[selectedSharedTaskId]; if (sharedTask && sharedTask.assignee_user_id === userId) { return; } setSelectedSharedTaskId(null); }, [selectedSharedTaskId, sharedTasksById, showSharedTasks, userId]); const kanbanColumns = useMemo(() => { const columns: Record = { todo: [], inprogress: [], inreview: [], done: [], cancelled: [], }; const matchesSearch = ( title: string, description?: string | null ): boolean => { if (!hasSearch) return true; const lowerTitle = title.toLowerCase(); const lowerDescription = description?.toLowerCase() ?? ''; return ( lowerTitle.includes(normalizedSearch) || lowerDescription.includes(normalizedSearch) ); }; tasks.forEach((task) => { const statusKey = normalizeStatus(task.status); const sharedTask = task.shared_task_id ? sharedTasksById[task.shared_task_id] : sharedTasksById[task.id]; if (!matchesSearch(task.title, task.description)) { return; } const isSharedAssignedElsewhere = !showSharedTasks && !!sharedTask && !!sharedTask.assignee_user_id && sharedTask.assignee_user_id !== userId; if (isSharedAssignedElsewhere) { return; } columns[statusKey].push({ type: 'task', task, sharedTask, }); }); ( Object.entries(sharedOnlyByStatus) as [TaskStatus, SharedTaskRecord[]][] ).forEach(([status, items]) => { if (!columns[status]) { columns[status] = []; } items.forEach((sharedTask) => { if (!matchesSearch(sharedTask.title, sharedTask.description)) { return; } const shouldIncludeShared = showSharedTasks || sharedTask.assignee_user_id === userId; if (!shouldIncludeShared) { return; } columns[status].push({ type: 'shared', task: sharedTask, }); }); }); const getTimestamp = (item: KanbanColumnItem) => { const createdAt = item.type === 'task' ? item.task.created_at : item.task.created_at; if (createdAt instanceof Date) { return createdAt.getTime(); } return new Date(createdAt).getTime(); }; TASK_STATUSES.forEach((status) => { columns[status].sort((a, b) => getTimestamp(b) - getTimestamp(a)); }); return columns; }, [ hasSearch, normalizedSearch, tasks, sharedOnlyByStatus, sharedTasksById, showSharedTasks, userId, ]); const visibleTasksByStatus = useMemo(() => { const map: Record = { todo: [], inprogress: [], inreview: [], done: [], cancelled: [], }; TASK_STATUSES.forEach((status) => { map[status] = kanbanColumns[status] .filter((item) => item.type === 'task') .map((item) => item.task); }); return map; }, [kanbanColumns]); const hasVisibleLocalTasks = useMemo( () => Object.values(visibleTasksByStatus).some( (items) => items && items.length > 0 ), [visibleTasksByStatus] ); const hasVisibleSharedTasks = useMemo( () => Object.values(kanbanColumns).some((items) => items.some((item) => item.type === 'shared') ), [kanbanColumns] ); useKeyNavUp( () => { selectPreviousTask(); }, { scope: Scope.KANBAN, preventDefault: true, } ); useKeyNavDown( () => { selectNextTask(); }, { scope: Scope.KANBAN, preventDefault: true, } ); useKeyNavLeft( () => { selectPreviousColumn(); }, { scope: Scope.KANBAN, preventDefault: true, } ); useKeyNavRight( () => { selectNextColumn(); }, { scope: Scope.KANBAN, preventDefault: true, } ); /** * Cycle the attempt area view. * - When panel is closed: opens task details (if a task is selected) * - When panel is open: cycles among [attempt, preview, diffs] */ const cycleView = useCallback( (direction: 'forward' | 'backward' = 'forward') => { const order: LayoutMode[] = [null, 'preview', 'diffs']; const idx = order.indexOf(mode); const next = direction === 'forward' ? order[(idx + 1) % order.length] : order[(idx - 1 + order.length) % order.length]; setMode(next); }, [mode, setMode] ); const cycleViewForward = useCallback(() => cycleView('forward'), [cycleView]); const cycleViewBackward = useCallback( () => cycleView('backward'), [cycleView] ); // meta/ctrl+enter → open details or cycle forward const isFollowUpReadyActive = activeScopes.includes(Scope.FOLLOW_UP_READY); useKeyOpenDetails( () => { if (isPanelOpen) { // Track keyboard shortcut before cycling view const order: LayoutMode[] = [null, 'preview', 'diffs']; const idx = order.indexOf(mode); const next = order[(idx + 1) % order.length]; if (next === 'preview') { posthog?.capture('preview_navigated', { trigger: 'keyboard', direction: 'forward', timestamp: new Date().toISOString(), source: 'frontend', }); } else if (next === 'diffs') { posthog?.capture('diffs_navigated', { trigger: 'keyboard', direction: 'forward', timestamp: new Date().toISOString(), source: 'frontend', }); } cycleViewForward(); } else if (selectedTask) { handleViewTaskDetails(selectedTask); } }, { scope: Scope.KANBAN, when: () => !isFollowUpReadyActive } ); // meta/ctrl+shift+enter → cycle backward useKeyCycleViewBackward( () => { if (isPanelOpen) { // Track keyboard shortcut before cycling view const order: LayoutMode[] = [null, 'preview', 'diffs']; const idx = order.indexOf(mode); const next = order[(idx - 1 + order.length) % order.length]; if (next === 'preview') { posthog?.capture('preview_navigated', { trigger: 'keyboard', direction: 'backward', timestamp: new Date().toISOString(), source: 'frontend', }); } else if (next === 'diffs') { posthog?.capture('diffs_navigated', { trigger: 'keyboard', direction: 'backward', timestamp: new Date().toISOString(), source: 'frontend', }); } cycleViewBackward(); } }, { scope: Scope.KANBAN, preventDefault: true } ); useKeyDeleteTask( () => { // Note: Delete is now handled by TaskActionsDropdown // This keyboard shortcut could trigger the dropdown action if needed }, { scope: Scope.KANBAN, preventDefault: true, } ); const handleClosePanel = useCallback(() => { if (projectId) { navigate(`/projects/${projectId}/tasks`, { replace: true }); } }, [projectId, navigate]); const handleViewTaskDetails = useCallback( (task: Task, attemptIdToShow?: string) => { if (!projectId) return; setSelectedSharedTaskId(null); if (attemptIdToShow) { navigateWithSearch(paths.attempt(projectId, task.id, attemptIdToShow)); } else { navigateWithSearch(`${paths.task(projectId, task.id)}/attempts/latest`); } }, [projectId, navigateWithSearch] ); const handleViewSharedTask = useCallback( (sharedTask: SharedTaskRecord) => { setSelectedSharedTaskId(sharedTask.id); setMode(null); if (projectId) { navigateWithSearch(paths.projectTasks(projectId), { replace: true }); } }, [navigateWithSearch, projectId, setMode] ); const selectNextTask = useCallback(() => { if (selectedTask) { const statusKey = normalizeStatus(selectedTask.status); const tasksInStatus = visibleTasksByStatus[statusKey] || []; const currentIndex = tasksInStatus.findIndex( (task) => task.id === selectedTask.id ); if (currentIndex >= 0 && currentIndex < tasksInStatus.length - 1) { handleViewTaskDetails(tasksInStatus[currentIndex + 1]); } } else { for (const status of TASK_STATUSES) { const tasks = visibleTasksByStatus[status]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); break; } } } }, [selectedTask, visibleTasksByStatus, handleViewTaskDetails]); const selectPreviousTask = useCallback(() => { if (selectedTask) { const statusKey = normalizeStatus(selectedTask.status); const tasksInStatus = visibleTasksByStatus[statusKey] || []; const currentIndex = tasksInStatus.findIndex( (task) => task.id === selectedTask.id ); if (currentIndex > 0) { handleViewTaskDetails(tasksInStatus[currentIndex - 1]); } } else { for (const status of TASK_STATUSES) { const tasks = visibleTasksByStatus[status]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); break; } } } }, [selectedTask, visibleTasksByStatus, handleViewTaskDetails]); const selectNextColumn = useCallback(() => { if (selectedTask) { const currentStatus = normalizeStatus(selectedTask.status); const currentIndex = TASK_STATUSES.findIndex( (status) => status === currentStatus ); for (let i = currentIndex + 1; i < TASK_STATUSES.length; i++) { const tasks = visibleTasksByStatus[TASK_STATUSES[i]]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); return; } } } else { for (const status of TASK_STATUSES) { const tasks = visibleTasksByStatus[status]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); break; } } } }, [selectedTask, visibleTasksByStatus, handleViewTaskDetails]); const selectPreviousColumn = useCallback(() => { if (selectedTask) { const currentStatus = normalizeStatus(selectedTask.status); const currentIndex = TASK_STATUSES.findIndex( (status) => status === currentStatus ); for (let i = currentIndex - 1; i >= 0; i--) { const tasks = visibleTasksByStatus[TASK_STATUSES[i]]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); return; } } } else { for (const status of TASK_STATUSES) { const tasks = visibleTasksByStatus[status]; if (tasks && tasks.length > 0) { handleViewTaskDetails(tasks[0]); break; } } } }, [selectedTask, visibleTasksByStatus, handleViewTaskDetails]); const handleDragEnd = useCallback( async (event: DragEndEvent) => { const { active, over } = event; if (!over || !active.data.current) return; const draggedTaskId = active.id as string; const newStatus = over.id as Task['status']; const task = tasksById[draggedTaskId]; if (!task || task.status === newStatus) return; try { await tasksApi.update(draggedTaskId, { title: task.title, description: task.description, status: newStatus, parent_task_attempt: task.parent_task_attempt, image_ids: null, }); } catch (err) { console.error('Failed to update task status:', err); } }, [tasksById] ); const getSharedTask = useCallback( (task: Task | null | undefined) => { if (!task) return undefined; if (task.shared_task_id) { return sharedTasksById[task.shared_task_id]; } return sharedTasksById[task.id]; }, [sharedTasksById] ); const hasSharedTasks = useMemo(() => { return Object.values(kanbanColumns).some((items) => items.some((item) => { if (item.type === 'shared') return true; return Boolean(item.sharedTask); }) ); }, [kanbanColumns]); const isInitialTasksLoad = isLoading && tasks.length === 0; if (projectError) { return (
{t('common:states.error')} {projectError.message || 'Failed to load project'}
); } if (projectLoading && isInitialTasksLoad) { return ; } const truncateTitle = (title: string | undefined, maxLength = 20) => { if (!title) return 'Task'; if (title.length <= maxLength) return title; const truncated = title.substring(0, maxLength); const lastSpace = truncated.lastIndexOf(' '); return lastSpace > 0 ? `${truncated.substring(0, lastSpace)}...` : `${truncated}...`; }; const kanbanContent = tasks.length === 0 && !hasSharedTasks ? (

{t('empty.noTasks')}

) : !hasVisibleLocalTasks && !hasVisibleSharedTasks ? (

{t('empty.noSearchResults')}

) : (
); const rightHeader = selectedTask ? ( navigate(`/projects/${projectId}/tasks`, { replace: true }) } /> ) : ( navigate(`/projects/${projectId}/tasks`, { replace: true }) } /> ) } >
{isTaskView ? ( {truncateTitle(selectedTask?.title)} ) : ( navigateWithSearch(paths.task(projectId!, taskId!)) } > {truncateTitle(selectedTask?.title)} )} {!isTaskView && ( <> {attempt?.branch || 'Task Attempt'} )}
) : selectedSharedTask ? ( { setSelectedSharedTaskId(null); if (projectId) { navigateWithSearch(paths.projectTasks(projectId), { replace: true, }); } }} > } >
{truncateTitle(selectedSharedTask?.title)}
) : null; const attemptContent = selectedTask ? ( {isTaskView ? ( ) : ( {({ logs, followUp }) => ( <>
{logs}
{followUp}
)}
)}
) : selectedSharedTask ? ( ) : null; const auxContent = selectedTask && attempt ? (
{mode === 'preview' && } {mode === 'diffs' && ( )}
) : (
); const effectiveMode: LayoutMode = selectedSharedTask ? null : mode; const attemptArea = ( ); return (
{streamError && ( {t('common:states.reconnecting')} {streamError} )}
{attemptArea}
); }