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:
425
frontend/src/components/context/TaskDetailsContextProvider.tsx
Normal file
425
frontend/src/components/context/TaskDetailsContextProvider.tsx
Normal 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;
|
||||
46
frontend/src/components/context/taskDetailsContext.ts
Normal file
46
frontend/src/components/context/taskDetailsContext.ts
Normal 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
|
||||
);
|
||||
@@ -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}>
|
||||
|
||||
105
frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx
Normal file
105
frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx
Normal 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;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
32
frontend/src/components/tasks/TaskDetails/DiffTab.tsx
Normal file
32
frontend/src/components/tasks/TaskDetails/DiffTab.tsx
Normal 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;
|
||||
330
frontend/src/components/tasks/TaskDetails/LogsTab.tsx
Normal file
330
frontend/src/components/tasks/TaskDetails/LogsTab.tsx
Normal 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;
|
||||
@@ -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>
|
||||
56
frontend/src/components/tasks/TaskDetails/TabNavigation.tsx
Normal file
56
frontend/src/components/tasks/TaskDetails/TabNavigation.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
264
shared/types.ts
264
shared/types.ts
@@ -4,35 +4,89 @@
|
||||
|
||||
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 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 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 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 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, };
|
||||
|
||||
@@ -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 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 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 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, };
|
||||
|
||||
@@ -64,11 +163,34 @@ export type UpdateTaskAttempt = Record<string, never>;
|
||||
|
||||
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, };
|
||||
|
||||
@@ -80,37 +202,125 @@ export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
|
||||
|
||||
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 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 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 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
|
||||
export const EXECUTOR_TYPES: string[] = [
|
||||
|
||||
Reference in New Issue
Block a user