import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import { Link } from "react-router-dom"; import { X, History, Clock, FileText, Code, ChevronDown, ChevronUp, Settings2, Edit, Trash2, StopCircle, Send, AlertCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Label } from "@/components/ui/label"; import { Chip } from "@/components/ui/chip"; import { Textarea } from "@/components/ui/textarea"; import { ExecutionOutputViewer } from "./ExecutionOutputViewer"; import { EditorSelectionDialog } from "./EditorSelectionDialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { makeRequest } from "@/lib/api"; import { getTaskPanelClasses, getBackdropClasses, } from "@/lib/responsive-config"; import { useConfig } from "@/components/config-provider"; import type { TaskStatus, TaskAttempt, TaskAttemptActivity, TaskAttemptStatus, ApiResponse, TaskWithAttemptStatus, ExecutionProcess, EditorType, } from "shared/types"; interface TaskDetailsPanelProps { task: TaskWithAttemptStatus | null; projectId: string; isOpen: boolean; onClose: () => void; onEditTask?: (task: TaskWithAttemptStatus) => void; onDeleteTask?: (taskId: string) => void; } const statusLabels: Record = { todo: "To Do", inprogress: "In Progress", inreview: "In Review", done: "Done", cancelled: "Cancelled", }; const getTaskStatusDotColor = (status: TaskStatus): string => { switch (status) { case "todo": return "bg-gray-400"; case "inprogress": return "bg-blue-500"; case "inreview": return "bg-yellow-500"; case "done": return "bg-green-500"; case "cancelled": return "bg-red-500"; default: return "bg-gray-400"; } }; const getAttemptStatusDisplay = ( status: TaskAttemptStatus ): { label: string; dotColor: string } => { switch (status) { case "setuprunning": return { label: "Setup Running", dotColor: "bg-blue-500", }; case "setupcomplete": return { label: "Setup Complete", dotColor: "bg-green-500", }; case "setupfailed": return { label: "Setup Failed", dotColor: "bg-red-500", }; case "executorrunning": return { label: "Executor Running", dotColor: "bg-blue-500", }; case "executorcomplete": return { label: "Executor Complete", dotColor: "bg-green-500", }; case "executorfailed": return { label: "Executor Failed", dotColor: "bg-red-500", }; default: return { label: "Unknown", dotColor: "bg-gray-400", }; } }; export function TaskDetailsPanel({ task, projectId, isOpen, onClose, onEditTask, onDeleteTask, }: TaskDetailsPanelProps) { const [taskAttempts, setTaskAttempts] = useState([]); const [selectedAttempt, setSelectedAttempt] = useState( null ); const [attemptActivities, setAttemptActivities] = useState< TaskAttemptActivity[] >([]); const [executionProcesses, setExecutionProcesses] = useState< Record >({}); const [loading, setLoading] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [selectedExecutor, setSelectedExecutor] = useState("claude"); const [isStopping, setIsStopping] = useState(false); const [expandedOutputs, setExpandedOutputs] = useState>( new Set() ); const [showEditorDialog, setShowEditorDialog] = useState(false); const [followUpMessage, setFollowUpMessage] = useState(""); const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [followUpError, setFollowUpError] = useState(null); // Auto-scroll state const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const scrollContainerRef = useRef(null); const { config } = useConfig(); // Available executors const availableExecutors = [ { id: "echo", name: "Echo" }, { id: "claude", name: "Claude" }, { id: "amp", name: "Amp" }, ]; // Check if any execution process is currently running // We need to check the latest activity for each execution process const isAttemptRunning = useMemo(() => { if (!selectedAttempt || attemptActivities.length === 0 || isStopping) { return false; } // Group activities by execution_process_id and get the latest one for each const latestActivitiesByProcess = new Map(); attemptActivities.forEach((activity) => { const existing = latestActivitiesByProcess.get( activity.execution_process_id ); if ( !existing || new Date(activity.created_at) > new Date(existing.created_at) ) { latestActivitiesByProcess.set(activity.execution_process_id, activity); } }); // Check if any execution process has a running status as its latest activity return Array.from(latestActivitiesByProcess.values()).some( (activity) => activity.status === "setuprunning" || activity.status === "executorrunning" ); }, [selectedAttempt, attemptActivities, isStopping]); // Check if follow-up should be enabled const canSendFollowUp = useMemo(() => { if (!selectedAttempt || attemptActivities.length === 0 || isAttemptRunning || isSendingFollowUp) { return false; } // Need at least one completed coding agent execution const codingAgentActivities = attemptActivities.filter( (activity) => activity.status === "executorcomplete" ); return codingAgentActivities.length > 0; }, [selectedAttempt, attemptActivities, isAttemptRunning, isSendingFollowUp]); // Polling for updates when attempt is running useEffect(() => { if (!isAttemptRunning || !task) return; const interval = setInterval(() => { if (selectedAttempt) { fetchAttemptActivities(selectedAttempt.id, true); } }, 2000); return () => clearInterval(interval); }, [isAttemptRunning, task?.id, selectedAttempt?.id]); // Set default executor from config useEffect(() => { if (config) { setSelectedExecutor(config.executor.type); } }, [config]); useEffect(() => { if (task && isOpen) { fetchTaskAttempts(); } }, [task, isOpen]); // Auto-scroll to bottom when activities change useEffect(() => { if (shouldAutoScroll && scrollContainerRef.current) { scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; } }, [attemptActivities, shouldAutoScroll]); // Handle scroll events to detect manual scrolling const handleScroll = useCallback(() => { if (scrollContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance if (isAtBottom && !shouldAutoScroll) { setShouldAutoScroll(true); } else if (!isAtBottom && shouldAutoScroll) { setShouldAutoScroll(false); } } }, [shouldAutoScroll]); 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); } else { // Clear state when no attempts exist setSelectedAttempt(null); setAttemptActivities([]); setExecutionProcesses({}); } } } } 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); // Fetch execution processes for running activities const runningActivities = result.data.filter( (activity) => activity.status === "setuprunning" || activity.status === "executorrunning" ); for (const activity of runningActivities) { fetchExecutionProcess(activity.execution_process_id); } } } } catch (err) { console.error("Failed to fetch attempt activities:", err); } }; const fetchExecutionProcess = async (processId: string) => { if (!task) return; try { const response = await makeRequest( `/api/projects/${projectId}/execution-processes/${processId}` ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { setExecutionProcesses((prev) => ({ ...prev, [processId]: result.data!, })); } } } catch (err) { console.error("Failed to fetch execution process:", err); } }; const handleAttemptChange = (attemptId: string) => { const attempt = taskAttempts.find((a) => a.id === attemptId); if (attempt) { setSelectedAttempt(attempt); fetchAttemptActivities(attempt.id); } }; const openInEditor = async (editorType?: EditorType) => { if (!task || !selectedAttempt) return; try { const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/open-editor`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(editorType ? { editor_type: editorType } : null), } ); if (!response.ok) { throw new Error("Failed to open editor"); } } catch (err) { console.error("Failed to open editor:", err); // Show editor selection dialog if editor failed to open if (!editorType) { setShowEditorDialog(true); } } }; const createNewAttempt = async (executor?: string) => { if (!task) return; try { const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ executor: executor || selectedExecutor, }), } ); if (response.ok) { // Refresh the attempts list fetchTaskAttempts(); } } catch (err) { console.error("Failed to create new attempt:", err); } }; const stopAllExecutions = async () => { if (!task || !selectedAttempt) return; try { setIsStopping(true); const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`, { method: "POST", headers: { "Content-Type": "application/json", }, } ); if (response.ok) { // Clear cached execution processes since they should be stopped setExecutionProcesses({}); // Refresh activities to show updated status await fetchAttemptActivities(selectedAttempt.id); // Wait a bit for the backend to finish updating setTimeout(() => { fetchAttemptActivities(selectedAttempt.id); }, 1000); } } catch (err) { console.error("Failed to stop executions:", err); } finally { setIsStopping(false); } }; const toggleOutputExpansion = (processId: string) => { setExpandedOutputs((prev) => { const newSet = new Set(prev); if (newSet.has(processId)) { newSet.delete(processId); } else { newSet.add(processId); } return newSet; }); }; const handleSendFollowUp = async () => { if (!task || !selectedAttempt || !followUpMessage.trim()) return; try { setIsSendingFollowUp(true); setFollowUpError(null); const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/follow-up`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt: followUpMessage.trim(), }), } ); if (response.ok) { // Clear the message setFollowUpMessage(""); // Refresh activities to show the new follow-up execution fetchAttemptActivities(selectedAttempt.id); } else { const errorText = await response.text(); setFollowUpError(`Failed to start follow-up execution: ${errorText || response.statusText}`); } } catch (err) { setFollowUpError(`Failed to send follow-up: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsSendingFollowUp(false); } }; if (!task) return null; return ( <> {isOpen && ( <> {/* Backdrop - only on smaller screens (overlay mode) */}
{/* Panel */}
{/* Header */}

