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) {
return (
<div className="space-y-4">
<div className="space-y-4 py-12 px-4">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
@@ -124,7 +124,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
}
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 items-center space-x-4">
<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 {
Dialog,
@@ -16,11 +16,11 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { EditorType } from 'shared/types';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface EditorSelectionDialogProps {
isOpen: boolean;
onClose: () => void;
onSelectEditor: (editorType: EditorType) => void;
}
const editorOptions: {
@@ -63,12 +63,12 @@ const editorOptions: {
export function EditorSelectionDialog({
isOpen,
onClose,
onSelectEditor,
}: EditorSelectionDialogProps) {
const { handleOpenInEditor } = useContext(TaskDetailsContext);
const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode');
const handleConfirm = () => {
onSelectEditor(selectedEditor);
handleOpenInEditor(selectedEditor);
onClose();
};

View File

@@ -1,21 +1,20 @@
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 { Label } from '@/components/ui/label';
import { Chip } from '@/components/ui/chip';
import { NormalizedConversationViewer } from './NormalizedConversationViewer';
import { NormalizedConversationViewer } from './TaskDetails/NormalizedConversationViewer.tsx';
import type {
ExecutionProcess,
TaskAttempt,
TaskAttemptActivityWithPrompt,
TaskAttemptStatus,
ExecutionProcess,
} from 'shared/types';
interface TaskActivityHistoryProps {
selectedAttempt: TaskAttempt | null;
activities: TaskAttemptActivityWithPrompt[];
runningProcessDetails: Record<string, ExecutionProcess>;
projectId: string;
}
const getAttemptStatusDisplay = (
@@ -64,7 +63,6 @@ export function TaskActivityHistory({
selectedAttempt,
activities,
runningProcessDetails,
projectId,
}: TaskActivityHistoryProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
new Set()
@@ -169,7 +167,6 @@ export function TaskActivityHistory({
executionProcess={
runningProcessDetails[activity.execution_process_id]
}
projectId={projectId}
/>
</div>
<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 { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp, Trash2, GitCompare } from 'lucide-react';
import type { WorktreeDiff, DiffChunkType, DiffChunk } from 'shared/types';
import { useCallback, useContext, useState } from 'react';
import { Button } from '@/components/ui/button.tsx';
import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react';
import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface ProcessedLine {
content: string;
@@ -20,26 +21,31 @@ interface ProcessedSection {
interface DiffCardProps {
diff: WorktreeDiff | null;
isBackgroundRefreshing?: boolean;
onDeleteFile?: (filePath: string) => void;
deletingFiles?: Set<string>;
deletable?: boolean;
compact?: boolean;
className?: string;
}
export function DiffCard({
diff,
isBackgroundRefreshing = false,
onDeleteFile,
deletingFiles = new Set(),
deletable = false,
compact = false,
className = '',
}: DiffCardProps) {
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } =
useContext(TaskDetailsContext);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set()
);
const onDeleteFile = useCallback(
(filePath: string) => {
setFileToDelete(filePath);
},
[setFileToDelete]
);
// Diff processing functions
const getChunkClassName = (chunkType: DiffChunkType) => {
const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
@@ -361,7 +367,7 @@ export function DiffCard({
</div>
)}
</div>
{onDeleteFile && (
{deletable && (
<Button
variant="ghost"
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 {
User,
Bot,
Eye,
Edit,
Terminal,
Search,
Globe,
Plus,
Settings,
Brain,
Hammer,
AlertCircle,
Bot,
Brain,
CheckSquare,
ChevronRight,
ChevronUp,
Edit,
Eye,
Globe,
Hammer,
Plus,
Search,
Settings,
Terminal,
ToggleLeft,
ToggleRight,
CheckSquare,
User,
} from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
import { DiffCard } from './DiffCard';
import { makeRequest } from '@/lib/api.ts';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
import { DiffCard } from './DiffCard.tsx';
import type {
ApiResponse,
ExecutionProcess,
NormalizedConversation,
NormalizedEntry,
NormalizedEntryType,
ExecutionProcess,
ApiResponse,
WorktreeDiff,
} from 'shared/types';
} from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface NormalizedConversationViewerProps {
executionProcess: ExecutionProcess;
projectId: string;
onConversationUpdate?: () => void;
diff?: WorktreeDiff | null;
isBackgroundRefreshing?: boolean;
onDeleteFile?: (filePath: string) => void;
deletingFiles?: Set<string>;
diffDeletable?: boolean;
}
const getEntryIcon = (entryType: NormalizedEntryType) => {
@@ -356,13 +355,10 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
export function NormalizedConversationViewer({
executionProcess,
projectId,
diffDeletable,
onConversationUpdate,
diff,
isBackgroundRefreshing = false,
onDeleteFile,
deletingFiles = new Set(),
}: NormalizedConversationViewerProps) {
const { projectId, diff } = useContext(TaskDetailsContext);
const [conversation, setConversation] =
useState<NormalizedConversation | null>(null);
const [loading, setLoading] = useState(true);
@@ -642,9 +638,7 @@ export function NormalizedConversationViewer({
<div className="mt-4 mb-2">
<DiffCard
diff={incrementalDiff}
isBackgroundRefreshing={isBackgroundRefreshing}
onDeleteFile={onDeleteFile}
deletingFiles={deletingFiles}
deletable={diffDeletable}
compact={true}
/>
</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 { Edit, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react';
import { useContext, useState } from 'react';
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Chip } from '@/components/ui/chip';
import {
@@ -9,9 +9,9 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface TaskDetailsHeaderProps {
task: TaskWithAttemptStatus;
onClose: () => void;
onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void;
@@ -43,11 +43,11 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
};
export function TaskDetailsHeader({
task,
onClose,
onEditTask,
onDeleteTask,
}: TaskDetailsHeaderProps) {
const { task } = useContext(TaskDetailsContext);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
return (

View File

@@ -1,41 +1,22 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useState } from 'react';
import { TaskDetailsHeader } from './TaskDetailsHeader';
import { TaskDetailsToolbar } from './TaskDetailsToolbar';
import { NormalizedConversationViewer } from './NormalizedConversationViewer';
import { TaskFollowUpSection } from './TaskFollowUpSection';
import { EditorSelectionDialog } from './EditorSelectionDialog';
import { useTaskDetails } from '@/hooks/useTaskDetails';
import {
getTaskPanelClasses,
getBackdropClasses,
getTaskPanelClasses,
} from '@/lib/responsive-config';
import { makeRequest } from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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';
import type { TaskWithAttemptStatus } from 'shared/types';
import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx';
import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx';
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
project: Project | null;
projectHasDevScript?: boolean;
projectId: string;
isOpen: boolean;
onClose: () => void;
@@ -44,15 +25,9 @@ interface TaskDetailsPanelProps {
isDialogOpen?: boolean;
}
interface ApiResponse<T> {
success: boolean;
data: T | null;
message: string | null;
}
export function TaskDetailsPanel({
task,
project,
projectHasDevScript,
projectId,
isOpen,
onClose,
@@ -61,177 +36,19 @@ export function TaskDetailsPanel({
isDialogOpen = false,
}: TaskDetailsPanelProps) {
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
const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs');
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(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
useEffect(() => {
if (task) {
if (task?.id) {
setActiveTab('logs');
setUserSelectedTab(true); // Treat this as a user selection to prevent auto-switching
}
}, [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
useEffect(() => {
if (!isOpen || isDialogOpen) return;
@@ -248,638 +65,56 @@ export function TaskDetailsPanel({
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [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 (
<>
{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) */}
<div className={getBackdropClasses()} onClick={onClose} />
{/* Panel */}
<div className={getTaskPanelClasses()}>
<div className="flex flex-col h-full">
{/* Header */}
<TaskDetailsHeader
task={task}
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
/>
{/* Collapsible Toolbar */}
<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>
<CollapsibleToolbar projectHasDevScript={projectHasDevScript} />
{/* Tab Navigation */}
<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>
<TabNavigation
activeTab={activeTab}
setActiveTab={setActiveTab}
setUserSelectedTab={setUserSelectedTab}
/>
{/* Tab Content */}
<div
className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`}
>
{renderTabContent()}
{activeTab === 'diffs' ? <DiffTab /> : <LogsTab />}
</div>
{/* Footer - Follow-up section */}
{selectedAttempt && (
<TaskFollowUpSection
followUpMessage={followUpMessage}
setFollowUpMessage={setFollowUpMessage}
isSendingFollowUp={isSendingFollowUp}
followUpError={followUpError}
setFollowUpError={setFollowUpError}
canSendFollowUp={canSendFollowUp}
projectId={projectId}
onSendFollowUp={handleSendFollowUp}
/>
)}
<TaskFollowUpSection />
</div>
</div>
{/* Editor Selection Dialog */}
<EditorSelectionDialog
isOpen={showEditorDialog}
onClose={() => setShowEditorDialog(false)}
onSelectEditor={handleOpenInEditor}
/>
{/* Delete File Confirmation Dialog */}
<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>
</>
<DeleteFileConfirmationDialog />
</TaskDetailsProvider>
)}
</>
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
ArrowDown,
ExternalLink,
@@ -50,13 +50,11 @@ import { makeRequest } from '@/lib/api';
import type {
BranchStatus,
ExecutionProcess,
ExecutionProcessSummary,
GitBranch,
Project,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import { ProvidePatDialog } from '@/components/ProvidePatDialog';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
interface ApiResponse<T> {
success: boolean;
@@ -65,27 +63,7 @@ interface ApiResponse<T> {
}
interface TaskDetailsToolbarProps {
task: TaskWithAttemptStatus;
project: Project | null;
projectId: string;
selectedAttempt: TaskAttempt | null;
taskAttempts: TaskAttempt[];
isAttemptRunning: boolean;
isStopping: boolean;
selectedExecutor: string;
runningDevServer: ExecutionProcessSummary | undefined;
isStartingDevServer: boolean;
devServerDetails: ExecutionProcess | null;
processedDevServerLogs: string;
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;
projectHasDevScript?: boolean;
}
const availableExecutors = [
@@ -97,31 +75,35 @@ const availableExecutors = [
];
export function TaskDetailsToolbar({
task,
project,
projectId,
selectedAttempt,
taskAttempts,
isAttemptRunning,
isStopping,
selectedExecutor,
runningDevServer,
isStartingDevServer,
devServerDetails,
processedDevServerLogs,
branches,
selectedBranch,
onAttemptChange,
onCreateNewAttempt,
onStopAllExecutions,
onStartDevServer,
onStopDevServer,
onOpenInEditor,
onSetIsHoveringDevServer,
projectHasDevScript,
}: 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 [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
const [isInCreateAttemptMode, setIsInCreateAttemptMode] = useState(false);
const [createAttemptBranch, setCreateAttemptBranch] = useState<string | null>(
@@ -146,6 +128,88 @@ export function TaskDetailsToolbar({
const [showPatDialog, setShowPatDialog] = useState(false);
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
useEffect(() => {
setIsInCreateAttemptMode(taskAttempts.length === 0);
@@ -185,6 +249,165 @@ export function TaskDetailsToolbar({
}
}, [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
const fetchBranchStatus = useCallback(async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
@@ -722,7 +945,7 @@ export function TaskDetailsToolbar({
<Button
variant="ghost"
size="sm"
onClick={() => onOpenInEditor()}
onClick={() => handleOpenInEditor()}
className="h-4 w-4 p-0 hover:bg-muted"
>
<ExternalLink className="h-3 w-3" />
@@ -743,10 +966,10 @@ export function TaskDetailsToolbar({
<div className="flex items-center gap-2 flex-wrap">
<div
className={
!project?.dev_script ? 'cursor-not-allowed' : ''
!projectHasDevScript ? 'cursor-not-allowed' : ''
}
onMouseEnter={() => onSetIsHoveringDevServer(true)}
onMouseLeave={() => onSetIsHoveringDevServer(false)}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<TooltipProvider>
<Tooltip>
@@ -758,11 +981,11 @@ export function TaskDetailsToolbar({
size="sm"
onClick={
runningDevServer
? onStopDevServer
: onStartDevServer
? stopDevServer
: startDevServer
}
disabled={
isStartingDevServer || !project?.dev_script
isStartingDevServer || !projectHasDevScript
}
className="gap-1"
>
@@ -787,7 +1010,7 @@ export function TaskDetailsToolbar({
align="center"
avoidCollisions={true}
>
{!project?.dev_script ? (
{!projectHasDevScript ? (
<p>
Configure a dev server command in project
settings
@@ -838,7 +1061,7 @@ export function TaskDetailsToolbar({
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => onAttemptChange(attempt.id)}
onClick={() => handleAttemptChange(attempt)}
className={
selectedAttempt?.id === attempt.id
? 'bg-accent'
@@ -928,7 +1151,7 @@ export function TaskDetailsToolbar({
<Button
variant="destructive"
size="sm"
onClick={onStopAllExecutions}
onClick={stopAllExecutions}
disabled={isStopping}
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 { Alert, AlertDescription } from '@/components/ui/alert';
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 {
followUpMessage: string;
setFollowUpMessage: (message: string) => void;
isSendingFollowUp: boolean;
followUpError: string | null;
setFollowUpError: (error: string | null) => void;
canSendFollowUp: boolean;
projectId: string;
onSendFollowUp: () => void;
}
export function TaskFollowUpSection() {
const {
task,
projectId,
selectedAttempt,
isAttemptRunning,
attemptData,
fetchAttemptData,
} = 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 (
<div className="border-t p-4">
<div className="space-y-2">
{followUpError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{followUpError}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 items-start">
<FileSearchTextarea
placeholder="Ask a follow-up question... Type @ to search files."
value={followUpMessage}
onChange={(value) => {
setFollowUpMessage(value);
if (followUpError) setFollowUpError(null);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (
canSendFollowUp &&
followUpMessage.trim() &&
!isSendingFollowUp
) {
onSendFollowUp();
selectedAttempt && (
<div className="border-t p-4">
<div className="space-y-2">
{followUpError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{followUpError}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 items-start">
<FileSearchTextarea
placeholder="Ask a follow-up question... Type @ to search files."
value={followUpMessage}
onChange={(value) => {
setFollowUpMessage(value);
if (followUpError) setFollowUpError(null);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (
canSendFollowUp &&
followUpMessage.trim() &&
!isSendingFollowUp
) {
onSendFollowUp();
}
}
}}
className="flex-1 min-h-[40px] resize-none"
disabled={!canSendFollowUp}
projectId={projectId}
rows={1}
/>
<Button
onClick={onSendFollowUp}
disabled={
!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp
}
}}
className="flex-1 min-h-[40px] resize-none"
disabled={!canSendFollowUp}
projectId={projectId}
rows={1}
/>
<Button
onClick={onSendFollowUp}
disabled={
!canSendFollowUp || !followUpMessage.trim() || isSendingFollowUp
}
size="sm"
>
{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>
size="sm"
>
{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>
)
);
}

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react';
import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
type DragEndEvent,
KanbanBoard,
KanbanCards,
KanbanHeader,
KanbanProvider,
} from '@/components/ui/shadcn-io/kanban';
import { TaskCard } from './TaskCard';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
@@ -51,46 +52,39 @@ export function TaskKanbanBoard({
onDeleteTask,
onViewTaskDetails,
}: TaskKanbanBoardProps) {
const filterTasks = (tasks: Task[]) => {
// Memoize filtered tasks
const filteredTasks = useMemo(() => {
if (!searchQuery.trim()) {
return tasks;
}
const query = searchQuery.toLowerCase();
return tasks.filter(
(task) =>
task.title.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[]>;
// Initialize groups for all possible statuses
allTaskStatuses.forEach((status) => {
groups[status] = [];
});
const filteredTasks = filterTasks(tasks);
filteredTasks.forEach((task) => {
// Convert old capitalized status to lowercase if needed
const normalizedStatus = task.status.toLowerCase() as TaskStatus;
if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task);
} else {
// Default to todo if status doesn't match any expected value
groups['todo'].push(task);
}
});
return groups;
};
}, [filteredTasks]);
return (
<KanbanProvider onDragEnd={onDragEnd}>
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
{Object.entries(groupedTasks).map(([status, statusTasks]) => (
<KanbanBoard key={status} id={status as TaskStatus}>
<KanbanHeader
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 { useParams, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
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 { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
import { ProjectForm } from '@/components/projects/project-form';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
import {
getMainContainerClasses,
getKanbanSectionClasses,
getMainContainerClasses,
} from '@/lib/responsive-config';
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
import type {
CreateTaskAndStart,
ExecutorConfig,
ProjectWithBranch,
TaskStatus,
TaskWithAttemptStatus,
ProjectWithBranch,
ExecutorConfig,
CreateTaskAndStart,
} from 'shared/types';
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
@@ -459,7 +459,7 @@ export function ProjectTasks() {
{isPanelOpen && (
<TaskDetailsPanel
task={selectedTask}
project={project}
projectHasDevScript={!!project?.dev_script}
projectId={projectId!}
isOpen={isPanelOpen}
onClose={handleClosePanel}