From 3fae97deac8451f36bcf2a9e013811094776b39a Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Wed, 25 Jun 2025 12:06:29 +0100 Subject: [PATCH] Task attempt 9c523f08-4352-4824-b5a8-00d2b8843443 - Final changes --- .../components/tasks/TaskActivityHistory.tsx | 203 +++ .../components/tasks/TaskDetailsHeader.tsx | 167 +++ .../src/components/tasks/TaskDetailsPanel.tsx | 1322 ++--------------- .../components/tasks/TaskDetailsToolbar.tsx | 350 +++++ .../components/tasks/TaskFollowUpSection.tsx | 96 ++ frontend/src/hooks/useTaskDetails.ts | 505 +++++++ 6 files changed, 1408 insertions(+), 1235 deletions(-) create mode 100644 frontend/src/components/tasks/TaskActivityHistory.tsx create mode 100644 frontend/src/components/tasks/TaskDetailsHeader.tsx create mode 100644 frontend/src/components/tasks/TaskDetailsToolbar.tsx create mode 100644 frontend/src/components/tasks/TaskFollowUpSection.tsx create mode 100644 frontend/src/hooks/useTaskDetails.ts diff --git a/frontend/src/components/tasks/TaskActivityHistory.tsx b/frontend/src/components/tasks/TaskActivityHistory.tsx new file mode 100644 index 00000000..06fda2e1 --- /dev/null +++ b/frontend/src/components/tasks/TaskActivityHistory.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { Clock, ChevronDown, ChevronUp, Code } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Chip } from '@/components/ui/chip'; +import { ExecutionOutputViewer } from './ExecutionOutputViewer'; +import type { + TaskAttempt, + TaskAttemptActivityWithPrompt, + TaskAttemptStatus, + ExecutionProcess, +} from 'shared/types'; + +interface TaskActivityHistoryProps { + selectedAttempt: TaskAttempt | null; + activities: TaskAttemptActivityWithPrompt[]; + runningProcessDetails: Record; +} + +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 TaskActivityHistory({ + selectedAttempt, + activities, + runningProcessDetails, +}: TaskActivityHistoryProps) { + const [expandedOutputs, setExpandedOutputs] = useState>( + new Set() + ); + + const toggleOutputExpansion = (processId: string) => { + setExpandedOutputs((prev) => { + const newSet = new Set(prev); + if (newSet.has(processId)) { + newSet.delete(processId); + } else { + newSet.add(processId); + } + return newSet; + }); + }; + + if (!selectedAttempt) { + return null; + } + + return ( +
+ + {activities.length === 0 ? ( +
+ No activities found +
+ ) : ( +
+ {/* Fake worktree created activity */} +
+
+ New Worktree + + {selectedAttempt.worktree_path} + +
+ + {new Date(selectedAttempt.created_at).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} +
+
+
+ {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 prompt for coding agent executions */} + {activity.prompt && activity.status === 'executorrunning' && ( +
+
+
+ + + Prompt + +
+
+                      {activity.prompt}
+                    
+
+
+ )} + + {/* Show stdio output for running processes */} + {(activity.status === 'setuprunning' || + activity.status === 'executorrunning') && + runningProcessDetails[activity.execution_process_id] && ( +
+
+ +
+ +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx new file mode 100644 index 00000000..c0af96e5 --- /dev/null +++ b/frontend/src/components/tasks/TaskDetailsHeader.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { Edit, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Chip } from '@/components/ui/chip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; + +interface TaskDetailsHeaderProps { + task: TaskWithAttemptStatus; + 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'; + } +}; + +export function TaskDetailsHeader({ + task, + onClose, + onEditTask, + onDeleteTask, +}: TaskDetailsHeaderProps) { + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + + return ( +
+ {/* 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 +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 03d13269..a796f164 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -1,59 +1,16 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { Link } from 'react-router-dom'; -import { - X, - History, - Clock, - ChevronDown, - ChevronUp, - Settings2, - Edit, - Trash2, - StopCircle, - Send, - AlertCircle, - Play, - GitCompare, - ExternalLink, - Code, -} 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 { FileSearchTextarea } from '@/components/ui/file-search-textarea'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { ExecutionOutputViewer } from './ExecutionOutputViewer'; +import { useEffect, useRef, useCallback, useState } from 'react'; +import { TaskDetailsHeader } from './TaskDetailsHeader'; +import { TaskDetailsToolbar } from './TaskDetailsToolbar'; +import { TaskActivityHistory } from './TaskActivityHistory'; +import { TaskFollowUpSection } from './TaskFollowUpSection'; import { EditorSelectionDialog } from './EditorSelectionDialog'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; - -import { makeRequest } from '@/lib/api'; +import { useTaskDetails } from '@/hooks/useTaskDetails'; import { getTaskPanelClasses, getBackdropClasses, } from '@/lib/responsive-config'; -import { useConfig } from '@/components/config-provider'; import type { - TaskStatus, - TaskAttempt, - TaskAttemptActivity, - TaskAttemptActivityWithPrompt, - TaskAttemptStatus, - ApiResponse, TaskWithAttemptStatus, - ExecutionProcess, - ExecutionProcessSummary, EditorType, Project, } from 'shared/types'; @@ -69,73 +26,6 @@ interface TaskDetailsPanelProps { 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, @@ -146,52 +36,44 @@ export function TaskDetailsPanel({ onDeleteTask, isDialogOpen = false, }: TaskDetailsPanelProps) { - const [taskAttempts, setTaskAttempts] = useState([]); - const [selectedAttempt, setSelectedAttempt] = useState( - null - ); - // Combined attempt data state - const [attemptData, setAttemptData] = useState<{ - activities: TaskAttemptActivityWithPrompt[]; - 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]); + // Use the custom hook for all task details logic + const { + taskAttempts, + selectedAttempt, + attemptData, + loading, + selectedExecutor, + isStopping, + followUpMessage, + isSendingFollowUp, + followUpError, + isStartingDevServer, + devServerDetails, + + runningDevServer, + isAttemptRunning, + canSendFollowUp, + processedDevServerLogs, + setSelectedExecutor, + setFollowUpMessage, + setFollowUpError, + setIsHoveringDevServer, + handleAttemptChange, + createNewAttempt, + stopAllExecutions, + startDevServer, + stopDevServer, + openInEditor, + handleSendFollowUp, + } = useTaskDetails(task, projectId, isOpen); // Handle ESC key locally to prevent global navigation useEffect(() => { - if (!isOpen || isDialogOpen) return; // Don't handle ESC if dialog is open + if (!isOpen || isDialogOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -201,147 +83,10 @@ export function TaskDetailsPanel({ } }; - document.addEventListener('keydown', handleKeyDown, true); // Use capture phase + document.addEventListener('keydown', handleKeyDown, true); 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) { @@ -355,7 +100,7 @@ export function TaskDetailsPanel({ if (scrollContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; if (isAtBottom && !shouldAutoScroll) { setShouldAutoScroll(true); @@ -365,328 +110,16 @@ export function TaskDetailsPanel({ } }, [shouldAutoScroll]); - const fetchTaskAttempts = async () => { - if (!task) return; - + const handleOpenInEditor = async (editorType?: EditorType) => { 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: {}, - }); - } - } - } + await openInEditor(editorType); } 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 ( @@ -700,420 +133,36 @@ export function TaskDetailsPanel({
{/* 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'} - -
-
- ))} -
-
- )} - {isAttemptRunning || isStopping ? ( - - - - - - -

- {isStopping - ? 'Stopping execution...' - : 'Stop execution'} -

-
-
-
- ) : ( -
- - - - - - -

- {selectedAttempt - ? 'Create new attempt with current executor' - : 'Start new attempt 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 && ( - <> -
- - {/* Dev Server Control Group */} -
- - - - - 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

-
-
-
-
- - )} -
-
-
-
+ {/* Toolbar */} + {/* Content */}
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 prompt for coding agent executions */} - {activity.prompt && - activity.status === 'executorrunning' && ( -
-
-
- - - Prompt - -
-
-                                          {activity.prompt}
-                                        
-
-
- )} - - {/* 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} - - )} -
- { - setFollowUpMessage(value); - if (followUpError) setFollowUpError(null); - }} - onKeyDown={(e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - if ( - canSendFollowUp && - followUpMessage.trim() && - !isSendingFollowUp - ) { - handleSendFollowUp(); - } - } - }} - className="w-full min-h-[80px] resize-none" - disabled={!canSendFollowUp} - projectId={projectId} - rows={4} - /> -
- -
-
-

- {!canSendFollowUp - ? isAttemptRunning - ? 'Wait for current execution to complete before asking follow-up questions' - : 'Complete at least one coding agent execution to enable follow-up questions' - : 'Continue the conversation with the most recent executor session'} -

-
-
+ )}
@@ -1353,7 +205,7 @@ export function TaskDetailsPanel({ setShowEditorDialog(false)} - onSelectEditor={(editorType) => openInEditor(editorType)} + onSelectEditor={handleOpenInEditor} /> )} diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx new file mode 100644 index 00000000..a725daca --- /dev/null +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -0,0 +1,350 @@ +import { Link } from 'react-router-dom'; +import { + History, + Settings2, + StopCircle, + Play, + GitCompare, + ExternalLink, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useConfig } from '@/components/config-provider'; +import type { + TaskAttempt, + TaskWithAttemptStatus, + ExecutionProcessSummary, + ExecutionProcess, + Project, +} from 'shared/types'; + +interface TaskDetailsToolbarProps { + task: TaskWithAttemptStatus; + project: Project | null; + projectId: string; + selectedAttempt: TaskAttempt | null; + taskAttempts: TaskAttempt[]; + isAttemptRunning: boolean; + isStopping: boolean; + selectedExecutor: string; + runningDevServer: ExecutionProcessSummary | undefined; + isStartingDevServer: boolean; + devServerDetails: ExecutionProcess | null; + processedDevServerLogs: string; + onAttemptChange: (attemptId: string) => void; + onCreateNewAttempt: (executor?: string) => void; + onStopAllExecutions: () => void; + onSetSelectedExecutor: (executor: string) => void; + onStartDevServer: () => void; + onStopDevServer: () => void; + onOpenInEditor: () => void; + onSetIsHoveringDevServer: (hovering: boolean) => void; +} + +const availableExecutors = [ + { id: 'echo', name: 'Echo' }, + { id: 'claude', name: 'Claude' }, + { id: 'amp', name: 'Amp' }, +]; + +export function TaskDetailsToolbar({ + task, + project, + projectId, + selectedAttempt, + taskAttempts, + isAttemptRunning, + isStopping, + selectedExecutor, + runningDevServer, + isStartingDevServer, + devServerDetails, + processedDevServerLogs, + onAttemptChange, + onCreateNewAttempt, + onStopAllExecutions, + onSetSelectedExecutor, + onStartDevServer, + onStopDevServer, + onOpenInEditor, + onSetIsHoveringDevServer, +}: TaskDetailsToolbarProps) { + const { config } = useConfig(); + + return ( +
+
+ {/* 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) => ( + onAttemptChange(attempt.id)} + className={ + selectedAttempt?.id === attempt.id ? 'bg-accent' : '' + } + > +
+ + {new Date(attempt.created_at).toLocaleDateString()}{' '} + {new Date(attempt.created_at).toLocaleTimeString()} + + + {attempt.executor || 'executor'} + +
+
+ ))} +
+
+ )} + {isAttemptRunning || isStopping ? ( + + + + + + +

+ {isStopping + ? 'Stopping execution...' + : 'Stop execution'} +

+
+
+
+ ) : ( +
+ + + + + + +

+ {selectedAttempt + ? 'Create new attempt with current executor' + : 'Start new attempt with current executor'} +

+
+
+
+ + + + + + + + + +

Choose executor

+
+
+
+ + {availableExecutors.map((executor) => ( + onSetSelectedExecutor(executor.id)} + className={ + selectedExecutor === executor.id ? 'bg-accent' : '' + } + > + {executor.name} + {config?.executor.type === executor.id && + ' (Default)'} + + ))} + +
+
+ )} +
+ + {selectedAttempt && ( + <> +
+ + {/* Dev Server Control Group */} +
+ + + + onSetIsHoveringDevServer(true)} + onMouseLeave={() => onSetIsHoveringDevServer(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

+
+
+
+
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx new file mode 100644 index 00000000..2325ae1d --- /dev/null +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -0,0 +1,96 @@ +import { 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 { FileSearchTextarea } from '@/components/ui/file-search-textarea'; + +interface TaskFollowUpSectionProps { + followUpMessage: string; + setFollowUpMessage: (message: string) => void; + isSendingFollowUp: boolean; + followUpError: string | null; + setFollowUpError: (error: string | null) => void; + canSendFollowUp: boolean; + isAttemptRunning: boolean; + projectId: string; + onSendFollowUp: () => void; +} + +export function TaskFollowUpSection({ + followUpMessage, + setFollowUpMessage, + isSendingFollowUp, + followUpError, + setFollowUpError, + canSendFollowUp, + isAttemptRunning, + projectId, + onSendFollowUp, +}: TaskFollowUpSectionProps) { + return ( +
+
+ + {followUpError && ( + + + {followUpError} + + )} +
+ { + setFollowUpMessage(value); + if (followUpError) setFollowUpError(null); + }} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if ( + canSendFollowUp && + followUpMessage.trim() && + !isSendingFollowUp + ) { + onSendFollowUp(); + } + } + }} + className="w-full min-h-[80px] resize-none" + disabled={!canSendFollowUp} + projectId={projectId} + rows={4} + /> +
+ +
+
+

+ {!canSendFollowUp + ? isAttemptRunning + ? 'Wait for current execution to complete before asking follow-up questions' + : 'Complete at least one coding agent execution to enable follow-up questions' + : 'Continue the conversation with the most recent executor session'} +

+
+
+ ); +} diff --git a/frontend/src/hooks/useTaskDetails.ts b/frontend/src/hooks/useTaskDetails.ts new file mode 100644 index 00000000..9e486d6e --- /dev/null +++ b/frontend/src/hooks/useTaskDetails.ts @@ -0,0 +1,505 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { makeRequest } from '@/lib/api'; +import { useConfig } from '@/components/config-provider'; +import type { + TaskAttempt, + TaskAttemptActivityWithPrompt, + ApiResponse, + TaskWithAttemptStatus, + ExecutionProcess, + ExecutionProcessSummary, + EditorType, +} from 'shared/types'; + +export function useTaskDetails( + task: TaskWithAttemptStatus | null, + projectId: string, + isOpen: boolean +) { + const [taskAttempts, setTaskAttempts] = useState([]); + const [selectedAttempt, setSelectedAttempt] = useState( + null + ); + const [attemptData, setAttemptData] = useState<{ + activities: TaskAttemptActivityWithPrompt[]; + processes: ExecutionProcessSummary[]; + runningProcessDetails: Record; + }>({ + activities: [], + processes: [], + runningProcessDetails: {}, + }); + const [loading, setLoading] = useState(false); + const [selectedExecutor, setSelectedExecutor] = useState('claude'); + const [isStopping, setIsStopping] = 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); + + const { config } = useConfig(); + + // Find running dev server in current project + const runningDevServer = useMemo(() => { + return attemptData.processes.find( + (process) => + process.process_type === 'devserver' && process.status === 'running' + ); + }, [attemptData.processes]); + + // Check if any execution process is currently running + const isAttemptRunning = useMemo(() => { + if (!selectedAttempt || attemptData.activities.length === 0 || isStopping) { + return false; + } + + 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); + } + }); + + 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; + } + + const codingAgentActivities = attemptData.activities.filter( + (activity) => activity.status === 'executorcomplete' + ); + + return codingAgentActivities.length > 0; + }, [ + selectedAttempt, + attemptData.activities, + isAttemptRunning, + isSendingFollowUp, + ]); + + // Memoize processed dev server logs + 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]); + + // 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 = useCallback(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); + } + }, [runningDevServer?.id, task?.id, selectedAttempt?.id, projectId]); + + // Poll dev server details while hovering + useEffect(() => { + if (!isHoveringDevServer || !runningDevServer) { + setDevServerDetails(null); + return; + } + + fetchDevServerDetails(); + const interval = setInterval(fetchDevServerDetails, 2000); + return () => clearInterval(interval); + }, [ + isHoveringDevServer, + runningDevServer?.id, + fetchDevServerDetails, + ]); + + 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); + + 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 { + 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 + ) { + const runningActivities = activitiesResult.data.filter( + (activity) => + activity.status === 'setuprunning' || + activity.status === 'executorrunning' + ); + + 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 + ); + } + } + + 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 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) { + 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) { + await fetchAttemptData(selectedAttempt.id); + setTimeout(() => { + fetchAttemptData(selectedAttempt.id); + }, 1000); + } + } catch (err) { + console.error('Failed to stop executions:', err); + } finally { + setIsStopping(false); + } + }; + + const startDevServer = async () => { + if (!task || !selectedAttempt) 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'); + } + + 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'); + } + + fetchAttemptData(selectedAttempt.id); + } catch (err) { + console.error('Failed to stop dev server:', err); + } finally { + setIsStartingDevServer(false); + } + }; + + 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); + throw err; + } + }; + + 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) { + setFollowUpMessage(''); + 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); + } + }; + + return { + // State + taskAttempts, + selectedAttempt, + attemptData, + loading, + selectedExecutor, + isStopping, + followUpMessage, + isSendingFollowUp, + followUpError, + isStartingDevServer, + devServerDetails, + isHoveringDevServer, + + // Computed + runningDevServer, + isAttemptRunning, + canSendFollowUp, + processedDevServerLogs, + + // Actions + setSelectedExecutor, + setFollowUpMessage, + setFollowUpError, + setIsHoveringDevServer, + handleAttemptChange, + createNewAttempt, + stopAllExecutions, + startDevServer, + stopDevServer, + openInEditor, + handleSendFollowUp, + }; +}