{task.title}

{statusLabels[task.status]}
{onEditTask && ( )} {onDeleteTask && ( )}
{/* Description */}
{task.description ? (

200 ? "line-clamp-3" : "" }`} > {task.description}

{task.description.length > 200 && ( )}
) : (

No description provided

)}
{/* Attempt Selection */}
{selectedAttempt && (
Current attempt:{" "} {new Date( selectedAttempt.created_at ).toLocaleDateString()}{" "} {new Date( selectedAttempt.created_at ).toLocaleTimeString()} Worktree: {selectedAttempt.worktree_path}
)}
{taskAttempts.length > 1 && ( {taskAttempts.map((attempt) => ( handleAttemptChange(attempt.id)} className={ selectedAttempt?.id === attempt.id ? "bg-accent" : "" } >
{new Date( attempt.created_at ).toLocaleDateString()}{" "} {new Date( attempt.created_at ).toLocaleTimeString()} {attempt.executor || "executor"}
))}
)}
{availableExecutors.map((executor) => ( setSelectedExecutor(executor.id)} className={ selectedExecutor === executor.id ? "bg-accent" : "" } > {executor.name} {selectedExecutor === executor.id && " (Default)"} ))}
{selectedAttempt && (
{(isAttemptRunning || isStopping) && ( )}
)}
{/* Content */}
{loading ? (

Loading...

) : ( <> {/* Activity History */} {selectedAttempt && (
{attemptActivities.length === 0 ? (
No activities found
) : (
{/* Fake worktree created activity */} {selectedAttempt && (
New Worktree {selectedAttempt.worktree_path}
{new Date( selectedAttempt.created_at ).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", })}
)} {attemptActivities.slice().map((activity) => (
{/* Compact activity message */}
{ getAttemptStatusDisplay(activity.status) .label } {activity.note && ( {activity.note} )}
{new Date( activity.created_at ).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", })}
{/* Show stdio output for running processes */} {(activity.status === "setuprunning" || activity.status === "executorrunning") && executionProcesses[ activity.execution_process_id ] && (
)}
))}
)}
)} )}
{/* Footer - Follow-up section */} {selectedAttempt && (
{followUpError && ( {followUpError} )}