import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import { Link } from "react-router-dom"; import { X, History, Clock, Code, ChevronDown, ChevronUp, Settings2, Edit, Trash2, StopCircle, Send, AlertCircle, Play, GitCompare, } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; 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, ExecutionProcessSummary, EditorType, Project, } from "shared/types"; interface TaskDetailsPanelProps { task: TaskWithAttemptStatus | null; project: Project | null; projectId: string; isOpen: boolean; onClose: () => void; onEditTask?: (task: TaskWithAttemptStatus) => void; onDeleteTask?: (taskId: string) => void; isDialogOpen?: boolean; // New prop to indicate if any dialog is open } 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, project, projectId, isOpen, onClose, onEditTask, onDeleteTask, isDialogOpen = false, }: TaskDetailsPanelProps) { const [taskAttempts, setTaskAttempts] = useState([]); const [selectedAttempt, setSelectedAttempt] = useState( null ); // Combined attempt data state const [attemptData, setAttemptData] = useState<{ activities: TaskAttemptActivity[]; processes: ExecutionProcessSummary[]; runningProcessDetails: Record; }>({ activities: [], processes: [], runningProcessDetails: {}, }); 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); const [isStartingDevServer, setIsStartingDevServer] = useState(false); const [devServerDetails, setDevServerDetails] = useState(null); const [isHoveringDevServer, setIsHoveringDevServer] = useState(false); // Auto-scroll state const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const scrollContainerRef = useRef(null); const { config } = useConfig(); // Find running dev server in current project (across all task attempts) const runningDevServer = useMemo(() => { return attemptData.processes.find( (process) => process.process_type === "devserver" && process.status === "running" ); }, [attemptData.processes]); // Handle ESC key locally to prevent global navigation useEffect(() => { if (!isOpen || isDialogOpen) return; // Don't handle ESC if dialog is open const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); } }; document.addEventListener("keydown", handleKeyDown, true); // Use capture phase return () => document.removeEventListener("keydown", handleKeyDown, true); }, [isOpen, onClose, isDialogOpen]); // Available executors const availableExecutors = [ { id: "echo", name: "Echo" }, { id: "claude", name: "Claude" }, { id: "amp", name: "Amp" }, ]; // Check if any execution process is currently running const isAttemptRunning = useMemo(() => { if (!selectedAttempt || attemptData.activities.length === 0 || isStopping) { return false; } // Group activities by execution_process_id and get the latest one for each const latestActivitiesByProcess = new Map(); attemptData.activities.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, attemptData.activities, isStopping]); // Check if follow-up should be enabled const canSendFollowUp = useMemo(() => { if ( !selectedAttempt || attemptData.activities.length === 0 || isAttemptRunning || isSendingFollowUp ) { return false; } // Need at least one completed coding agent execution const codingAgentActivities = attemptData.activities.filter( (activity) => activity.status === "executorcomplete" ); return codingAgentActivities.length > 0; }, [ selectedAttempt, attemptData.activities, isAttemptRunning, isSendingFollowUp, ]); // Polling for updates when attempt is running useEffect(() => { if (!isAttemptRunning || !task) return; const interval = setInterval(() => { if (selectedAttempt) { fetchAttemptData(selectedAttempt.id, true); } }, 2000); return () => clearInterval(interval); }, [isAttemptRunning, task?.id, selectedAttempt?.id]); // Fetch dev server details when hovering const fetchDevServerDetails = async () => { if (!runningDevServer || !task || !selectedAttempt) return; try { const response = await makeRequest( `/api/projects/${projectId}/execution-processes/${runningDevServer.id}` ); if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { setDevServerDetails(result.data); } } } catch (err) { console.error("Failed to fetch dev server details:", err); } }; // Poll dev server details while hovering useEffect(() => { if (!isHoveringDevServer || !runningDevServer) { setDevServerDetails(null); return; } // Fetch immediately fetchDevServerDetails(); // Then poll every 2 seconds const interval = setInterval(fetchDevServerDetails, 2000); return () => clearInterval(interval); }, [ isHoveringDevServer, runningDevServer?.id, task?.id, selectedAttempt?.id, ]); // Memoize processed dev server logs to prevent stuttering const processedDevServerLogs = useMemo(() => { if (!devServerDetails) return "No output yet..."; const stdout = devServerDetails.stdout || ""; const stderr = devServerDetails.stderr || ""; const allOutput = stdout + (stderr ? "\n" + stderr : ""); const lines = allOutput.split("\n").filter((line) => line.trim()); const lastLines = lines.slice(-10); return lastLines.length > 0 ? lastLines.join("\n") : "No output yet..."; }, [devServerDetails?.stdout, devServerDetails?.stderr]); // 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 or execution processes change useEffect(() => { if (shouldAutoScroll && scrollContainerRef.current) { scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; } }, [attemptData.activities, attemptData.processes, 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); fetchAttemptData(latestAttempt.id); } else { // Clear state when no attempts exist setSelectedAttempt(null); setAttemptData({ activities: [], processes: [], runningProcessDetails: {}, }); } } } } catch (err) { console.error("Failed to fetch task attempts:", err); } finally { setLoading(false); } }; const fetchAttemptData = async ( attemptId: string, _isBackgroundUpdate = false ) => { if (!task) return; try { const [activitiesResponse, processesResponse] = await Promise.all([ makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities` ), makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes` ), ]); if (activitiesResponse.ok && processesResponse.ok) { const activitiesResult: ApiResponse = await activitiesResponse.json(); const processesResult: ApiResponse = await processesResponse.json(); if ( activitiesResult.success && processesResult.success && activitiesResult.data && processesResult.data ) { // Find running activities that need detailed execution info const runningActivities = activitiesResult.data.filter( (activity) => activity.status === "setuprunning" || activity.status === "executorrunning" ); // Fetch detailed execution info for running processes const runningProcessDetails: Record = {}; for (const activity of runningActivities) { try { const detailResponse = await makeRequest( `/api/projects/${projectId}/execution-processes/${activity.execution_process_id}` ); if (detailResponse.ok) { const detailResult: ApiResponse = await detailResponse.json(); if (detailResult.success && detailResult.data) { runningProcessDetails[activity.execution_process_id] = detailResult.data; } } } catch (err) { console.error( `Failed to fetch execution process ${activity.execution_process_id}:`, err ); } } // Update all attempt data at once setAttemptData({ activities: activitiesResult.data, processes: processesResult.data, runningProcessDetails, }); } } } catch (err) { console.error("Failed to fetch attempt data:", err); } }; const handleAttemptChange = (attemptId: string) => { const attempt = taskAttempts.find((a) => a.id === attemptId); if (attempt) { setSelectedAttempt(attempt); fetchAttemptData(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 startDevServer = async () => { if (!task || !selectedAttempt || !project?.dev_script) return; setIsStartingDevServer(true); try { const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/start-dev-server`, { method: "POST", headers: { "Content-Type": "application/json", }, } ); if (!response.ok) { throw new Error("Failed to start dev server"); } const data: ApiResponse = await response.json(); if (!data.success) { throw new Error(data.message || "Failed to start dev server"); } // Refresh activities to show the new dev server process fetchAttemptData(selectedAttempt.id); } catch (err) { console.error("Failed to start dev server:", err); } finally { setIsStartingDevServer(false); } }; const stopDevServer = async () => { if (!task || !selectedAttempt || !runningDevServer) return; setIsStartingDevServer(true); try { const response = await makeRequest( `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`, { method: "POST", headers: { "Content-Type": "application/json", }, } ); if (!response.ok) { throw new Error("Failed to stop dev server"); } // Refresh activities to show the stopped dev server fetchAttemptData(selectedAttempt.id); } catch (err) { console.error("Failed to stop dev server:", err); } finally { setIsStartingDevServer(false); } }; 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) { // Refresh activities to show updated status await fetchAttemptData(selectedAttempt.id); // Wait a bit for the backend to finish updating setTimeout(() => { fetchAttemptData(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 fetchAttemptData(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 */}
{/* Title and Task Actions */}

{task.title}

{statusLabels[task.status]}
{onEditTask && (

Edit task

)} {onDeleteTask && (

Delete task

)}

Close panel

{/* Description */}
{task.description ? (

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

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

No description provided

)}
{/* Integrated Toolbar */}
{/* Current Attempt Info */}
{selectedAttempt ? ( <>
{new Date(selectedAttempt.created_at).toLocaleDateString()}{" "} {new Date(selectedAttempt.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })} ({selectedAttempt.executor || "executor"})
) : (
No attempts yet
)}
{/* Action Button Groups */}
{/* Attempt Management Group */}
{taskAttempts.length > 1 && (

View attempt history

{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"}
))}
)}

