diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx new file mode 100644 index 00000000..03cbbdfb --- /dev/null +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -0,0 +1,548 @@ +import { useState, useEffect } from "react"; +import { X, History, Send, Clock, FileText, Code } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { makeRequest } from "@/lib/api"; +import type { + TaskStatus, + TaskAttempt, + TaskAttemptActivity, + TaskAttemptStatus, + ExecutionProcess, + ExecutionProcessStatus, + ExecutionProcessType, + ApiResponse, + TaskWithAttemptStatus, +} from "shared/types"; + +interface TaskDetailsPanelProps { + task: TaskWithAttemptStatus | null; + projectId: string; + isOpen: boolean; + onClose: () => void; +} + +const statusLabels: Record = { + todo: "To Do", + inprogress: "In Progress", + inreview: "In Review", + done: "Done", + cancelled: "Cancelled", +}; + +const getAttemptStatusDisplay = (status: TaskAttemptStatus): { label: string; className: string } => { + switch (status) { + case "init": + return { label: "Init", className: "bg-status-init text-status-init-foreground" }; + case "setuprunning": + return { label: "Setup Running", className: "bg-status-running text-status-running-foreground" }; + case "setupcomplete": + return { label: "Setup Complete", className: "bg-status-complete text-status-complete-foreground" }; + case "setupfailed": + return { label: "Setup Failed", className: "bg-status-failed text-status-failed-foreground" }; + case "executorrunning": + return { label: "Executor Running", className: "bg-status-running text-status-running-foreground" }; + case "executorcomplete": + return { label: "Executor Complete", className: "bg-status-complete text-status-complete-foreground" }; + case "executorfailed": + return { label: "Executor Failed", className: "bg-status-failed text-status-failed-foreground" }; + case "paused": + return { label: "Paused", className: "bg-status-paused text-status-paused-foreground" }; + default: + return { label: "Unknown", className: "bg-status-init text-status-init-foreground" }; + } +}; + +const getProcessStatusDisplay = (status: ExecutionProcessStatus): { label: string; className: string } => { + switch (status) { + case "running": + return { label: "Running", className: "bg-status-running text-status-running-foreground" }; + case "completed": + return { label: "Completed", className: "bg-status-complete text-status-complete-foreground" }; + case "failed": + return { label: "Failed", className: "bg-status-failed text-status-failed-foreground" }; + case "killed": + return { label: "Killed", className: "bg-status-failed text-status-failed-foreground" }; + default: + return { label: "Unknown", className: "bg-status-init text-status-init-foreground" }; + } +}; + +const getProcessTypeDisplay = (type: ExecutionProcessType): string => { + switch (type) { + case "setupscript": + return "Setup Script"; + case "codingagent": + return "Coding Agent"; + case "devserver": + return "Dev Server"; + default: + return "Unknown"; + } +}; + +export function TaskDetailsPanel({ task, projectId, isOpen, onClose }: TaskDetailsPanelProps) { + const [taskAttempts, setTaskAttempts] = useState([]); + const [selectedAttempt, setSelectedAttempt] = useState(null); + const [attemptActivities, setAttemptActivities] = useState([]); + const [executionProcesses, setExecutionProcesses] = useState([]); + const [loading, setLoading] = useState(false); + const [followUpMessage, setFollowUpMessage] = useState(""); + const [showAttemptHistory, setShowAttemptHistory] = useState(false); + + // Check if the selected attempt is active (not in a final state) + const isAttemptRunning = + selectedAttempt && + attemptActivities.length > 0 && + (attemptActivities[0].status === "init" || + attemptActivities[0].status === "setuprunning" || + attemptActivities[0].status === "setupcomplete" || + attemptActivities[0].status === "executorrunning"); + + // Polling for updates when attempt is running + useEffect(() => { + if (!isAttemptRunning || !task) return; + + const interval = setInterval(() => { + if (selectedAttempt) { + fetchAttemptActivities(selectedAttempt.id, true); + fetchExecutionProcesses(selectedAttempt.id, true); + } + }, 2000); + + return () => clearInterval(interval); + }, [isAttemptRunning, task?.id, selectedAttempt?.id]); + + useEffect(() => { + if (task && isOpen) { + fetchTaskAttempts(); + } + }, [task, isOpen]); + + const fetchTaskAttempts = async () => { + if (!task) return; + + try { + setLoading(true); + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setTaskAttempts(result.data); + + // Auto-select latest attempt + 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); + fetchExecutionProcesses(latestAttempt.id); + } + } + } + } catch (err) { + console.error("Failed to fetch task attempts:", err); + } finally { + setLoading(false); + } + }; + + const fetchAttemptActivities = async (attemptId: string, _isBackgroundUpdate = false) => { + if (!task) return; + + try { + const response = await makeRequest( + `/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); + } + } + } catch (err) { + console.error("Failed to fetch attempt activities:", err); + } + }; + + const fetchExecutionProcesses = async (attemptId: string, _isBackgroundUpdate = false) => { + if (!task) return; + + try { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes` + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + setExecutionProcesses(result.data); + } + } + } catch (err) { + console.error("Failed to fetch execution processes:", err); + } + }; + + const handleAttemptChange = (attemptId: string) => { + const attempt = taskAttempts.find(a => a.id === attemptId); + if (attempt) { + setSelectedAttempt(attempt); + fetchAttemptActivities(attempt.id); + fetchExecutionProcesses(attempt.id); + setShowAttemptHistory(false); + } + }; + + const handleSendFollowUp = () => { + // TODO: Implement follow-up message API + console.log("Follow-up message:", followUpMessage); + setFollowUpMessage(""); + }; + + const stopExecutionProcess = async (processId: string) => { + if (!task || !selectedAttempt) return; + + try { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/execution-processes/${processId}/stop`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (response.ok) { + // Refresh the execution processes + fetchExecutionProcesses(selectedAttempt.id); + fetchAttemptActivities(selectedAttempt.id); + } + } catch (err) { + console.error("Failed to stop execution process:", err); + } + }; + + const openInEditor = async () => { + if (!task || !selectedAttempt) return; + + try { + await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/open-editor`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + } catch (err) { + console.error("Failed to open editor:", err); + } + }; + + if (!task) return null; + + return ( + <> + {isOpen && ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+
+ {/* Header */} +
+
+
+

+ {task.title} +

+
+ + {statusLabels[task.status]} + +
+
+ +
+ + {/* Attempt Selection */} +
+ {selectedAttempt && !showAttemptHistory ? ( +
+ Current attempt: + + {new Date(selectedAttempt.created_at).toLocaleDateString()} {new Date(selectedAttempt.created_at).toLocaleTimeString()} + + {taskAttempts.length > 1 && ( + + )} +
+ ) : ( +
+ + +
+ )} + + {selectedAttempt && ( +
+ + +
+ )} +
+
+ + {/* Content */} +
+ {loading ? ( +
+
+

Loading...

+
+ ) : ( + <> + {/* Description */} +
+ +
+ {task.description ? ( +

{task.description}

+ ) : ( +

+ No description provided +

+ )} +
+
+ + {/* Execution Processes */} + {selectedAttempt && executionProcesses.length > 0 && ( +
+ +
+ {executionProcesses.map((process) => ( + + +
+
+ + {getProcessStatusDisplay(process.status).label} + + + {getProcessTypeDisplay(process.process_type)} + +
+
+ + {new Date(process.started_at).toLocaleTimeString()} + + {process.status === "running" && ( + + )} +
+
+ + {(process.stdout || process.stderr) && ( +
+ {process.stdout && ( +
+ +
+ {process.stdout} +
+
+ )} + {process.stderr && ( +
+ +
+ {process.stderr} +
+
+ )} +
+ )} +
+
+ ))} +
+
+ )} + + {/* Activity History */} + {selectedAttempt && ( +
+ + {attemptActivities.length === 0 ? ( +
+ No activities found +
+ ) : ( +
+ {attemptActivities.map((activity) => ( + + +
+ + {getAttemptStatusDisplay(activity.status).label} + +
+ + {new Date(activity.created_at).toLocaleString()} +
+
+ {activity.note && ( +

+ {activity.note} +

+ )} +
+
+ ))} +
+ )} +
+ )} + + )} +
+ + {/* Footer */} +
+
+ +
+