diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49c6dd06..ff8715bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Navbar } from '@/components/layout/navbar' import { HomePage } from '@/pages/home' import { Projects } from '@/pages/projects' import { ProjectTasks } from '@/pages/project-tasks' +import { TaskDetailsPage } from '@/pages/task-details' import { Users } from '@/pages/users' import { AuthProvider, useAuth } from '@/contexts/auth-context' @@ -42,6 +43,7 @@ function AppContent() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/tasks/index.ts b/frontend/src/components/tasks/index.ts index e43aa621..ce678a1e 100644 --- a/frontend/src/components/tasks/index.ts +++ b/frontend/src/components/tasks/index.ts @@ -1,5 +1,4 @@ export { TaskCreateDialog } from './TaskCreateDialog' export { TaskEditDialog } from './TaskEditDialog' -export { TaskDetailsDialog } from './TaskDetailsDialog' export { TaskCard } from './TaskCard' export { TaskKanbanBoard } from './TaskKanbanBoard' diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index 657a670d..20696de6 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -6,7 +6,7 @@ import { ArrowLeft, Plus } from 'lucide-react' import { makeAuthenticatedRequest } from '@/lib/auth' import { TaskCreateDialog } from '@/components/tasks/TaskCreateDialog' import { TaskEditDialog } from '@/components/tasks/TaskEditDialog' -import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog' + import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard' import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types' import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban' @@ -41,8 +41,7 @@ export function ProjectTasks() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [editingTask, setEditingTask] = useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [selectedTask, setSelectedTask] = useState(null) - const [isTaskDetailsDialogOpen, setIsTaskDetailsDialogOpen] = useState(false) + useEffect(() => { @@ -159,8 +158,7 @@ export function ProjectTasks() { } const handleViewTaskDetails = (task: Task) => { - setSelectedTask(task) - setIsTaskDetailsDialogOpen(true) + navigate(`/projects/${projectId}/tasks/${task.id}`) } const handleDragEnd = async (event: DragEndEvent) => { @@ -283,13 +281,7 @@ export function ProjectTasks() { onUpdateTask={handleUpdateTask} /> - + ) } diff --git a/frontend/src/pages/task-details.tsx b/frontend/src/pages/task-details.tsx new file mode 100644 index 00000000..d391c424 --- /dev/null +++ b/frontend/src/pages/task-details.tsx @@ -0,0 +1,727 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { ArrowLeft } from "lucide-react"; +import { makeAuthenticatedRequest } from "@/lib/auth"; +import type { + TaskStatus, + TaskAttempt, + TaskAttemptActivity, +} from "shared/types"; + +interface Task { + id: string; + project_id: string; + title: string; + description: string | null; + status: TaskStatus; + created_at: string; + updated_at: string; +} + +interface ApiResponse { + success: boolean; + data: T | null; + message: string | null; +} + +const statusLabels: Record = { + todo: "To Do", + inprogress: "In Progress", + inreview: "In Review", + done: "Done", + cancelled: "Cancelled", +}; + +export function TaskDetailsPage() { + const { projectId, taskId } = useParams<{ + projectId: string; + taskId: string; + }>(); + const navigate = useNavigate(); + + const [task, setTask] = useState(null); + const [taskLoading, setTaskLoading] = useState(true); + const [taskAttempts, setTaskAttempts] = useState([]); + const [taskAttemptsLoading, setTaskAttemptsLoading] = useState(false); + const [selectedAttempt, setSelectedAttempt] = useState( + null + ); + const [attemptActivities, setAttemptActivities] = useState< + TaskAttemptActivity[] + >([]); + const [activitiesLoading, setActivitiesLoading] = useState(false); + const [selectedExecutor, setSelectedExecutor] = useState("echo"); + const [creatingAttempt, setCreatingAttempt] = useState(false); + const [stoppingAttempt, setStoppingAttempt] = useState(false); + const [error, setError] = useState(null); + + // Edit mode state + const [isEditMode, setIsEditMode] = useState(false); + const [editedTitle, setEditedTitle] = useState(""); + const [editedDescription, setEditedDescription] = useState(""); + const [editedStatus, setEditedStatus] = useState("todo"); + const [savingTask, setSavingTask] = useState(false); + + // Check if the selected attempt is currently running (latest activity is "inprogress") + const isAttemptRunning = selectedAttempt && attemptActivities.length > 0 && + attemptActivities[0].status === "inprogress"; + + useEffect(() => { + if (projectId && taskId) { + fetchTask(); + } + }, [projectId, taskId]); + + useEffect(() => { + if (task) { + fetchTaskAttempts(task.id); + // Initialize edit state with current task values + setEditedTitle(task.title); + setEditedDescription(task.description || ""); + setEditedStatus(task.status); + setIsEditMode(false); + } + }, [task]); + + const fetchTask = async () => { + if (!projectId || !taskId) return; + + try { + setTaskLoading(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${taskId}` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setTask(result.data); + } else { + setError("Failed to load task"); + } + } else { + setError("Failed to load task"); + } + } catch (err) { + setError("Failed to load task"); + } finally { + setTaskLoading(false); + } + }; + + const fetchTaskAttempts = async (taskId: string) => { + if (!projectId) return; + + try { + setTaskAttemptsLoading(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setTaskAttempts(result.data); + // Automatically select the latest attempt if available + if (result.data.length > 0) { + const latestAttempt = result.data.reduce((latest, current) => + new Date(current.created_at) > new Date(latest.created_at) + ? current + : latest + ); + setSelectedAttempt(latestAttempt); + fetchAttemptActivities(latestAttempt.id); + } + } + } else { + setError("Failed to load task attempts"); + } + } catch (err) { + setError("Failed to load task attempts"); + } finally { + setTaskAttemptsLoading(false); + } + }; + + const fetchAttemptActivities = async (attemptId: string) => { + if (!task || !projectId) return; + + try { + setActivitiesLoading(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities` + ); + + if (response.ok) { + const result: ApiResponse = + await response.json(); + if (result.success && result.data) { + setAttemptActivities(result.data); + } + } else { + setError("Failed to load attempt activities"); + } + } catch (err) { + setError("Failed to load attempt activities"); + } finally { + setActivitiesLoading(false); + } + }; + + const handleAttemptClick = (attempt: TaskAttempt) => { + setSelectedAttempt(attempt); + fetchAttemptActivities(attempt.id); + }; + + const saveTaskChanges = async () => { + if (!task || !projectId) return; + + try { + setSavingTask(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${task.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: editedTitle, + description: editedDescription || null, + status: editedStatus, + }), + } + ); + + if (response.ok) { + setIsEditMode(false); + // Update the local task state + setTask({ + ...task, + title: editedTitle, + description: editedDescription || null, + status: editedStatus, + }); + } else { + setError("Failed to save task changes"); + } + } catch (err) { + setError("Failed to save task changes"); + } finally { + setSavingTask(false); + } + }; + + const cancelEdit = () => { + if (task) { + setEditedTitle(task.title); + setEditedDescription(task.description || ""); + setEditedStatus(task.status); + } + setIsEditMode(false); + }; + + const createNewAttempt = async () => { + if (!task || !projectId) return; + + try { + setCreatingAttempt(true); + const worktreePath = `/tmp/task-${task.id}-attempt-${Date.now()}`; + + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + task_id: task.id, + worktree_path: worktreePath, + base_commit: null, + merge_commit: null, + executor: selectedExecutor, + }), + } + ); + + if (response.ok) { + // Refresh the attempts list + await fetchTaskAttempts(task.id); + } else { + setError("Failed to create task attempt"); + } + } catch (err) { + setError("Failed to create task attempt"); + } finally { + setCreatingAttempt(false); + } + }; + + const stopTaskAttempt = async () => { + if (!task || !selectedAttempt || !projectId) return; + + try { + setStoppingAttempt(true); + const response = await makeAuthenticatedRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (response.ok) { + // Refresh the activities list to show the stopped status + fetchAttemptActivities(selectedAttempt.id); + } else { + setError("Failed to stop task attempt"); + } + } catch (err) { + setError("Failed to stop task attempt"); + } finally { + setStoppingAttempt(false); + } + }; + + const handleBackClick = () => { + navigate(`/projects/${projectId}/tasks`); + }; + + if (taskLoading) { + return ( +
+
+
+

Loading task...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!task) { + return ( +
+
+

Task not found

+ +
+
+ ); + } + + return ( +
+
+
+ +

+ {isEditMode ? "Edit Task" : "Task Details"} +

+
+
+ {isEditMode ? ( + <> + + + + ) : ( + + )} +
+
+ +
+ {/* Main Content */} +
+ {/* Task Details */} + + +
+
+ + {isEditMode ? ( + setEditedTitle(e.target.value)} + className="mt-1" + placeholder="Enter task title..." + /> + ) : ( +

+ {task.title} +

+ )} +
+ +
+ + {isEditMode ? ( +