{selectedAttempt ? "Retry task with current executor" : "Start task with current executor"}

Choose executor

{availableExecutors.map((executor) => ( setSelectedExecutor(executor.id)} className={ selectedExecutor === executor.id ? "bg-accent" : "" } > {executor.name} {config?.executor.type === executor.id && " (Default)"} ))}
{selectedAttempt && ( <>
{/* Execution Control Group */}
{(isAttemptRunning || isStopping) && (

{isStopping ? "Stopping execution..." : "Stop execution"}

)} setIsHoveringDevServer(true)} onMouseLeave={() => setIsHoveringDevServer(false)} > {!project?.dev_script ? (

Configure a dev server command in project settings

) : runningDevServer && devServerDetails ? (

Dev Server Logs (Last 10 lines):

                                      {processedDevServerLogs}
                                      
) : runningDevServer ? (

Stop the running dev server

) : (

Start the dev server

)}
{/* Code Actions Group */}

Open in editor

View code changes

)}
{/* Content */}
{loading ? (

Loading...

) : ( <> {/* Activity History */} {selectedAttempt && (
{attemptData.activities.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", })}
)} {attemptData.activities.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") && attemptData.runningProcessDetails[ activity.execution_process_id ] && (
)}
))}
)}
)} )}
{/* Footer - Follow-up section */} {selectedAttempt && (
{followUpError && ( {followUpError} )}