keyboard shortcuts to navigate the kanban board. In kanban boards with many tasks in a single column, when we use keyboard shortcuts to select a task 'below the fold', the animation restarts from the top of the kanban board, rather than preserving the existing position.
1068 lines
30 KiB
TypeScript
1068 lines
30 KiB
TypeScript
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 (
|
|
<div className="mx-4 mt-4 p-3 border border-destructive rounded">
|
|
<div className="text-destructive text-sm">{gitError}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<DiffsPanel
|
|
selectedAttempt={attempt}
|
|
gitOps={
|
|
attempt && selectedTask
|
|
? {
|
|
task: selectedTask,
|
|
projectId,
|
|
branchStatus: branchStatus ?? null,
|
|
branches,
|
|
isAttemptRunning,
|
|
selectedBranch: branchStatus?.target_branch_name ?? null,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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<GitBranch[]>([]);
|
|
|
|
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<TaskStatus, KanbanColumnItem[]> = {
|
|
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<TaskStatus, Task[]> = {
|
|
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 (
|
|
<div className="p-4">
|
|
<Alert>
|
|
<AlertTitle className="flex items-center gap-2">
|
|
<AlertTriangle size="16" />
|
|
{t('common:states.error')}
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
{projectError.message || 'Failed to load project'}
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (projectLoading && isInitialTasksLoad) {
|
|
return <Loader message={t('loading')} size={32} className="py-8" />;
|
|
}
|
|
|
|
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 ? (
|
|
<div className="max-w-7xl mx-auto mt-8">
|
|
<Card>
|
|
<CardContent className="text-center py-8">
|
|
<p className="text-muted-foreground">{t('empty.noTasks')}</p>
|
|
<Button className="mt-4" onClick={handleCreateNewTask}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t('empty.createFirst')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : !hasVisibleLocalTasks && !hasVisibleSharedTasks ? (
|
|
<div className="max-w-7xl mx-auto mt-8">
|
|
<Card>
|
|
<CardContent className="text-center py-8">
|
|
<p className="text-muted-foreground">
|
|
{t('empty.noSearchResults')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-full overflow-x-auto overflow-y-auto overscroll-x-contain">
|
|
<TaskKanbanBoard
|
|
columns={kanbanColumns}
|
|
onDragEnd={handleDragEnd}
|
|
onViewTaskDetails={handleViewTaskDetails}
|
|
onViewSharedTask={handleViewSharedTask}
|
|
selectedTaskId={selectedTask?.id}
|
|
selectedSharedTaskId={selectedSharedTaskId}
|
|
onCreateTask={handleCreateNewTask}
|
|
projectId={projectId!}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const rightHeader = selectedTask ? (
|
|
<NewCardHeader
|
|
className="shrink-0"
|
|
actions={
|
|
isTaskView ? (
|
|
<TaskPanelHeaderActions
|
|
task={selectedTask}
|
|
sharedTask={getSharedTask(selectedTask)}
|
|
onClose={() =>
|
|
navigate(`/projects/${projectId}/tasks`, { replace: true })
|
|
}
|
|
/>
|
|
) : (
|
|
<AttemptHeaderActions
|
|
mode={mode}
|
|
onModeChange={setMode}
|
|
task={selectedTask}
|
|
sharedTask={getSharedTask(selectedTask)}
|
|
attempt={attempt ?? null}
|
|
onClose={() =>
|
|
navigate(`/projects/${projectId}/tasks`, { replace: true })
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
>
|
|
<div className="mx-auto w-full">
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
{isTaskView ? (
|
|
<BreadcrumbPage>
|
|
{truncateTitle(selectedTask?.title)}
|
|
</BreadcrumbPage>
|
|
) : (
|
|
<BreadcrumbLink
|
|
className="cursor-pointer hover:underline"
|
|
onClick={() =>
|
|
navigateWithSearch(paths.task(projectId!, taskId!))
|
|
}
|
|
>
|
|
{truncateTitle(selectedTask?.title)}
|
|
</BreadcrumbLink>
|
|
)}
|
|
</BreadcrumbItem>
|
|
{!isTaskView && (
|
|
<>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage>
|
|
{attempt?.branch || 'Task Attempt'}
|
|
</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</>
|
|
)}
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
</div>
|
|
</NewCardHeader>
|
|
) : selectedSharedTask ? (
|
|
<NewCardHeader
|
|
className="shrink-0"
|
|
actions={
|
|
<Button
|
|
variant="icon"
|
|
aria-label={t('common:buttons.close', { defaultValue: 'Close' })}
|
|
onClick={() => {
|
|
setSelectedSharedTaskId(null);
|
|
if (projectId) {
|
|
navigateWithSearch(paths.projectTasks(projectId), {
|
|
replace: true,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<X size={16} />
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="mx-auto w-full">
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage>
|
|
{truncateTitle(selectedSharedTask?.title)}
|
|
</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
</div>
|
|
</NewCardHeader>
|
|
) : null;
|
|
|
|
const attemptContent = selectedTask ? (
|
|
<NewCard className="h-full min-h-0 flex flex-col bg-diagonal-lines bg-muted border-0">
|
|
{isTaskView ? (
|
|
<TaskPanel task={selectedTask} />
|
|
) : (
|
|
<TaskAttemptPanel attempt={attempt} task={selectedTask}>
|
|
{({ logs, followUp }) => (
|
|
<>
|
|
<GitErrorBanner />
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
<div className="flex-1 min-h-0 flex flex-col">{logs}</div>
|
|
|
|
<div className="shrink-0 border-t">
|
|
<div className="mx-auto w-full max-w-[50rem]">
|
|
<TodoPanel />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 max-h-[50%] border-t overflow-hidden">
|
|
<div className="mx-auto w-full max-w-[50rem] h-full min-h-0">
|
|
{followUp}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TaskAttemptPanel>
|
|
)}
|
|
</NewCard>
|
|
) : selectedSharedTask ? (
|
|
<NewCard className="h-full min-h-0 flex flex-col bg-diagonal-lines bg-muted border-0">
|
|
<SharedTaskPanel task={selectedSharedTask} />
|
|
</NewCard>
|
|
) : null;
|
|
|
|
const auxContent =
|
|
selectedTask && attempt ? (
|
|
<div className="relative h-full w-full">
|
|
{mode === 'preview' && <PreviewPanel />}
|
|
{mode === 'diffs' && (
|
|
<DiffsPanelContainer
|
|
attempt={attempt}
|
|
selectedTask={selectedTask}
|
|
projectId={projectId!}
|
|
branchStatus={branchStatus ?? null}
|
|
branches={branches}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="relative h-full w-full" />
|
|
);
|
|
|
|
const effectiveMode: LayoutMode = selectedSharedTask ? null : mode;
|
|
|
|
const attemptArea = (
|
|
<GitOperationsProvider attemptId={attempt?.id}>
|
|
<ClickedElementsProvider attempt={attempt}>
|
|
<ReviewProvider attemptId={attempt?.id}>
|
|
<ExecutionProcessesProvider attemptId={attempt?.id}>
|
|
<TasksLayout
|
|
kanban={kanbanContent}
|
|
attempt={attemptContent}
|
|
aux={auxContent}
|
|
isPanelOpen={isPanelOpen}
|
|
mode={effectiveMode}
|
|
isMobile={isMobile}
|
|
rightHeader={rightHeader}
|
|
/>
|
|
</ExecutionProcessesProvider>
|
|
</ReviewProvider>
|
|
</ClickedElementsProvider>
|
|
</GitOperationsProvider>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-full h-full flex flex-col">
|
|
{streamError && (
|
|
<Alert className="w-full z-30 xl:sticky xl:top-0">
|
|
<AlertTitle className="flex items-center gap-2">
|
|
<AlertTriangle size="16" />
|
|
{t('common:states.reconnecting')}
|
|
</AlertTitle>
|
|
<AlertDescription>{streamError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex-1 min-h-0">{attemptArea}</div>
|
|
</div>
|
|
);
|
|
}
|