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 (
);
}
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 }) => (
<>
>
)}
)}
) : 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}
);
}