Refactor TaskDetailsPanel (#126)

* improve performance

* split task details panel into components

* remove useTaskDetails hook

* create task details context

* move context provider
This commit is contained in:
Anastasiia Solop
2025-07-11 11:31:28 +02:00
committed by GitHub
parent c6a247a728
commit aae0984271
20 changed files with 1785 additions and 1629 deletions

View File

@@ -0,0 +1,425 @@
import {
Dispatch,
FC,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
ApiResponse,
AttemptData,
EditorType,
ExecutionProcess,
ExecutionProcessSummary,
TaskAttempt,
TaskAttemptActivityWithPrompt,
TaskAttemptState,
TaskWithAttemptStatus,
WorktreeDiff,
} from 'shared/types.ts';
import { makeRequest } from '@/lib/api.ts';
import { TaskDetailsContext } from './taskDetailsContext.ts';
const TaskDetailsProvider: FC<{
task: TaskWithAttemptStatus;
projectId: string;
children: ReactNode;
activeTab: 'logs' | 'diffs';
setActiveTab: Dispatch<SetStateAction<'logs' | 'diffs'>>;
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
isOpen: boolean;
userSelectedTab: boolean;
}> = ({
task,
projectId,
children,
activeTab,
setActiveTab,
setShowEditorDialog,
isOpen,
userSelectedTab,
}) => {
const [loading, setLoading] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(
null
);
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
// Diff-related state
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
const [diffLoading, setDiffLoading] = useState(true);
const [diffError, setDiffError] = useState<string | null>(null);
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
const [executionState, setExecutionState] = useState<TaskAttemptState | null>(
null
);
const [attemptData, setAttemptData] = useState<AttemptData>({
activities: [],
processes: [],
runningProcessDetails: {},
});
const diffLoadingRef = useRef(false);
const fetchDiff = useCallback(
async (isBackgroundRefresh = false) => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) {
setDiff(null);
setDiffLoading(false);
return;
}
// Prevent multiple concurrent requests
if (diffLoadingRef.current) {
return;
}
try {
diffLoadingRef.current = true;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(true);
} else {
setDiffLoading(true);
}
setDiffError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/diff`
);
if (response.ok) {
const result: ApiResponse<WorktreeDiff> = await response.json();
if (result.success && result.data) {
setDiff(result.data);
} else {
setDiffError('Failed to load diff');
}
} else {
setDiffError('Failed to load diff');
}
} catch (err) {
setDiffError('Failed to load diff');
} finally {
diffLoadingRef.current = false;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(false);
} else {
setDiffLoading(false);
}
}
},
[projectId, selectedAttempt?.id, selectedAttempt?.task_id]
);
useEffect(() => {
if (isOpen) {
fetchDiff();
}
}, [isOpen, fetchDiff]);
const fetchExecutionState = useCallback(
async (attemptId: string, taskId: string) => {
if (!task) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}`
);
if (response.ok) {
const result: ApiResponse<TaskAttemptState> = await response.json();
if (result.success && result.data) {
setExecutionState(result.data);
}
}
} catch (err) {
console.error('Failed to fetch execution state:', err);
}
},
[task, projectId]
);
const handleOpenInEditor = useCallback(
async (editorType?: EditorType) => {
if (!task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/open-editor`,
{
method: 'POST',
body: JSON.stringify(
editorType ? { editor_type: editorType } : null
),
}
);
if (!response.ok) {
if (!editorType) {
setShowEditorDialog(true);
}
}
} catch (err) {
console.error('Failed to open editor:', err);
if (!editorType) {
setShowEditorDialog(true);
}
}
},
[task, projectId, selectedAttempt, setShowEditorDialog]
);
const fetchAttemptData = useCallback(
async (attemptId: string, taskId: string) => {
if (!task) return;
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/execution-processes`
),
]);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
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<string, ExecutionProcess> = {};
// Fetch details for running activities
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<ExecutionProcess> =
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
);
}
}
// Also fetch setup script process details if it exists in the processes
const setupProcess = processesResult.data.find(
(process) => process.process_type === 'setupscript'
);
if (setupProcess && !runningProcessDetails[setupProcess.id]) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${setupProcess.id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[setupProcess.id] = detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch setup process details ${setupProcess.id}:`,
err
);
}
}
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
}
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
}
},
[task, projectId]
);
useEffect(() => {
if (selectedAttempt && task) {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
fetchExecutionState(selectedAttempt.id, selectedAttempt.task_id);
}
}, [selectedAttempt, task, fetchAttemptData, fetchExecutionState]);
const isAttemptRunning = useMemo(() => {
if (!selectedAttempt || isStopping) {
return false;
}
return attemptData.processes.some(
(process) =>
(process.process_type === 'codingagent' ||
process.process_type === 'setupscript') &&
process.status === 'running'
);
}, [selectedAttempt, attemptData.processes, isStopping]);
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
fetchExecutionState(selectedAttempt.id, selectedAttempt.task_id);
}
}, 2000);
return () => clearInterval(interval);
}, [
isAttemptRunning,
task,
selectedAttempt,
fetchAttemptData,
fetchExecutionState,
]);
// Refresh diff when coding agent is running and making changes
useEffect(() => {
if (!executionState || !isOpen || !selectedAttempt) return;
const isCodingAgentRunning =
executionState.execution_state === 'CodingAgentRunning';
if (isCodingAgentRunning) {
// Immediately refresh diff when coding agent starts running
fetchDiff(true);
// Then refresh diff every 2 seconds while coding agent is active
const interval = setInterval(() => {
fetchDiff(true);
}, 2000);
return () => {
clearInterval(interval);
};
}
}, [executionState, isOpen, selectedAttempt, fetchDiff]);
// Refresh diff when coding agent completes or changes state
useEffect(() => {
if (!executionState?.execution_state || !isOpen || !selectedAttempt) return;
const isCodingAgentComplete =
executionState.execution_state === 'CodingAgentComplete';
const isCodingAgentFailed =
executionState.execution_state === 'CodingAgentFailed';
const isComplete = executionState.execution_state === 'Complete';
const hasChanges = executionState.has_changes;
// Fetch diff when coding agent completes, fails, or task is complete and has changes
if (
(isCodingAgentComplete || isCodingAgentFailed || isComplete) &&
hasChanges
) {
fetchDiff();
// Auto-switch to diffs tab when changes are detected, but only if user hasn't manually selected a tab
if (activeTab === 'logs' && !userSelectedTab) {
setActiveTab('diffs');
}
}
}, [
executionState?.execution_state,
executionState?.has_changes,
isOpen,
selectedAttempt,
fetchDiff,
activeTab,
userSelectedTab,
setActiveTab,
]);
const value = useMemo(
() => ({
task,
projectId,
loading,
setLoading,
selectedAttempt,
setSelectedAttempt,
isStopping,
setIsStopping,
deletingFiles,
fileToDelete,
setFileToDelete,
setDeletingFiles,
fetchDiff,
setDiffError,
diff,
diffError,
diffLoading,
setDiffLoading,
setDiff,
isBackgroundRefreshing,
handleOpenInEditor,
isAttemptRunning,
fetchExecutionState,
executionState,
attemptData,
setAttemptData,
fetchAttemptData,
}),
[
task,
projectId,
loading,
selectedAttempt,
isStopping,
deletingFiles,
fileToDelete,
fetchDiff,
diff,
diffError,
diffLoading,
isBackgroundRefreshing,
handleOpenInEditor,
isAttemptRunning,
fetchExecutionState,
executionState,
attemptData,
fetchAttemptData,
]
);
return (
<TaskDetailsContext.Provider value={value}>
{children}
</TaskDetailsContext.Provider>
);
};
export default TaskDetailsProvider;

View File

@@ -0,0 +1,46 @@
import { createContext, Dispatch, SetStateAction } from 'react';
import type {
AttemptData,
EditorType,
TaskAttempt,
TaskAttemptState,
TaskWithAttemptStatus,
WorktreeDiff,
} from 'shared/types.ts';
export interface TaskDetailsContextValue {
task: TaskWithAttemptStatus;
projectId: string;
loading: boolean;
setLoading: Dispatch<SetStateAction<boolean>>;
selectedAttempt: TaskAttempt | null;
setSelectedAttempt: Dispatch<SetStateAction<TaskAttempt | null>>;
isStopping: boolean;
setIsStopping: Dispatch<SetStateAction<boolean>>;
deletingFiles: Set<string>;
setDeletingFiles: Dispatch<SetStateAction<Set<string>>>;
fileToDelete: string | null;
setFileToDelete: Dispatch<SetStateAction<string | null>>;
setDiffError: Dispatch<SetStateAction<string | null>>;
fetchDiff: (isBackgroundRefresh?: boolean) => Promise<void>;
diff: WorktreeDiff | null;
diffError: string | null;
diffLoading: boolean;
isBackgroundRefreshing: boolean;
setDiff: Dispatch<SetStateAction<WorktreeDiff | null>>;
setDiffLoading: Dispatch<SetStateAction<boolean>>;
handleOpenInEditor: (editorType?: EditorType) => Promise<void>;
isAttemptRunning: boolean;
fetchExecutionState: (
attemptId: string,
taskId: string
) => Promise<void> | void;
executionState: TaskAttemptState | null;
attemptData: AttemptData;
setAttemptData: Dispatch<SetStateAction<AttemptData>>;
fetchAttemptData: (attemptId: string, taskId: string) => Promise<void> | void;
}
export const TaskDetailsContext = createContext<TaskDetailsContextValue>(
{} as TaskDetailsContextValue
);

View File

@@ -99,7 +99,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (error || !project) { if (error || !project) {
return ( return (
<div className="space-y-4"> <div className="space-y-4 py-12 px-4">
<Button variant="outline" onClick={onBack}> <Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects Back to Projects
@@ -124,7 +124,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 py-12 px-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button variant="outline" onClick={onBack}> <Button variant="outline" onClick={onBack}>

View File

@@ -0,0 +1,105 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { Button } from '@/components/ui/button.tsx';
import { makeRequest } from '@/lib/api.ts';
import { useContext } from 'react';
import { ApiResponse } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
function DeleteFileConfirmationDialog() {
const {
task,
projectId,
selectedAttempt,
setDeletingFiles,
fileToDelete,
deletingFiles,
setFileToDelete,
fetchDiff,
setDiffError,
} = useContext(TaskDetailsContext);
const handleConfirmDelete = async () => {
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
return;
try {
setDeletingFiles((prev) => new Set(prev).add(fileToDelete));
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/delete-file?file_path=${encodeURIComponent(
fileToDelete
)}`,
{
method: 'POST',
}
);
if (response.ok) {
const result: ApiResponse<null> = await response.json();
if (result.success) {
fetchDiff();
} else {
setDiffError(result.message || 'Failed to delete file');
}
} else {
setDiffError('Failed to delete file');
}
} catch (err) {
setDiffError('Failed to delete file');
} finally {
setDeletingFiles((prev) => {
const newSet = new Set(prev);
newSet.delete(fileToDelete);
return newSet;
});
setFileToDelete(null);
}
};
const handleCancelDelete = () => {
setFileToDelete(null);
};
return (
<Dialog open={!!fileToDelete} onOpenChange={() => handleCancelDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Are you sure you want to delete the file{' '}
<span className="font-mono font-medium">"{fileToDelete}"</span>?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<p className="text-sm text-red-800">
<strong>Warning:</strong> This action will permanently remove the
entire file from the worktree. This cannot be undone.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelDelete}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deletingFiles.has(fileToDelete || '')}
>
{deletingFiles.has(fileToDelete || '')
? 'Deleting...'
: 'Delete File'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DeleteFileConfirmationDialog;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useContext, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -16,11 +16,11 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { EditorType } from 'shared/types'; import type { EditorType } from 'shared/types';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface EditorSelectionDialogProps { interface EditorSelectionDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSelectEditor: (editorType: EditorType) => void;
} }
const editorOptions: { const editorOptions: {
@@ -63,12 +63,12 @@ const editorOptions: {
export function EditorSelectionDialog({ export function EditorSelectionDialog({
isOpen, isOpen,
onClose, onClose,
onSelectEditor,
}: EditorSelectionDialogProps) { }: EditorSelectionDialogProps) {
const { handleOpenInEditor } = useContext(TaskDetailsContext);
const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode'); const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode');
const handleConfirm = () => { const handleConfirm = () => {
onSelectEditor(selectedEditor); handleOpenInEditor(selectedEditor);
onClose(); onClose();
}; };

View File

@@ -1,21 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { Clock, ChevronDown, ChevronUp, Code } from 'lucide-react'; import { ChevronDown, ChevronUp, Clock, Code } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Chip } from '@/components/ui/chip'; import { Chip } from '@/components/ui/chip';
import { NormalizedConversationViewer } from './NormalizedConversationViewer'; import { NormalizedConversationViewer } from './TaskDetails/NormalizedConversationViewer.tsx';
import type { import type {
ExecutionProcess,
TaskAttempt, TaskAttempt,
TaskAttemptActivityWithPrompt, TaskAttemptActivityWithPrompt,
TaskAttemptStatus, TaskAttemptStatus,
ExecutionProcess,
} from 'shared/types'; } from 'shared/types';
interface TaskActivityHistoryProps { interface TaskActivityHistoryProps {
selectedAttempt: TaskAttempt | null; selectedAttempt: TaskAttempt | null;
activities: TaskAttemptActivityWithPrompt[]; activities: TaskAttemptActivityWithPrompt[];
runningProcessDetails: Record<string, ExecutionProcess>; runningProcessDetails: Record<string, ExecutionProcess>;
projectId: string;
} }
const getAttemptStatusDisplay = ( const getAttemptStatusDisplay = (
@@ -64,7 +63,6 @@ export function TaskActivityHistory({
selectedAttempt, selectedAttempt,
activities, activities,
runningProcessDetails, runningProcessDetails,
projectId,
}: TaskActivityHistoryProps) { }: TaskActivityHistoryProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>( const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
new Set() new Set()
@@ -169,7 +167,6 @@ export function TaskActivityHistory({
executionProcess={ executionProcess={
runningProcessDetails[activity.execution_process_id] runningProcessDetails[activity.execution_process_id]
} }
projectId={projectId}
/> />
</div> </div>
<Button <Button

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button.tsx';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { TaskDetailsToolbar } from '@/components/tasks/TaskDetailsToolbar.tsx';
type Props = {
projectHasDevScript?: boolean;
};
function CollapsibleToolbar({ projectHasDevScript }: Props) {
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
return (
<div className="border-b">
<div className="px-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
Task Details
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setIsHeaderCollapsed((prev) => !prev)}
className="h-6 w-6 p-0"
>
{isHeaderCollapsed ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!isHeaderCollapsed && (
<TaskDetailsToolbar projectHasDevScript={projectHasDevScript} />
)}
</div>
);
}
export default CollapsibleToolbar;

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useCallback, useContext, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button.tsx';
import { ChevronDown, ChevronUp, Trash2, GitCompare } from 'lucide-react'; import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react';
import type { WorktreeDiff, DiffChunkType, DiffChunk } from 'shared/types'; import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface ProcessedLine { interface ProcessedLine {
content: string; content: string;
@@ -20,26 +21,31 @@ interface ProcessedSection {
interface DiffCardProps { interface DiffCardProps {
diff: WorktreeDiff | null; diff: WorktreeDiff | null;
isBackgroundRefreshing?: boolean; deletable?: boolean;
onDeleteFile?: (filePath: string) => void;
deletingFiles?: Set<string>;
compact?: boolean; compact?: boolean;
className?: string; className?: string;
} }
export function DiffCard({ export function DiffCard({
diff, diff,
isBackgroundRefreshing = false, deletable = false,
onDeleteFile,
deletingFiles = new Set(),
compact = false, compact = false,
className = '', className = '',
}: DiffCardProps) { }: DiffCardProps) {
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } =
useContext(TaskDetailsContext);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = useState<Set<string>>( const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set() new Set()
); );
const onDeleteFile = useCallback(
(filePath: string) => {
setFileToDelete(filePath);
},
[setFileToDelete]
);
// Diff processing functions // Diff processing functions
const getChunkClassName = (chunkType: DiffChunkType) => { const getChunkClassName = (chunkType: DiffChunkType) => {
const baseClass = 'font-mono text-sm whitespace-pre flex w-full'; const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
@@ -361,7 +367,7 @@ export function DiffCard({
</div> </div>
)} )}
</div> </div>
{onDeleteFile && ( {deletable && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@@ -0,0 +1,32 @@
import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx';
import { useContext } from 'react';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
function DiffTab() {
const { diff, diffLoading, diffError } = useContext(TaskDetailsContext);
if (diffLoading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading changes...</p>
</div>
);
}
if (diffError) {
return (
<div className="text-center py-8 text-destructive">
<p>{diffError}</p>
</div>
);
}
return (
<div className="h-full px-4 pb-4">
<DiffCard diff={diff} deletable compact={false} className="h-full" />
</div>
);
}
export default DiffTab;

View File

@@ -0,0 +1,330 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { MessageSquare } from 'lucide-react';
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
function LogsTab() {
const { loading, selectedAttempt, executionState, attemptData } =
useContext(TaskDetailsContext);
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const setupScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (shouldAutoScrollLogs && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [
attemptData.activities,
attemptData.processes,
conversationUpdateTrigger,
shouldAutoScrollLogs,
]);
// Auto-scroll setup script logs to bottom
useEffect(() => {
if (setupScrollRef.current) {
setupScrollRef.current.scrollTop = setupScrollRef.current.scrollHeight;
}
}, [attemptData.runningProcessDetails]);
const handleLogsScroll = useCallback(() => {
if (scrollContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
scrollContainerRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
if (isAtBottom && !shouldAutoScrollLogs) {
setShouldAutoScrollLogs(true);
} else if (!isAtBottom && shouldAutoScrollLogs) {
setShouldAutoScrollLogs(false);
}
}
}, [shouldAutoScrollLogs]);
// Callback to trigger auto-scroll when conversation updates
const handleConversationUpdate = useCallback(() => {
setConversationUpdateTrigger((prev) => prev + 1);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading...</p>
</div>
);
}
// If no attempt is selected, show message
if (!selectedAttempt) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No attempt selected</p>
<p className="text-sm">Select an attempt to view its logs</p>
</div>
);
}
// If no execution state, execution hasn't started yet
if (!executionState) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">
Task execution not started yet
</p>
<p className="text-sm">
Logs will appear here once the task execution begins
</p>
</div>
);
}
const isSetupRunning = executionState.execution_state === 'SetupRunning';
const isSetupComplete = executionState.execution_state === 'SetupComplete';
const isSetupFailed = executionState.execution_state === 'SetupFailed';
const isCodingAgentRunning =
executionState.execution_state === 'CodingAgentRunning';
const isCodingAgentComplete =
executionState.execution_state === 'CodingAgentComplete';
const isCodingAgentFailed =
executionState.execution_state === 'CodingAgentFailed';
const isComplete = executionState.execution_state === 'Complete';
const hasChanges = executionState.has_changes;
// When setup script is running, show setup execution stdio
if (isSetupRunning) {
// Find the setup script process in runningProcessDetails first, then fallback to processes
const setupProcess = executionState.setup_process_id
? attemptData.runningProcessDetails[executionState.setup_process_id]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
);
return (
<div ref={setupScrollRef} className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2">Setup Script Running</p>
<p className="text-muted-foreground mb-4">
Preparing the environment for the coding agent...
</p>
</div>
{setupProcess && (
<div className="font-mono text-sm whitespace-pre-wrap text-muted-foreground">
{[setupProcess.stdout || '', setupProcess.stderr || '']
.filter(Boolean)
.join('\n') || 'Waiting for setup script output...'}
</div>
)}
</div>
);
}
// When setup failed, show error message and conversation
if (isSetupFailed) {
const setupProcess = executionState.setup_process_id
? attemptData.runningProcessDetails[executionState.setup_process_id]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
);
return (
<div className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2 text-destructive">
Setup Script Failed
</p>
<p className="text-muted-foreground mb-4">
The setup script encountered an error. Error details below:
</p>
</div>
{setupProcess && (
<NormalizedConversationViewer
executionProcess={setupProcess}
onConversationUpdate={handleConversationUpdate}
/>
)}
</div>
);
}
// When coding agent failed, show error message and conversation
if (isCodingAgentFailed) {
const codingAgentProcess = executionState.coding_agent_process_id
? attemptData.runningProcessDetails[
executionState.coding_agent_process_id
]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'codingagent'
);
return (
<div className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2 text-destructive">
Coding Agent Failed
</p>
<p className="text-muted-foreground mb-4">
The coding agent encountered an error. Error details below:
</p>
</div>
{codingAgentProcess && (
<NormalizedConversationViewer
executionProcess={codingAgentProcess}
onConversationUpdate={handleConversationUpdate}
/>
)}
</div>
);
}
// When setup is complete but coding agent hasn't started, show waiting state
if (
isSetupComplete &&
!isCodingAgentRunning &&
!isCodingAgentComplete &&
!isCodingAgentFailed &&
!hasChanges
) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-semibold mb-2">Setup Complete</p>
<p>Waiting for coding agent to start...</p>
</div>
);
}
// When task is complete, show completion message
if (isComplete) {
return (
<div className="text-center py-8 text-green-600">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-semibold mb-2">Task Complete</p>
<p className="text-muted-foreground">
The task has been completed successfully.
</p>
</div>
);
}
// When coding agent is running or complete, show conversation
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
return (
<div
ref={scrollContainerRef}
onScroll={handleLogsScroll}
className="h-full overflow-y-auto"
>
{(() => {
// Find main coding agent process (command: "executor")
let mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (!mainCodingAgentProcess) {
const mainCodingAgentSummary = attemptData.processes.find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (mainCodingAgentSummary) {
mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === mainCodingAgentSummary.id);
if (!mainCodingAgentProcess) {
mainCodingAgentProcess = {
...mainCodingAgentSummary,
stdout: null,
stderr: null,
} as any;
}
}
}
// Find follow up executor processes (command: "followup_executor")
const followUpProcesses = attemptData.processes
.filter(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'followup_executor'
)
.map((summary) => {
const detailedProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === summary.id);
return (
detailedProcess ||
({
...summary,
stdout: null,
stderr: null,
} as any)
);
});
if (mainCodingAgentProcess || followUpProcesses.length > 0) {
return (
<div className="space-y-8">
{mainCodingAgentProcess && (
<div className="space-y-6">
<NormalizedConversationViewer
executionProcess={mainCodingAgentProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
)}
{followUpProcesses.map((followUpProcess) => (
<div key={followUpProcess.id}>
<div className="border-t border-border mb-8"></div>
<NormalizedConversationViewer
executionProcess={followUpProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
))}
</div>
);
}
return (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold mb-2">
Coding Agent Starting
</p>
<p>Initializing conversation...</p>
</div>
);
})()}
</div>
);
}
// Default case - unexpected state
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Unknown execution state</p>
</div>
);
}
export default LogsTab;

View File

@@ -1,43 +1,42 @@
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { import {
User,
Bot,
Eye,
Edit,
Terminal,
Search,
Globe,
Plus,
Settings,
Brain,
Hammer,
AlertCircle, AlertCircle,
Bot,
Brain,
CheckSquare,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
Edit,
Eye,
Globe,
Hammer,
Plus,
Search,
Settings,
Terminal,
ToggleLeft, ToggleLeft,
ToggleRight, ToggleRight,
CheckSquare, User,
} from 'lucide-react'; } from 'lucide-react';
import { makeRequest } from '@/lib/api'; import { makeRequest } from '@/lib/api.ts';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
import { DiffCard } from './DiffCard'; import { DiffCard } from './DiffCard.tsx';
import type { import type {
ApiResponse,
ExecutionProcess,
NormalizedConversation, NormalizedConversation,
NormalizedEntry, NormalizedEntry,
NormalizedEntryType, NormalizedEntryType,
ExecutionProcess,
ApiResponse,
WorktreeDiff, WorktreeDiff,
} from 'shared/types'; } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface NormalizedConversationViewerProps { interface NormalizedConversationViewerProps {
executionProcess: ExecutionProcess; executionProcess: ExecutionProcess;
projectId: string;
onConversationUpdate?: () => void; onConversationUpdate?: () => void;
diff?: WorktreeDiff | null; diff?: WorktreeDiff | null;
isBackgroundRefreshing?: boolean; isBackgroundRefreshing?: boolean;
onDeleteFile?: (filePath: string) => void; diffDeletable?: boolean;
deletingFiles?: Set<string>;
} }
const getEntryIcon = (entryType: NormalizedEntryType) => { const getEntryIcon = (entryType: NormalizedEntryType) => {
@@ -356,13 +355,10 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
export function NormalizedConversationViewer({ export function NormalizedConversationViewer({
executionProcess, executionProcess,
projectId, diffDeletable,
onConversationUpdate, onConversationUpdate,
diff,
isBackgroundRefreshing = false,
onDeleteFile,
deletingFiles = new Set(),
}: NormalizedConversationViewerProps) { }: NormalizedConversationViewerProps) {
const { projectId, diff } = useContext(TaskDetailsContext);
const [conversation, setConversation] = const [conversation, setConversation] =
useState<NormalizedConversation | null>(null); useState<NormalizedConversation | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -642,9 +638,7 @@ export function NormalizedConversationViewer({
<div className="mt-4 mb-2"> <div className="mt-4 mb-2">
<DiffCard <DiffCard
diff={incrementalDiff} diff={incrementalDiff}
isBackgroundRefreshing={isBackgroundRefreshing} deletable={diffDeletable}
onDeleteFile={onDeleteFile}
deletingFiles={deletingFiles}
compact={true} compact={true}
/> />
</div> </div>

View File

@@ -0,0 +1,56 @@
import { GitCompare, MessageSquare } from 'lucide-react';
import { useContext } from 'react';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
type Props = {
activeTab: 'logs' | 'diffs';
setActiveTab: (tab: 'logs' | 'diffs') => void;
setUserSelectedTab: (tab: boolean) => void;
};
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
const { diff } = useContext(TaskDetailsContext);
return (
<div className="border-b bg-muted/30">
<div className="flex px-4">
<button
onClick={() => {
console.log('Logs tab clicked - setting activeTab to logs');
setActiveTab('logs');
setUserSelectedTab(true);
}}
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'logs'
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<MessageSquare className="h-4 w-4 mr-2" />
Logs
</button>
<button
onClick={() => {
console.log('Diffs tab clicked - setting activeTab to diffs');
setActiveTab('diffs');
setUserSelectedTab(true);
}}
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'diffs'
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<GitCompare className="h-4 w-4 mr-2" />
Diffs
{diff && diff.files.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary/10 text-primary rounded-full">
{diff.files.length}
</span>
)}
</button>
</div>
</div>
);
}
export default TabNavigation;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useContext, useState } from 'react';
import { Edit, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Chip } from '@/components/ui/chip'; import { Chip } from '@/components/ui/chip';
import { import {
@@ -9,9 +9,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface TaskDetailsHeaderProps { interface TaskDetailsHeaderProps {
task: TaskWithAttemptStatus;
onClose: () => void; onClose: () => void;
onEditTask?: (task: TaskWithAttemptStatus) => void; onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void;
@@ -43,11 +43,11 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
}; };
export function TaskDetailsHeader({ export function TaskDetailsHeader({
task,
onClose, onClose,
onEditTask, onEditTask,
onDeleteTask, onDeleteTask,
}: TaskDetailsHeaderProps) { }: TaskDetailsHeaderProps) {
const { task } = useContext(TaskDetailsContext);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
return ( return (

View File

@@ -1,41 +1,22 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useState } from 'react';
import { TaskDetailsHeader } from './TaskDetailsHeader'; import { TaskDetailsHeader } from './TaskDetailsHeader';
import { TaskDetailsToolbar } from './TaskDetailsToolbar';
import { NormalizedConversationViewer } from './NormalizedConversationViewer';
import { TaskFollowUpSection } from './TaskFollowUpSection'; import { TaskFollowUpSection } from './TaskFollowUpSection';
import { EditorSelectionDialog } from './EditorSelectionDialog'; import { EditorSelectionDialog } from './EditorSelectionDialog';
import { useTaskDetails } from '@/hooks/useTaskDetails';
import { import {
getTaskPanelClasses,
getBackdropClasses, getBackdropClasses,
getTaskPanelClasses,
} from '@/lib/responsive-config'; } from '@/lib/responsive-config';
import { makeRequest } from '@/lib/api'; import type { TaskWithAttemptStatus } from 'shared/types';
import { Button } from '@/components/ui/button'; import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
import { import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
Dialog, import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
DialogContent, import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
DialogDescription, import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx';
DialogFooter, import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx';
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
ChevronDown,
ChevronUp,
MessageSquare,
GitCompare,
} from 'lucide-react';
import { DiffCard } from './DiffCard';
import type {
TaskWithAttemptStatus,
EditorType,
Project,
WorktreeDiff,
} from 'shared/types';
interface TaskDetailsPanelProps { interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null; task: TaskWithAttemptStatus | null;
project: Project | null; projectHasDevScript?: boolean;
projectId: string; projectId: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -44,15 +25,9 @@ interface TaskDetailsPanelProps {
isDialogOpen?: boolean; isDialogOpen?: boolean;
} }
interface ApiResponse<T> {
success: boolean;
data: T | null;
message: string | null;
}
export function TaskDetailsPanel({ export function TaskDetailsPanel({
task, task,
project, projectHasDevScript,
projectId, projectId,
isOpen, isOpen,
onClose, onClose,
@@ -61,177 +36,19 @@ export function TaskDetailsPanel({
isDialogOpen = false, isDialogOpen = false,
}: TaskDetailsPanelProps) { }: TaskDetailsPanelProps) {
const [showEditorDialog, setShowEditorDialog] = useState(false); const [showEditorDialog, setShowEditorDialog] = useState(false);
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const setupScrollRef = useRef<HTMLDivElement>(null);
// Tab and collapsible state // Tab and collapsible state
const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs'); const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs');
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
const [userSelectedTab, setUserSelectedTab] = useState<boolean>(false); const [userSelectedTab, setUserSelectedTab] = useState<boolean>(false);
// Diff-related state
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
const [diffLoading, setDiffLoading] = useState(true);
const [diffError, setDiffError] = useState<string | null>(null);
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
// Use the custom hook for all task details logic
const {
taskAttempts,
selectedAttempt,
attemptData,
loading,
selectedExecutor,
isStopping,
followUpMessage,
isSendingFollowUp,
followUpError,
isStartingDevServer,
devServerDetails,
branches,
selectedBranch,
runningDevServer,
isAttemptRunning,
canSendFollowUp,
processedDevServerLogs,
executionState,
setFollowUpMessage,
setFollowUpError,
setIsHoveringDevServer,
handleAttemptChange,
createNewAttempt,
stopAllExecutions,
startDevServer,
stopDevServer,
openInEditor,
handleSendFollowUp,
} = useTaskDetails(task, projectId, isOpen);
// Use ref to track loading state to prevent dependency cycles
const diffLoadingRef = useRef(false);
// Reset to logs tab when task changes // Reset to logs tab when task changes
useEffect(() => { useEffect(() => {
if (task) { if (task?.id) {
setActiveTab('logs'); setActiveTab('logs');
setUserSelectedTab(true); // Treat this as a user selection to prevent auto-switching setUserSelectedTab(true); // Treat this as a user selection to prevent auto-switching
} }
}, [task?.id]); }, [task?.id]);
// Fetch diff when attempt changes
const fetchDiff = useCallback(
async (isBackgroundRefresh = false) => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) {
setDiff(null);
setDiffLoading(false);
return;
}
// Prevent multiple concurrent requests
if (diffLoadingRef.current) {
return;
}
try {
diffLoadingRef.current = true;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(true);
} else {
setDiffLoading(true);
}
setDiffError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/diff`
);
if (response.ok) {
const result: ApiResponse<WorktreeDiff> = await response.json();
if (result.success && result.data) {
setDiff(result.data);
} else {
setDiffError('Failed to load diff');
}
} else {
setDiffError('Failed to load diff');
}
} catch (err) {
setDiffError('Failed to load diff');
} finally {
diffLoadingRef.current = false;
if (isBackgroundRefresh) {
setIsBackgroundRefreshing(false);
} else {
setDiffLoading(false);
}
}
},
[projectId, selectedAttempt?.id, selectedAttempt?.task_id]
);
useEffect(() => {
if (isOpen) {
fetchDiff();
}
}, [isOpen, fetchDiff]);
// Refresh diff when coding agent is running and making changes
useEffect(() => {
if (!executionState || !isOpen || !selectedAttempt) return;
const isCodingAgentRunning =
executionState.execution_state === 'CodingAgentRunning';
if (isCodingAgentRunning) {
// Immediately refresh diff when coding agent starts running
fetchDiff(true);
// Then refresh diff every 2 seconds while coding agent is active
const interval = setInterval(() => {
fetchDiff(true);
}, 2000);
return () => {
clearInterval(interval);
};
}
}, [executionState, isOpen, selectedAttempt, fetchDiff]);
// Refresh diff when coding agent completes or changes state
useEffect(() => {
if (!executionState || !isOpen || !selectedAttempt) return;
const isCodingAgentComplete =
executionState.execution_state === 'CodingAgentComplete';
const isCodingAgentFailed =
executionState.execution_state === 'CodingAgentFailed';
const isComplete = executionState.execution_state === 'Complete';
const hasChanges = executionState.has_changes;
// Fetch diff when coding agent completes, fails, or task is complete and has changes
if (
(isCodingAgentComplete || isCodingAgentFailed || isComplete) &&
hasChanges
) {
fetchDiff();
// Auto-switch to diffs tab when changes are detected, but only if user hasn't manually selected a tab
if (activeTab === 'logs' && !userSelectedTab) {
setActiveTab('diffs');
}
}
}, [
executionState?.execution_state,
executionState?.has_changes,
isOpen,
selectedAttempt,
fetchDiff,
activeTab,
userSelectedTab,
]);
// Handle ESC key locally to prevent global navigation // Handle ESC key locally to prevent global navigation
useEffect(() => { useEffect(() => {
if (!isOpen || isDialogOpen) return; if (!isOpen || isDialogOpen) return;
@@ -248,638 +65,56 @@ export function TaskDetailsPanel({
return () => document.removeEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen, onClose, isDialogOpen]); }, [isOpen, onClose, isDialogOpen]);
// Callback to trigger auto-scroll when conversation updates
const handleConversationUpdate = useCallback(() => {
setConversationUpdateTrigger((prev) => prev + 1);
}, []);
// Auto-scroll to bottom when activities, execution processes, or conversation changes (for logs section)
useEffect(() => {
if (
shouldAutoScrollLogs &&
scrollContainerRef.current &&
activeTab === 'logs'
) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [
attemptData.activities,
attemptData.processes,
conversationUpdateTrigger,
shouldAutoScrollLogs,
activeTab,
]);
// Auto-scroll setup script logs to bottom
useEffect(() => {
if (setupScrollRef.current) {
setupScrollRef.current.scrollTop = setupScrollRef.current.scrollHeight;
}
}, [attemptData.runningProcessDetails]);
// Handle scroll events to detect manual scrolling (for logs section)
const handleLogsScroll = useCallback(() => {
if (scrollContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
scrollContainerRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
if (isAtBottom && !shouldAutoScrollLogs) {
setShouldAutoScrollLogs(true);
} else if (!isAtBottom && shouldAutoScrollLogs) {
setShouldAutoScrollLogs(false);
}
}
}, [shouldAutoScrollLogs]);
const handleOpenInEditor = async (editorType?: EditorType) => {
try {
await openInEditor(editorType);
} catch (err) {
if (!editorType) {
setShowEditorDialog(true);
}
}
};
const handleDeleteFileClick = (filePath: string) => {
setFileToDelete(filePath);
};
const handleConfirmDelete = async () => {
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
return;
try {
setDeletingFiles((prev) => new Set(prev).add(fileToDelete));
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/delete-file?file_path=${encodeURIComponent(
fileToDelete
)}`,
{
method: 'POST',
}
);
if (response.ok) {
const result: ApiResponse<null> = await response.json();
if (result.success) {
fetchDiff();
} else {
setDiffError(result.message || 'Failed to delete file');
}
} else {
setDiffError('Failed to delete file');
}
} catch (err) {
setDiffError('Failed to delete file');
} finally {
setDeletingFiles((prev) => {
const newSet = new Set(prev);
newSet.delete(fileToDelete);
return newSet;
});
setFileToDelete(null);
}
};
const handleCancelDelete = () => {
setFileToDelete(null);
};
// Render tab content based on active tab
const renderTabContent = (): JSX.Element => {
console.log('renderTabContent called with activeTab:', activeTab);
if (activeTab === 'diffs') {
return renderDiffsContent();
}
return renderLogsContent();
};
// Render diffs content
const renderDiffsContent = (): JSX.Element => {
if (diffLoading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading changes...</p>
</div>
);
}
if (diffError) {
return (
<div className="text-center py-8 text-destructive">
<p>{diffError}</p>
</div>
);
}
return (
<div className="h-full px-4 pb-4">
<DiffCard
diff={diff}
isBackgroundRefreshing={isBackgroundRefreshing}
onDeleteFile={handleDeleteFileClick}
deletingFiles={deletingFiles}
compact={false}
className="h-full"
/>
</div>
);
};
// Render logs content
const renderLogsContent = (): JSX.Element => {
// Debug logging to help identify the issue
console.log('renderLogsContent called with state:', {
loading,
selectedAttempt: selectedAttempt?.id,
executionState: executionState?.execution_state,
activeTab,
});
// Show loading spinner only when we're actually loading data
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground ml-4">Loading...</p>
</div>
);
}
// If no attempt is selected, show message
if (!selectedAttempt) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No attempt selected</p>
<p className="text-sm">Select an attempt to view its logs</p>
</div>
);
}
// If no execution state, execution hasn't started yet
if (!executionState) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">
Task execution not started yet
</p>
<p className="text-sm">
Logs will appear here once the task execution begins
</p>
</div>
);
}
const isSetupRunning = executionState.execution_state === 'SetupRunning';
const isSetupComplete = executionState.execution_state === 'SetupComplete';
const isSetupFailed = executionState.execution_state === 'SetupFailed';
const isCodingAgentRunning =
executionState.execution_state === 'CodingAgentRunning';
const isCodingAgentComplete =
executionState.execution_state === 'CodingAgentComplete';
const isCodingAgentFailed =
executionState.execution_state === 'CodingAgentFailed';
const isComplete = executionState.execution_state === 'Complete';
const hasChanges = executionState.has_changes;
// When setup script is running, show setup execution stdio
if (isSetupRunning) {
// Find the setup script process in runningProcessDetails first, then fallback to processes
const setupProcess = executionState.setup_process_id
? attemptData.runningProcessDetails[executionState.setup_process_id]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
);
return (
<div ref={setupScrollRef} className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2">Setup Script Running</p>
<p className="text-muted-foreground mb-4">
Preparing the environment for the coding agent...
</p>
</div>
{setupProcess && (
<div className="font-mono text-sm whitespace-pre-wrap text-muted-foreground">
{(() => {
const stdout = setupProcess.stdout || '';
const stderr = setupProcess.stderr || '';
const combined = [stdout, stderr].filter(Boolean).join('\n');
return combined || 'Waiting for setup script output...';
})()}
</div>
)}
</div>
);
}
// When setup failed, show error message and conversation
if (isSetupFailed) {
const setupProcess = executionState.setup_process_id
? attemptData.runningProcessDetails[executionState.setup_process_id]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'setupscript'
);
return (
<div className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2 text-destructive">
Setup Script Failed
</p>
<p className="text-muted-foreground mb-4">
The setup script encountered an error. Error details below:
</p>
</div>
{setupProcess && (
<NormalizedConversationViewer
executionProcess={setupProcess}
projectId={projectId}
onConversationUpdate={handleConversationUpdate}
/>
)}
</div>
);
}
// When coding agent failed, show error message and conversation
if (isCodingAgentFailed) {
const codingAgentProcess = executionState.coding_agent_process_id
? attemptData.runningProcessDetails[
executionState.coding_agent_process_id
]
: Object.values(attemptData.runningProcessDetails).find(
(process) => process.process_type === 'codingagent'
);
return (
<div className="h-full overflow-y-auto">
<div className="mb-4">
<p className="text-lg font-semibold mb-2 text-destructive">
Coding Agent Failed
</p>
<p className="text-muted-foreground mb-4">
The coding agent encountered an error. Error details below:
</p>
</div>
{codingAgentProcess && (
<NormalizedConversationViewer
executionProcess={codingAgentProcess}
projectId={projectId}
onConversationUpdate={handleConversationUpdate}
/>
)}
</div>
);
}
// When setup is complete but coding agent hasn't started, show waiting state
if (
isSetupComplete &&
!isCodingAgentRunning &&
!isCodingAgentComplete &&
!isCodingAgentFailed &&
!hasChanges
) {
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-semibold mb-2">Setup Complete</p>
<p>Waiting for coding agent to start...</p>
</div>
);
}
// When task is complete, show completion message
if (isComplete) {
return (
<div className="text-center py-8 text-green-600">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-semibold mb-2">Task Complete</p>
<p className="text-muted-foreground">
The task has been completed successfully.
</p>
</div>
);
}
// When coding agent is running or complete, show conversation
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
return (
<div
ref={scrollContainerRef}
onScroll={handleLogsScroll}
className="h-full overflow-y-auto"
>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
) : (
(() => {
// Find main coding agent process (command: "executor")
let mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (!mainCodingAgentProcess) {
const mainCodingAgentSummary = attemptData.processes.find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (mainCodingAgentSummary) {
mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === mainCodingAgentSummary.id);
if (!mainCodingAgentProcess) {
mainCodingAgentProcess = {
...mainCodingAgentSummary,
stdout: null,
stderr: null,
} as any;
}
}
}
// Find follow up executor processes (command: "followup_executor")
const followUpProcesses = attemptData.processes
.filter(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'followup_executor'
)
.map((summary) => {
const detailedProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === summary.id);
return (
detailedProcess ||
({
...summary,
stdout: null,
stderr: null,
} as any)
);
});
if (mainCodingAgentProcess || followUpProcesses.length > 0) {
return (
<div className="space-y-8">
{mainCodingAgentProcess && (
<div className="space-y-6">
<NormalizedConversationViewer
executionProcess={mainCodingAgentProcess}
projectId={projectId}
onConversationUpdate={handleConversationUpdate}
diff={diff}
isBackgroundRefreshing={isBackgroundRefreshing}
onDeleteFile={handleDeleteFileClick}
deletingFiles={deletingFiles}
/>
</div>
)}
{followUpProcesses.map((followUpProcess) => (
<div key={followUpProcess.id}>
<div className="border-t border-border mb-8"></div>
<NormalizedConversationViewer
executionProcess={followUpProcess}
projectId={projectId}
onConversationUpdate={handleConversationUpdate}
diff={diff}
isBackgroundRefreshing={isBackgroundRefreshing}
onDeleteFile={handleDeleteFileClick}
deletingFiles={deletingFiles}
/>
</div>
))}
</div>
);
}
return (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold mb-2">
Coding Agent Starting
</p>
<p>Initializing conversation...</p>
</div>
);
})()
)}
</div>
);
}
// Default case - unexpected state
return (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Unknown execution state</p>
</div>
);
};
if (!task) return null;
return ( return (
<> <>
{isOpen && ( {!task || !isOpen ? null : (
<> <TaskDetailsProvider
task={task}
projectId={projectId}
setShowEditorDialog={setShowEditorDialog}
activeTab={activeTab}
setActiveTab={setActiveTab}
isOpen={isOpen}
userSelectedTab={userSelectedTab}
>
{/* Backdrop - only on smaller screens (overlay mode) */} {/* Backdrop - only on smaller screens (overlay mode) */}
<div className={getBackdropClasses()} onClick={onClose} /> <div className={getBackdropClasses()} onClick={onClose} />
{/* Panel */} {/* Panel */}
<div className={getTaskPanelClasses()}> <div className={getTaskPanelClasses()}>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */}
<TaskDetailsHeader <TaskDetailsHeader
task={task}
onClose={onClose} onClose={onClose}
onEditTask={onEditTask} onEditTask={onEditTask}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
{/* Collapsible Toolbar */} <CollapsibleToolbar projectHasDevScript={projectHasDevScript} />
<div className="border-b">
<div className="px-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
Task Details
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setIsHeaderCollapsed(!isHeaderCollapsed)}
className="h-6 w-6 p-0"
>
{isHeaderCollapsed ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
</div>
{!isHeaderCollapsed && (
<TaskDetailsToolbar
task={task}
project={project}
projectId={projectId}
selectedAttempt={selectedAttempt}
taskAttempts={taskAttempts}
isAttemptRunning={isAttemptRunning}
isStopping={isStopping}
selectedExecutor={selectedExecutor}
runningDevServer={runningDevServer}
isStartingDevServer={isStartingDevServer}
devServerDetails={devServerDetails}
processedDevServerLogs={processedDevServerLogs}
branches={branches}
selectedBranch={selectedBranch}
onAttemptChange={handleAttemptChange}
onCreateNewAttempt={createNewAttempt}
onStopAllExecutions={stopAllExecutions}
onStartDevServer={startDevServer}
onStopDevServer={stopDevServer}
onOpenInEditor={handleOpenInEditor}
onSetIsHoveringDevServer={setIsHoveringDevServer}
/>
)}
</div>
{/* Tab Navigation */} <TabNavigation
<div className="border-b bg-muted/30"> activeTab={activeTab}
<div className="flex px-4"> setActiveTab={setActiveTab}
<button setUserSelectedTab={setUserSelectedTab}
onClick={() => { />
console.log(
'Logs tab clicked - setting activeTab to logs'
);
setActiveTab('logs');
setUserSelectedTab(true);
}}
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'logs'
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<MessageSquare className="h-4 w-4 mr-2" />
Logs
</button>
<button
onClick={() => {
console.log(
'Diffs tab clicked - setting activeTab to diffs'
);
setActiveTab('diffs');
setUserSelectedTab(true);
}}
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'diffs'
? 'border-primary text-primary bg-background'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<GitCompare className="h-4 w-4 mr-2" />
Diffs
{diff && diff.files.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary/10 text-primary rounded-full">
{diff.files.length}
</span>
)}
</button>
</div>
</div>
{/* Tab Content */} {/* Tab Content */}
<div <div
className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`} className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`}
> >
{renderTabContent()} {activeTab === 'diffs' ? <DiffTab /> : <LogsTab />}
</div> </div>
{/* Footer - Follow-up section */} <TaskFollowUpSection />
{selectedAttempt && (
<TaskFollowUpSection
followUpMessage={followUpMessage}
setFollowUpMessage={setFollowUpMessage}
isSendingFollowUp={isSendingFollowUp}
followUpError={followUpError}
setFollowUpError={setFollowUpError}
canSendFollowUp={canSendFollowUp}
projectId={projectId}
onSendFollowUp={handleSendFollowUp}
/>
)}
</div> </div>
</div> </div>
{/* Editor Selection Dialog */}
<EditorSelectionDialog <EditorSelectionDialog
isOpen={showEditorDialog} isOpen={showEditorDialog}
onClose={() => setShowEditorDialog(false)} onClose={() => setShowEditorDialog(false)}
onSelectEditor={handleOpenInEditor}
/> />
{/* Delete File Confirmation Dialog */} <DeleteFileConfirmationDialog />
<Dialog </TaskDetailsProvider>
open={!!fileToDelete}
onOpenChange={() => handleCancelDelete()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Are you sure you want to delete the file{' '}
<span className="font-mono font-medium">
"{fileToDelete}"
</span>
?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<p className="text-sm text-red-800">
<strong>Warning:</strong> This action will permanently
remove the entire file from the worktree. This cannot be
undone.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelDelete}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deletingFiles.has(fileToDelete || '')}
>
{deletingFiles.has(fileToDelete || '')
? 'Deleting...'
: 'Delete File'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)} )}
</> </>
); );

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { import {
ArrowDown, ArrowDown,
ExternalLink, ExternalLink,
@@ -50,13 +50,11 @@ import { makeRequest } from '@/lib/api';
import type { import type {
BranchStatus, BranchStatus,
ExecutionProcess, ExecutionProcess,
ExecutionProcessSummary,
GitBranch, GitBranch,
Project,
TaskAttempt, TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types'; } from 'shared/types';
import { ProvidePatDialog } from '@/components/ProvidePatDialog'; import { ProvidePatDialog } from '@/components/ProvidePatDialog';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -65,27 +63,7 @@ interface ApiResponse<T> {
} }
interface TaskDetailsToolbarProps { interface TaskDetailsToolbarProps {
task: TaskWithAttemptStatus; projectHasDevScript?: boolean;
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;
branches: GitBranch[];
selectedBranch: string | null;
onAttemptChange: (attemptId: string) => void;
onCreateNewAttempt: (executor?: string, baseBranch?: string) => void;
onStopAllExecutions: () => void;
onStartDevServer: () => void;
onStopDevServer: () => void;
onOpenInEditor: () => void;
onSetIsHoveringDevServer: (hovering: boolean) => void;
} }
const availableExecutors = [ const availableExecutors = [
@@ -97,31 +75,35 @@ const availableExecutors = [
]; ];
export function TaskDetailsToolbar({ export function TaskDetailsToolbar({
task, projectHasDevScript,
project,
projectId,
selectedAttempt,
taskAttempts,
isAttemptRunning,
isStopping,
selectedExecutor,
runningDevServer,
isStartingDevServer,
devServerDetails,
processedDevServerLogs,
branches,
selectedBranch,
onAttemptChange,
onCreateNewAttempt,
onStopAllExecutions,
onStartDevServer,
onStopDevServer,
onOpenInEditor,
onSetIsHoveringDevServer,
}: TaskDetailsToolbarProps) { }: TaskDetailsToolbarProps) {
const {
task,
projectId,
setLoading,
setSelectedAttempt,
isStopping,
handleOpenInEditor,
isAttemptRunning,
setAttemptData,
fetchAttemptData,
fetchExecutionState,
selectedAttempt,
setIsStopping,
attemptData,
} = useContext(TaskDetailsContext);
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([]);
const { config } = useConfig(); const { config } = useConfig();
const [branchSearchTerm, setBranchSearchTerm] = useState(''); const [branchSearchTerm, setBranchSearchTerm] = useState('');
const [branches, setBranches] = useState<GitBranch[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [selectedExecutor, setSelectedExecutor] = useState<string>(
config?.executor.type || 'claude'
);
// State for create attempt mode // State for create attempt mode
const [isInCreateAttemptMode, setIsInCreateAttemptMode] = useState(false); const [isInCreateAttemptMode, setIsInCreateAttemptMode] = useState(false);
const [createAttemptBranch, setCreateAttemptBranch] = useState<string | null>( const [createAttemptBranch, setCreateAttemptBranch] = useState<string | null>(
@@ -146,6 +128,88 @@ export function TaskDetailsToolbar({
const [showPatDialog, setShowPatDialog] = useState(false); const [showPatDialog, setShowPatDialog] = useState(false);
const [patDialogError, setPatDialogError] = useState<string | null>(null); const [patDialogError, setPatDialogError] = useState<string | null>(null);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
// Find running dev server in current project
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
(process) =>
process.process_type === 'devserver' && process.status === 'running'
);
}, [attemptData.processes]);
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<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
}, [runningDevServer, task, selectedAttempt, projectId]);
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
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]);
const fetchProjectBranches = useCallback(async () => {
try {
const response = await makeRequest(`/api/projects/${projectId}/branches`);
if (response.ok) {
const result: ApiResponse<GitBranch[]> = await response.json();
if (result.success && result.data) {
setBranches(result.data);
// Set current branch as default
const currentBranch = result.data.find((b) => b.is_current);
if (currentBranch && !selectedBranch) {
setSelectedBranch(currentBranch.name);
}
}
}
} catch (err) {
console.error('Failed to fetch project branches:', err);
}
}, [projectId, selectedBranch]);
useEffect(() => {
fetchProjectBranches();
}, [fetchProjectBranches]);
// Set default executor from config
useEffect(() => {
if (config && config.executor.type !== selectedExecutor) {
setSelectedExecutor(config.executor.type);
}
}, [config, selectedExecutor]);
// Set create attempt mode when there are no attempts // Set create attempt mode when there are no attempts
useEffect(() => { useEffect(() => {
setIsInCreateAttemptMode(taskAttempts.length === 0); setIsInCreateAttemptMode(taskAttempts.length === 0);
@@ -185,6 +249,165 @@ export function TaskDetailsToolbar({
} }
}, [selectedAttempt?.base_branch]); }, [selectedAttempt?.base_branch]);
const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => {
if (!task) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
{
method: 'POST',
body: JSON.stringify({
executor: executor || selectedExecutor,
base_branch: baseBranch || selectedBranch,
}),
}
);
if (response.ok) {
fetchTaskAttempts();
}
} catch (err) {
console.error('Failed to create new attempt:', err);
}
};
const fetchTaskAttempts = useCallback(async () => {
if (!task) return;
try {
setLoading(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`
);
if (response.ok) {
const result: ApiResponse<TaskAttempt[]> = 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, latestAttempt.task_id);
fetchExecutionState(latestAttempt.id, latestAttempt.task_id);
} else {
setSelectedAttempt(null);
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
}
}
}
} catch (err) {
console.error('Failed to fetch task attempts:', err);
} finally {
setLoading(false);
}
}, [task, projectId, fetchAttemptData, fetchExecutionState]);
useEffect(() => {
fetchTaskAttempts();
}, [fetchTaskAttempts]);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const startDevServer = async () => {
if (!task || !selectedAttempt) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: 'POST',
}
);
if (!response.ok) {
throw new Error('Failed to start dev server');
}
const data: ApiResponse<null> = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to start dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_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/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: 'POST',
}
);
if (!response.ok) {
throw new Error('Failed to stop dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (err) {
console.error('Failed to stop dev server:', err);
} finally {
setIsStartingDevServer(false);
}
};
const stopAllExecutions = async () => {
if (!task || !selectedAttempt) return;
try {
setIsStopping(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/stop`,
{
method: 'POST',
}
);
if (response.ok) {
await fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
setTimeout(() => {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
}, 1000);
}
} catch (err) {
console.error('Failed to stop executions:', err);
} finally {
setIsStopping(false);
}
};
const handleAttemptChange = useCallback(
(attempt: TaskAttempt) => {
setSelectedAttempt(attempt);
fetchAttemptData(attempt.id, attempt.task_id);
fetchExecutionState(attempt.id, attempt.task_id);
},
[fetchAttemptData, fetchExecutionState, setSelectedAttempt]
);
// Branch status fetching // Branch status fetching
const fetchBranchStatus = useCallback(async () => { const fetchBranchStatus = useCallback(async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
@@ -722,7 +945,7 @@ export function TaskDetailsToolbar({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onOpenInEditor()} onClick={() => handleOpenInEditor()}
className="h-4 w-4 p-0 hover:bg-muted" className="h-4 w-4 p-0 hover:bg-muted"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
@@ -743,10 +966,10 @@ export function TaskDetailsToolbar({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div <div
className={ className={
!project?.dev_script ? 'cursor-not-allowed' : '' !projectHasDevScript ? 'cursor-not-allowed' : ''
} }
onMouseEnter={() => onSetIsHoveringDevServer(true)} onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => onSetIsHoveringDevServer(false)} onMouseLeave={() => setIsHoveringDevServer(false)}
> >
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -758,11 +981,11 @@ export function TaskDetailsToolbar({
size="sm" size="sm"
onClick={ onClick={
runningDevServer runningDevServer
? onStopDevServer ? stopDevServer
: onStartDevServer : startDevServer
} }
disabled={ disabled={
isStartingDevServer || !project?.dev_script isStartingDevServer || !projectHasDevScript
} }
className="gap-1" className="gap-1"
> >
@@ -787,7 +1010,7 @@ export function TaskDetailsToolbar({
align="center" align="center"
avoidCollisions={true} avoidCollisions={true}
> >
{!project?.dev_script ? ( {!projectHasDevScript ? (
<p> <p>
Configure a dev server command in project Configure a dev server command in project
settings settings
@@ -838,7 +1061,7 @@ export function TaskDetailsToolbar({
{taskAttempts.map((attempt) => ( {taskAttempts.map((attempt) => (
<DropdownMenuItem <DropdownMenuItem
key={attempt.id} key={attempt.id}
onClick={() => onAttemptChange(attempt.id)} onClick={() => handleAttemptChange(attempt)}
className={ className={
selectedAttempt?.id === attempt.id selectedAttempt?.id === attempt.id
? 'bg-accent' ? 'bg-accent'
@@ -928,7 +1151,7 @@ export function TaskDetailsToolbar({
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={onStopAllExecutions} onClick={stopAllExecutions}
disabled={isStopping} disabled={isStopping}
className="gap-2" className="gap-2"
> >

View File

@@ -1,81 +1,141 @@
import { Send, AlertCircle } from 'lucide-react'; import { AlertCircle, Send } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { useContext, useMemo, useState } from 'react';
import { makeRequest } from '@/lib/api.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface TaskFollowUpSectionProps { export function TaskFollowUpSection() {
followUpMessage: string; const {
setFollowUpMessage: (message: string) => void; task,
isSendingFollowUp: boolean; projectId,
followUpError: string | null; selectedAttempt,
setFollowUpError: (error: string | null) => void; isAttemptRunning,
canSendFollowUp: boolean; attemptData,
projectId: string; fetchAttemptData,
onSendFollowUp: () => void; } = useContext(TaskDetailsContext);
} const [followUpMessage, setFollowUpMessage] = useState('');
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null);
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,
]);
const onSendFollowUp = async () => {
if (!task || !selectedAttempt || !followUpMessage.trim()) return;
try {
setIsSendingFollowUp(true);
setFollowUpError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.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, selectedAttempt.task_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);
}
};
export function TaskFollowUpSection({
followUpMessage,
setFollowUpMessage,
isSendingFollowUp,
followUpError,
setFollowUpError,
canSendFollowUp,
projectId,
onSendFollowUp,
}: TaskFollowUpSectionProps) {
return ( return (
<div className="border-t p-4"> selectedAttempt && (
<div className="space-y-2"> <div className="border-t p-4">
{followUpError && ( <div className="space-y-2">
<Alert variant="destructive"> {followUpError && (
<AlertCircle className="h-4 w-4" /> <Alert variant="destructive">
<AlertDescription>{followUpError}</AlertDescription> <AlertCircle className="h-4 w-4" />
</Alert> <AlertDescription>{followUpError}</AlertDescription>
)} </Alert>
<div className="flex gap-2 items-start"> )}
<FileSearchTextarea <div className="flex gap-2 items-start">
placeholder="Ask a follow-up question... Type @ to search files." <FileSearchTextarea
value={followUpMessage} placeholder="Ask a follow-up question... Type @ to search files."
onChange={(value) => { value={followUpMessage}
setFollowUpMessage(value); onChange={(value) => {
if (followUpError) setFollowUpError(null); setFollowUpMessage(value);
}} if (followUpError) setFollowUpError(null);
onKeyDown={(e) => { }}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { onKeyDown={(e) => {
e.preventDefault(); if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if ( e.preventDefault();
canSendFollowUp && if (
followUpMessage.trim() && canSendFollowUp &&
!isSendingFollowUp followUpMessage.trim() &&
) { !isSendingFollowUp
onSendFollowUp(); ) {
onSendFollowUp();
}
} }
}}
className="flex-1 min-h-[40px] resize-none"
disabled={!canSendFollowUp}
projectId={projectId}
rows={1}
/>
<Button
onClick={onSendFollowUp}
disabled={
!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp
} }
}} size="sm"
className="flex-1 min-h-[40px] resize-none" >
disabled={!canSendFollowUp} {isSendingFollowUp ? (
projectId={projectId} <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
rows={1} ) : (
/> <>
<Button <Send className="h-4 w-4 mr-2" />
onClick={onSendFollowUp} Send
disabled={ </>
!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp )}
} </Button>
size="sm" </div>
>
{isSendingFollowUp ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send
</>
)}
</Button>
</div> </div>
</div> </div>
</div> )
); );
} }

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
type DragEndEvent, type DragEndEvent,
KanbanBoard,
KanbanCards,
KanbanHeader,
KanbanProvider,
} from '@/components/ui/shadcn-io/kanban'; } from '@/components/ui/shadcn-io/kanban';
import { TaskCard } from './TaskCard'; import { TaskCard } from './TaskCard';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'; import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
@@ -51,46 +52,39 @@ export function TaskKanbanBoard({
onDeleteTask, onDeleteTask,
onViewTaskDetails, onViewTaskDetails,
}: TaskKanbanBoardProps) { }: TaskKanbanBoardProps) {
const filterTasks = (tasks: Task[]) => { // Memoize filtered tasks
const filteredTasks = useMemo(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return tasks; return tasks;
} }
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return tasks.filter( return tasks.filter(
(task) => (task) =>
task.title.toLowerCase().includes(query) || task.title.toLowerCase().includes(query) ||
(task.description && task.description.toLowerCase().includes(query)) (task.description && task.description.toLowerCase().includes(query))
); );
}; }, [tasks, searchQuery]);
const groupTasksByStatus = () => { // Memoize grouped tasks
const groupedTasks = useMemo(() => {
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>; const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>;
// Initialize groups for all possible statuses
allTaskStatuses.forEach((status) => { allTaskStatuses.forEach((status) => {
groups[status] = []; groups[status] = [];
}); });
const filteredTasks = filterTasks(tasks);
filteredTasks.forEach((task) => { filteredTasks.forEach((task) => {
// Convert old capitalized status to lowercase if needed
const normalizedStatus = task.status.toLowerCase() as TaskStatus; const normalizedStatus = task.status.toLowerCase() as TaskStatus;
if (groups[normalizedStatus]) { if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task); groups[normalizedStatus].push(task);
} else { } else {
// Default to todo if status doesn't match any expected value
groups['todo'].push(task); groups['todo'].push(task);
} }
}); });
return groups; return groups;
}; }, [filteredTasks]);
return ( return (
<KanbanProvider onDragEnd={onDragEnd}> <KanbanProvider onDragEnd={onDragEnd}>
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => ( {Object.entries(groupedTasks).map(([status, statusTasks]) => (
<KanbanBoard key={status} id={status as TaskStatus}> <KanbanBoard key={status} id={status as TaskStatus}>
<KanbanHeader <KanbanHeader
name={statusLabels[status as TaskStatus]} name={statusLabels[status as TaskStatus]}

View File

@@ -1,596 +0,0 @@
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,
GitBranch,
TaskAttemptState,
} from 'shared/types';
export function useTaskDetails(
task: TaskWithAttemptStatus | null,
projectId: string,
isOpen: boolean
) {
const { config } = useConfig();
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([]);
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(
null
);
const [attemptData, setAttemptData] = useState<{
activities: TaskAttemptActivityWithPrompt[];
processes: ExecutionProcessSummary[];
runningProcessDetails: Record<string, ExecutionProcess>;
}>({
activities: [],
processes: [],
runningProcessDetails: {},
});
const [loading, setLoading] = useState(false);
const [selectedExecutor, setSelectedExecutor] = useState<string>(
config?.executor.type || 'claude'
);
const [isStopping, setIsStopping] = useState(false);
const [followUpMessage, setFollowUpMessage] = useState('');
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
const [executionState, setExecutionState] = useState<TaskAttemptState | null>(
null
);
// 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 || isStopping) {
return false;
}
return attemptData.processes.some(
(process) =>
(process.process_type === 'codingagent' ||
process.process_type === 'setupscript') &&
process.status === 'running'
);
}, [selectedAttempt, attemptData.processes, 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]);
// Define callbacks first
const fetchAttemptData = useCallback(
async (attemptId: string) => {
if (!task) return;
// Find the attempt to get the task_id
const attempt = taskAttempts.find((a) => a.id === attemptId);
const taskId = attempt?.task_id || task.id;
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/execution-processes`
),
]);
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivityWithPrompt[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
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<string, ExecutionProcess> = {};
// Fetch details for running activities
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<ExecutionProcess> =
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
);
}
}
// Also fetch setup script process details if it exists in the processes
const setupProcess = processesResult.data.find(
(process) => process.process_type === 'setupscript'
);
if (setupProcess && !runningProcessDetails[setupProcess.id]) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${setupProcess.id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[setupProcess.id] = detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch setup process details ${setupProcess.id}:`,
err
);
}
}
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
}
}
} catch (err) {
console.error('Failed to fetch attempt data:', err);
}
},
[task, projectId]
);
const fetchExecutionState = useCallback(
async (attemptId: string) => {
if (!task) return;
// Find the attempt to get the task_id
const attempt = taskAttempts.find((a) => a.id === attemptId);
const taskId = attempt?.task_id || task.id;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}`
);
if (response.ok) {
const result: ApiResponse<TaskAttemptState> = await response.json();
if (result.success && result.data) {
setExecutionState(result.data);
}
}
} catch (err) {
console.error('Failed to fetch execution state:', err);
}
},
[task, projectId]
);
const fetchTaskAttempts = useCallback(async () => {
if (!task) return;
try {
setLoading(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`
);
if (response.ok) {
const result: ApiResponse<TaskAttempt[]> = 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);
fetchExecutionState(latestAttempt.id);
} else {
setSelectedAttempt(null);
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
}
}
}
} catch (err) {
console.error('Failed to fetch task attempts:', err);
} finally {
setLoading(false);
}
}, [task, projectId, fetchAttemptData, fetchExecutionState]);
// 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<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
}, [runningDevServer, task, selectedAttempt, projectId]);
// Fetch project branches
const fetchProjectBranches = useCallback(async () => {
try {
const response = await makeRequest(`/api/projects/${projectId}/branches`);
if (response.ok) {
const result: ApiResponse<GitBranch[]> = await response.json();
if (result.success && result.data) {
setBranches(result.data);
// Set current branch as default
const currentBranch = result.data.find((b) => b.is_current);
if (currentBranch && !selectedBranch) {
setSelectedBranch(currentBranch.name);
}
}
}
} catch (err) {
console.error('Failed to fetch project branches:', err);
}
}, [projectId, selectedBranch]);
// Set default executor from config
useEffect(() => {
if (config && config.executor.type !== selectedExecutor) {
setSelectedExecutor(config.executor.type);
}
}, [config, selectedExecutor]);
useEffect(() => {
if (task && isOpen) {
fetchTaskAttempts();
fetchProjectBranches();
}
}, [task, isOpen, fetchTaskAttempts, fetchProjectBranches]);
// Load attempt data when selectedAttempt changes
useEffect(() => {
if (selectedAttempt && task) {
fetchAttemptData(selectedAttempt.id);
fetchExecutionState(selectedAttempt.id);
}
}, [selectedAttempt, task, fetchAttemptData, fetchExecutionState]);
// Polling for updates when attempt is running
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id);
fetchExecutionState(selectedAttempt.id);
}
}, 2000);
return () => clearInterval(interval);
}, [
isAttemptRunning,
task,
selectedAttempt,
fetchAttemptData,
fetchExecutionState,
]);
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
const handleAttemptChange = (attemptId: string) => {
const attempt = taskAttempts.find((a) => a.id === attemptId);
if (attempt) {
setSelectedAttempt(attempt);
fetchAttemptData(attempt.id);
fetchExecutionState(attempt.id);
}
};
const createNewAttempt = async (executor?: string, baseBranch?: 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,
base_branch: baseBranch || selectedBranch,
}),
}
);
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/${selectedAttempt.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/${selectedAttempt.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<null> = 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/${selectedAttempt.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/${selectedAttempt.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/${selectedAttempt.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,
branches,
selectedBranch,
executionState,
// Computed
runningDevServer,
isAttemptRunning,
canSendFollowUp,
processedDevServerLogs,
// Actions
setSelectedExecutor,
setFollowUpMessage,
setFollowUpError,
setIsHoveringDevServer,
setSelectedBranch,
handleAttemptChange,
createNewAttempt,
stopAllExecutions,
startDevServer,
stopDevServer,
openInEditor,
handleSendFollowUp,
};
}

View File

@@ -1,26 +1,26 @@
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Plus, Settings, FolderOpen } from 'lucide-react'; import { FolderOpen, Plus, Settings } from 'lucide-react';
import { makeRequest } from '@/lib/api'; import { makeRequest } from '@/lib/api';
import { TaskFormDialog } from '@/components/tasks/TaskFormDialog'; import { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
import { ProjectForm } from '@/components/projects/project-form'; import { ProjectForm } from '@/components/projects/project-form';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts'; import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
import { import {
getMainContainerClasses,
getKanbanSectionClasses, getKanbanSectionClasses,
getMainContainerClasses,
} from '@/lib/responsive-config'; } from '@/lib/responsive-config';
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard'; import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel'; import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
import type { import type {
CreateTaskAndStart,
ExecutorConfig,
ProjectWithBranch,
TaskStatus, TaskStatus,
TaskWithAttemptStatus, TaskWithAttemptStatus,
ProjectWithBranch,
ExecutorConfig,
CreateTaskAndStart,
} from 'shared/types'; } from 'shared/types';
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban'; import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
@@ -459,7 +459,7 @@ export function ProjectTasks() {
{isPanelOpen && ( {isPanelOpen && (
<TaskDetailsPanel <TaskDetailsPanel
task={selectedTask} task={selectedTask}
project={project} projectHasDevScript={!!project?.dev_script}
projectId={projectId!} projectId={projectId!}
isOpen={isPanelOpen} isOpen={isPanelOpen}
onClose={handleClosePanel} onClose={handleClosePanel}

View File

@@ -4,35 +4,89 @@
export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, }; export type ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, }; export type Config = {
theme: ThemeMode,
executor: ExecutorConfig,
disclaimer_acknowledged: boolean,
onboarding_acknowledged: boolean,
sound_alerts: boolean,
sound_file: SoundFile,
push_notifications: boolean,
editor: EditorConfig,
github: GitHubConfig,
analytics_enabled: boolean | null,
};
export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red"; export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";
export type EditorConfig = { editor_type: EditorType, custom_command: string | null, }; export type EditorConfig = { editor_type: EditorType, custom_command: string | null, };
export type GitHubConfig = { pat: string | null, token: string | null, username: string | null, primary_email: string | null, default_pr_base: string | null, }; export type GitHubConfig = {
pat: string | null,
token: string | null,
username: string | null,
primary_email: string | null,
default_pr_base: string | null,
};
export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom"; export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom";
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, }; export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };
export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster"; export type SoundFile =
"abstract-sound1"
| "abstract-sound2"
| "abstract-sound3"
| "abstract-sound4"
| "cow-mooing"
| "phone-vibration"
| "rooster";
export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Array<string>, }; export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Array<string>, };
export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, }; export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | { "type": "opencode" }; export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | {
"type": "opencode"
};
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, }; export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, dev_script: string | null, }; export type CreateProject = {
name: string,
git_repo_path: string,
use_existing_repo: boolean,
setup_script: string | null,
dev_script: string | null,
};
export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, created_at: Date, updated_at: Date, }; export type Project = {
id: string,
name: string,
git_repo_path: string,
setup_script: string | null,
dev_script: string | null,
created_at: Date,
updated_at: Date,
};
export type ProjectWithBranch = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, current_branch: string | null, created_at: Date, updated_at: Date, }; export type ProjectWithBranch = {
id: string,
name: string,
git_repo_path: string,
setup_script: string | null,
dev_script: string | null,
current_branch: string | null,
created_at: Date,
updated_at: Date,
};
export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, dev_script: string | null, }; export type UpdateProject = {
name: string | null,
git_repo_path: string | null,
setup_script: string | null,
dev_script: string | null,
};
export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, }; export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, };
@@ -44,19 +98,64 @@ export type CreateBranch = { name: string, base_branch: string | null, };
export type CreateTask = { project_id: string, title: string, description: string | null, }; export type CreateTask = { project_id: string, title: string, description: string | null, };
export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, executor: ExecutorConfig | null, }; export type CreateTaskAndStart = {
project_id: string,
title: string,
description: string | null,
executor: ExecutorConfig | null,
};
export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled"; export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled";
export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, }; export type Task = {
id: string,
project_id: string,
title: string,
description: string | null,
status: TaskStatus,
created_at: string,
updated_at: string,
};
export type TaskWithAttemptStatus = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, has_in_progress_attempt: boolean, has_merged_attempt: boolean, has_failed_attempt: boolean, }; export type TaskWithAttemptStatus = {
id: string,
project_id: string,
title: string,
description: string | null,
status: TaskStatus,
created_at: string,
updated_at: string,
has_in_progress_attempt: boolean,
has_merged_attempt: boolean,
has_failed_attempt: boolean,
};
export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, }; export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, };
export type TaskAttemptStatus = "setuprunning" | "setupcomplete" | "setupfailed" | "executorrunning" | "executorcomplete" | "executorfailed"; export type TaskAttemptStatus =
"setuprunning"
| "setupcomplete"
| "setupfailed"
| "executorrunning"
| "executorcomplete"
| "executorfailed";
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, branch: string, base_branch: string, merge_commit: string | null, executor: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, worktree_deleted: boolean, created_at: string, updated_at: string, }; export type TaskAttempt = {
id: string,
task_id: string,
worktree_path: string,
branch: string,
base_branch: string,
merge_commit: string | null,
executor: string | null,
pr_url: string | null,
pr_number: bigint | null,
pr_status: string | null,
pr_merged_at: string | null,
worktree_deleted: boolean,
created_at: string,
updated_at: string,
};
export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, }; export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, };
@@ -64,11 +163,34 @@ export type UpdateTaskAttempt = Record<string, never>;
export type CreateFollowUpAttempt = { prompt: string, }; export type CreateFollowUpAttempt = { prompt: string, };
export type TaskAttemptActivity = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, }; export type TaskAttemptActivity = {
id: string,
execution_process_id: string,
status: TaskAttemptStatus,
note: string | null,
created_at: string,
};
export type TaskAttemptActivityWithPrompt = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, prompt: string | null, }; export type TaskAttemptActivityWithPrompt = {
id: string,
execution_process_id: string,
status: TaskAttemptStatus,
note: string | null,
created_at: string,
prompt: string | null,
};
export type CreateTaskAttemptActivity = { execution_process_id: string, status: TaskAttemptStatus | null, note: string | null, }; export type AttemptData = {
activities: TaskAttemptActivityWithPrompt[];
processes: ExecutionProcessSummary[];
runningProcessDetails: Record<string, ExecutionProcess>;
}
export type CreateTaskAttemptActivity = {
execution_process_id: string,
status: TaskAttemptStatus | null,
note: string | null,
};
export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, }; export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, };
@@ -80,37 +202,125 @@ export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
export type WorktreeDiff = { files: Array<FileDiff>, }; export type WorktreeDiff = { files: Array<FileDiff>, };
export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, merged: boolean, has_uncommitted_changes: boolean, base_branch_name: string, }; export type BranchStatus = {
is_behind: boolean,
commits_behind: number,
commits_ahead: number,
up_to_date: boolean,
merged: boolean,
has_uncommitted_changes: boolean,
base_branch_name: string,
};
export type ExecutionState = "NotStarted" | "SetupRunning" | "SetupComplete" | "SetupFailed" | "CodingAgentRunning" | "CodingAgentComplete" | "CodingAgentFailed" | "Complete"; export type ExecutionState =
"NotStarted"
| "SetupRunning"
| "SetupComplete"
| "SetupFailed"
| "CodingAgentRunning"
| "CodingAgentComplete"
| "CodingAgentFailed"
| "Complete";
export type TaskAttemptState = { execution_state: ExecutionState, has_changes: boolean, has_setup_script: boolean, setup_process_id: string | null, coding_agent_process_id: string | null, }; export type TaskAttemptState = {
execution_state: ExecutionState,
has_changes: boolean,
has_setup_script: boolean,
setup_process_id: string | null,
coding_agent_process_id: string | null,
};
export type ExecutionProcess = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, stdout: string | null, stderr: string | null, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, }; export type ExecutionProcess = {
id: string,
task_attempt_id: string,
process_type: ExecutionProcessType,
executor_type: string | null,
status: ExecutionProcessStatus,
command: string,
args: string | null,
working_directory: string,
stdout: string | null,
stderr: string | null,
exit_code: bigint | null,
started_at: string,
completed_at: string | null,
created_at: string,
updated_at: string,
};
export type ExecutionProcessSummary = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, }; export type ExecutionProcessSummary = {
id: string,
task_attempt_id: string,
process_type: ExecutionProcessType,
executor_type: string | null,
status: ExecutionProcessStatus,
command: string,
args: string | null,
working_directory: string,
exit_code: bigint | null,
started_at: string,
completed_at: string | null,
created_at: string,
updated_at: string,
};
export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed"; export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed";
export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver"; export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver";
export type CreateExecutionProcess = { task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, command: string, args: string | null, working_directory: string, }; export type CreateExecutionProcess = {
task_attempt_id: string,
process_type: ExecutionProcessType,
executor_type: string | null,
command: string,
args: string | null,
working_directory: string,
};
export type UpdateExecutionProcess = { status: ExecutionProcessStatus | null, exit_code: bigint | null, completed_at: string | null, }; export type UpdateExecutionProcess = {
status: ExecutionProcessStatus | null,
exit_code: bigint | null,
completed_at: string | null,
};
export type ExecutorSession = { id: string, task_attempt_id: string, execution_process_id: string, session_id: string | null, prompt: string | null, summary: string | null, created_at: string, updated_at: string, }; export type ExecutorSession = {
id: string,
task_attempt_id: string,
execution_process_id: string,
session_id: string | null,
prompt: string | null,
summary: string | null,
created_at: string,
updated_at: string,
};
export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, }; export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, };
export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, }; export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, };
export type NormalizedConversation = { entries: Array<NormalizedEntry>, session_id: string | null, executor_type: string, prompt: string | null, summary: string | null, }; export type NormalizedConversation = {
entries: Array<NormalizedEntry>,
session_id: string | null,
executor_type: string,
prompt: string | null,
summary: string | null,
};
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, }; export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" }; export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | {
"type": "tool_use",
tool_name: string,
action_type: ActionType,
} | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" };
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "other", description: string, }; export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | {
"action": "command_run",
command: string,
} | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | {
"action": "task_create",
description: string,
} | { "action": "other", description: string, };
// Generated constants // Generated constants
export const EXECUTOR_TYPES: string[] = [ export const EXECUTOR_TYPES: string[] = [