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) {
|
if (error || !project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 py-12 px-4">
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Projects
|
Back to Projects
|
||||||
@@ -124,7 +124,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 py-12 px-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
|||||||
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { EditorType } from 'shared/types';
|
import type { EditorType } from 'shared/types';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface EditorSelectionDialogProps {
|
interface EditorSelectionDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectEditor: (editorType: EditorType) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorOptions: {
|
const editorOptions: {
|
||||||
@@ -63,12 +63,12 @@ const editorOptions: {
|
|||||||
export function EditorSelectionDialog({
|
export function EditorSelectionDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSelectEditor,
|
|
||||||
}: EditorSelectionDialogProps) {
|
}: EditorSelectionDialogProps) {
|
||||||
|
const { handleOpenInEditor } = useContext(TaskDetailsContext);
|
||||||
const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode');
|
const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode');
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
onSelectEditor(selectedEditor);
|
handleOpenInEditor(selectedEditor);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Clock, ChevronDown, ChevronUp, Code } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Clock, Code } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Chip } from '@/components/ui/chip';
|
import { Chip } from '@/components/ui/chip';
|
||||||
import { NormalizedConversationViewer } from './NormalizedConversationViewer';
|
import { NormalizedConversationViewer } from './TaskDetails/NormalizedConversationViewer.tsx';
|
||||||
import type {
|
import type {
|
||||||
|
ExecutionProcess,
|
||||||
TaskAttempt,
|
TaskAttempt,
|
||||||
TaskAttemptActivityWithPrompt,
|
TaskAttemptActivityWithPrompt,
|
||||||
TaskAttemptStatus,
|
TaskAttemptStatus,
|
||||||
ExecutionProcess,
|
|
||||||
} from 'shared/types';
|
} from 'shared/types';
|
||||||
|
|
||||||
interface TaskActivityHistoryProps {
|
interface TaskActivityHistoryProps {
|
||||||
selectedAttempt: TaskAttempt | null;
|
selectedAttempt: TaskAttempt | null;
|
||||||
activities: TaskAttemptActivityWithPrompt[];
|
activities: TaskAttemptActivityWithPrompt[];
|
||||||
runningProcessDetails: Record<string, ExecutionProcess>;
|
runningProcessDetails: Record<string, ExecutionProcess>;
|
||||||
projectId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAttemptStatusDisplay = (
|
const getAttemptStatusDisplay = (
|
||||||
@@ -64,7 +63,6 @@ export function TaskActivityHistory({
|
|||||||
selectedAttempt,
|
selectedAttempt,
|
||||||
activities,
|
activities,
|
||||||
runningProcessDetails,
|
runningProcessDetails,
|
||||||
projectId,
|
|
||||||
}: TaskActivityHistoryProps) {
|
}: TaskActivityHistoryProps) {
|
||||||
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
|
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
|
||||||
new Set()
|
new Set()
|
||||||
@@ -169,7 +167,6 @@ export function TaskActivityHistory({
|
|||||||
executionProcess={
|
executionProcess={
|
||||||
runningProcessDetails[activity.execution_process_id]
|
runningProcessDetails[activity.execution_process_id]
|
||||||
}
|
}
|
||||||
projectId={projectId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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 { useCallback, useContext, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button.tsx';
|
||||||
import { ChevronDown, ChevronUp, Trash2, GitCompare } from 'lucide-react';
|
import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react';
|
||||||
import type { WorktreeDiff, DiffChunkType, DiffChunk } from 'shared/types';
|
import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface ProcessedLine {
|
interface ProcessedLine {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -20,26 +21,31 @@ interface ProcessedSection {
|
|||||||
|
|
||||||
interface DiffCardProps {
|
interface DiffCardProps {
|
||||||
diff: WorktreeDiff | null;
|
diff: WorktreeDiff | null;
|
||||||
isBackgroundRefreshing?: boolean;
|
deletable?: boolean;
|
||||||
onDeleteFile?: (filePath: string) => void;
|
|
||||||
deletingFiles?: Set<string>;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffCard({
|
export function DiffCard({
|
||||||
diff,
|
diff,
|
||||||
isBackgroundRefreshing = false,
|
deletable = false,
|
||||||
onDeleteFile,
|
|
||||||
deletingFiles = new Set(),
|
|
||||||
compact = false,
|
compact = false,
|
||||||
className = '',
|
className = '',
|
||||||
}: DiffCardProps) {
|
}: DiffCardProps) {
|
||||||
|
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } =
|
||||||
|
useContext(TaskDetailsContext);
|
||||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onDeleteFile = useCallback(
|
||||||
|
(filePath: string) => {
|
||||||
|
setFileToDelete(filePath);
|
||||||
|
},
|
||||||
|
[setFileToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
// Diff processing functions
|
// Diff processing functions
|
||||||
const getChunkClassName = (chunkType: DiffChunkType) => {
|
const getChunkClassName = (chunkType: DiffChunkType) => {
|
||||||
const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
|
const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
|
||||||
@@ -361,7 +367,7 @@ export function DiffCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onDeleteFile && (
|
{deletable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
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 {
|
import {
|
||||||
User,
|
|
||||||
Bot,
|
|
||||||
Eye,
|
|
||||||
Edit,
|
|
||||||
Terminal,
|
|
||||||
Search,
|
|
||||||
Globe,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
Brain,
|
|
||||||
Hammer,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
CheckSquare,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Hammer,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Terminal,
|
||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
CheckSquare,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { makeRequest } from '@/lib/api';
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer';
|
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
|
||||||
import { DiffCard } from './DiffCard';
|
import { DiffCard } from './DiffCard.tsx';
|
||||||
import type {
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
ExecutionProcess,
|
||||||
NormalizedConversation,
|
NormalizedConversation,
|
||||||
NormalizedEntry,
|
NormalizedEntry,
|
||||||
NormalizedEntryType,
|
NormalizedEntryType,
|
||||||
ExecutionProcess,
|
|
||||||
ApiResponse,
|
|
||||||
WorktreeDiff,
|
WorktreeDiff,
|
||||||
} from 'shared/types';
|
} from 'shared/types.ts';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface NormalizedConversationViewerProps {
|
interface NormalizedConversationViewerProps {
|
||||||
executionProcess: ExecutionProcess;
|
executionProcess: ExecutionProcess;
|
||||||
projectId: string;
|
|
||||||
onConversationUpdate?: () => void;
|
onConversationUpdate?: () => void;
|
||||||
diff?: WorktreeDiff | null;
|
diff?: WorktreeDiff | null;
|
||||||
isBackgroundRefreshing?: boolean;
|
isBackgroundRefreshing?: boolean;
|
||||||
onDeleteFile?: (filePath: string) => void;
|
diffDeletable?: boolean;
|
||||||
deletingFiles?: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||||
@@ -356,13 +355,10 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
|||||||
|
|
||||||
export function NormalizedConversationViewer({
|
export function NormalizedConversationViewer({
|
||||||
executionProcess,
|
executionProcess,
|
||||||
projectId,
|
diffDeletable,
|
||||||
onConversationUpdate,
|
onConversationUpdate,
|
||||||
diff,
|
|
||||||
isBackgroundRefreshing = false,
|
|
||||||
onDeleteFile,
|
|
||||||
deletingFiles = new Set(),
|
|
||||||
}: NormalizedConversationViewerProps) {
|
}: NormalizedConversationViewerProps) {
|
||||||
|
const { projectId, diff } = useContext(TaskDetailsContext);
|
||||||
const [conversation, setConversation] =
|
const [conversation, setConversation] =
|
||||||
useState<NormalizedConversation | null>(null);
|
useState<NormalizedConversation | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -642,9 +638,7 @@ export function NormalizedConversationViewer({
|
|||||||
<div className="mt-4 mb-2">
|
<div className="mt-4 mb-2">
|
||||||
<DiffCard
|
<DiffCard
|
||||||
diff={incrementalDiff}
|
diff={incrementalDiff}
|
||||||
isBackgroundRefreshing={isBackgroundRefreshing}
|
deletable={diffDeletable}
|
||||||
onDeleteFile={onDeleteFile}
|
|
||||||
deletingFiles={deletingFiles}
|
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
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 { useContext, useState } from 'react';
|
||||||
import { Edit, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Chip } from '@/components/ui/chip';
|
import { Chip } from '@/components/ui/chip';
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
|
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface TaskDetailsHeaderProps {
|
interface TaskDetailsHeaderProps {
|
||||||
task: TaskWithAttemptStatus;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
@@ -43,11 +43,11 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TaskDetailsHeader({
|
export function TaskDetailsHeader({
|
||||||
task,
|
|
||||||
onClose,
|
onClose,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: TaskDetailsHeaderProps) {
|
}: TaskDetailsHeaderProps) {
|
||||||
|
const { task } = useContext(TaskDetailsContext);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,41 +1,22 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { TaskDetailsHeader } from './TaskDetailsHeader';
|
import { TaskDetailsHeader } from './TaskDetailsHeader';
|
||||||
import { TaskDetailsToolbar } from './TaskDetailsToolbar';
|
|
||||||
import { NormalizedConversationViewer } from './NormalizedConversationViewer';
|
|
||||||
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
||||||
import { EditorSelectionDialog } from './EditorSelectionDialog';
|
import { EditorSelectionDialog } from './EditorSelectionDialog';
|
||||||
import { useTaskDetails } from '@/hooks/useTaskDetails';
|
|
||||||
import {
|
import {
|
||||||
getTaskPanelClasses,
|
|
||||||
getBackdropClasses,
|
getBackdropClasses,
|
||||||
|
getTaskPanelClasses,
|
||||||
} from '@/lib/responsive-config';
|
} from '@/lib/responsive-config';
|
||||||
import { makeRequest } from '@/lib/api';
|
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
|
||||||
import {
|
import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
|
||||||
Dialog,
|
import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
|
||||||
DialogContent,
|
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
|
||||||
DialogDescription,
|
import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx';
|
||||||
DialogFooter,
|
import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx';
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
MessageSquare,
|
|
||||||
GitCompare,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { DiffCard } from './DiffCard';
|
|
||||||
import type {
|
|
||||||
TaskWithAttemptStatus,
|
|
||||||
EditorType,
|
|
||||||
Project,
|
|
||||||
WorktreeDiff,
|
|
||||||
} from 'shared/types';
|
|
||||||
|
|
||||||
interface TaskDetailsPanelProps {
|
interface TaskDetailsPanelProps {
|
||||||
task: TaskWithAttemptStatus | null;
|
task: TaskWithAttemptStatus | null;
|
||||||
project: Project | null;
|
projectHasDevScript?: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -44,15 +25,9 @@ interface TaskDetailsPanelProps {
|
|||||||
isDialogOpen?: boolean;
|
isDialogOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data: T | null;
|
|
||||||
message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskDetailsPanel({
|
export function TaskDetailsPanel({
|
||||||
task,
|
task,
|
||||||
project,
|
projectHasDevScript,
|
||||||
projectId,
|
projectId,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -61,177 +36,19 @@ export function TaskDetailsPanel({
|
|||||||
isDialogOpen = false,
|
isDialogOpen = false,
|
||||||
}: TaskDetailsPanelProps) {
|
}: TaskDetailsPanelProps) {
|
||||||
const [showEditorDialog, setShowEditorDialog] = useState(false);
|
const [showEditorDialog, setShowEditorDialog] = useState(false);
|
||||||
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
|
|
||||||
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const setupScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Tab and collapsible state
|
// Tab and collapsible state
|
||||||
const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs');
|
const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs');
|
||||||
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
|
|
||||||
const [userSelectedTab, setUserSelectedTab] = useState<boolean>(false);
|
const [userSelectedTab, setUserSelectedTab] = useState<boolean>(false);
|
||||||
|
|
||||||
// Diff-related state
|
|
||||||
const [diff, setDiff] = useState<WorktreeDiff | null>(null);
|
|
||||||
const [diffLoading, setDiffLoading] = useState(true);
|
|
||||||
const [diffError, setDiffError] = useState<string | null>(null);
|
|
||||||
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
|
|
||||||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
|
||||||
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Use the custom hook for all task details logic
|
|
||||||
const {
|
|
||||||
taskAttempts,
|
|
||||||
selectedAttempt,
|
|
||||||
attemptData,
|
|
||||||
loading,
|
|
||||||
selectedExecutor,
|
|
||||||
isStopping,
|
|
||||||
followUpMessage,
|
|
||||||
isSendingFollowUp,
|
|
||||||
followUpError,
|
|
||||||
isStartingDevServer,
|
|
||||||
devServerDetails,
|
|
||||||
branches,
|
|
||||||
selectedBranch,
|
|
||||||
runningDevServer,
|
|
||||||
isAttemptRunning,
|
|
||||||
canSendFollowUp,
|
|
||||||
processedDevServerLogs,
|
|
||||||
executionState,
|
|
||||||
setFollowUpMessage,
|
|
||||||
setFollowUpError,
|
|
||||||
setIsHoveringDevServer,
|
|
||||||
handleAttemptChange,
|
|
||||||
createNewAttempt,
|
|
||||||
stopAllExecutions,
|
|
||||||
startDevServer,
|
|
||||||
stopDevServer,
|
|
||||||
openInEditor,
|
|
||||||
handleSendFollowUp,
|
|
||||||
} = useTaskDetails(task, projectId, isOpen);
|
|
||||||
|
|
||||||
// Use ref to track loading state to prevent dependency cycles
|
|
||||||
const diffLoadingRef = useRef(false);
|
|
||||||
|
|
||||||
// Reset to logs tab when task changes
|
// Reset to logs tab when task changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task) {
|
if (task?.id) {
|
||||||
setActiveTab('logs');
|
setActiveTab('logs');
|
||||||
setUserSelectedTab(true); // Treat this as a user selection to prevent auto-switching
|
setUserSelectedTab(true); // Treat this as a user selection to prevent auto-switching
|
||||||
}
|
}
|
||||||
}, [task?.id]);
|
}, [task?.id]);
|
||||||
|
|
||||||
// Fetch diff when attempt changes
|
|
||||||
const fetchDiff = useCallback(
|
|
||||||
async (isBackgroundRefresh = false) => {
|
|
||||||
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) {
|
|
||||||
setDiff(null);
|
|
||||||
setDiffLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent multiple concurrent requests
|
|
||||||
if (diffLoadingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
diffLoadingRef.current = true;
|
|
||||||
if (isBackgroundRefresh) {
|
|
||||||
setIsBackgroundRefreshing(true);
|
|
||||||
} else {
|
|
||||||
setDiffLoading(true);
|
|
||||||
}
|
|
||||||
setDiffError(null);
|
|
||||||
const response = await makeRequest(
|
|
||||||
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/diff`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result: ApiResponse<WorktreeDiff> = await response.json();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setDiff(result.data);
|
|
||||||
} else {
|
|
||||||
setDiffError('Failed to load diff');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDiffError('Failed to load diff');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setDiffError('Failed to load diff');
|
|
||||||
} finally {
|
|
||||||
diffLoadingRef.current = false;
|
|
||||||
if (isBackgroundRefresh) {
|
|
||||||
setIsBackgroundRefreshing(false);
|
|
||||||
} else {
|
|
||||||
setDiffLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId, selectedAttempt?.id, selectedAttempt?.task_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
fetchDiff();
|
|
||||||
}
|
|
||||||
}, [isOpen, fetchDiff]);
|
|
||||||
|
|
||||||
// Refresh diff when coding agent is running and making changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!executionState || !isOpen || !selectedAttempt) return;
|
|
||||||
|
|
||||||
const isCodingAgentRunning =
|
|
||||||
executionState.execution_state === 'CodingAgentRunning';
|
|
||||||
|
|
||||||
if (isCodingAgentRunning) {
|
|
||||||
// Immediately refresh diff when coding agent starts running
|
|
||||||
fetchDiff(true);
|
|
||||||
|
|
||||||
// Then refresh diff every 2 seconds while coding agent is active
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchDiff(true);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [executionState, isOpen, selectedAttempt, fetchDiff]);
|
|
||||||
|
|
||||||
// Refresh diff when coding agent completes or changes state
|
|
||||||
useEffect(() => {
|
|
||||||
if (!executionState || !isOpen || !selectedAttempt) return;
|
|
||||||
|
|
||||||
const isCodingAgentComplete =
|
|
||||||
executionState.execution_state === 'CodingAgentComplete';
|
|
||||||
const isCodingAgentFailed =
|
|
||||||
executionState.execution_state === 'CodingAgentFailed';
|
|
||||||
const isComplete = executionState.execution_state === 'Complete';
|
|
||||||
const hasChanges = executionState.has_changes;
|
|
||||||
|
|
||||||
// Fetch diff when coding agent completes, fails, or task is complete and has changes
|
|
||||||
if (
|
|
||||||
(isCodingAgentComplete || isCodingAgentFailed || isComplete) &&
|
|
||||||
hasChanges
|
|
||||||
) {
|
|
||||||
fetchDiff();
|
|
||||||
// Auto-switch to diffs tab when changes are detected, but only if user hasn't manually selected a tab
|
|
||||||
if (activeTab === 'logs' && !userSelectedTab) {
|
|
||||||
setActiveTab('diffs');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
executionState?.execution_state,
|
|
||||||
executionState?.has_changes,
|
|
||||||
isOpen,
|
|
||||||
selectedAttempt,
|
|
||||||
fetchDiff,
|
|
||||||
activeTab,
|
|
||||||
userSelectedTab,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle ESC key locally to prevent global navigation
|
// Handle ESC key locally to prevent global navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isDialogOpen) return;
|
if (!isOpen || isDialogOpen) return;
|
||||||
@@ -248,638 +65,56 @@ export function TaskDetailsPanel({
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [isOpen, onClose, isDialogOpen]);
|
}, [isOpen, onClose, isDialogOpen]);
|
||||||
|
|
||||||
// Callback to trigger auto-scroll when conversation updates
|
|
||||||
const handleConversationUpdate = useCallback(() => {
|
|
||||||
setConversationUpdateTrigger((prev) => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when activities, execution processes, or conversation changes (for logs section)
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
shouldAutoScrollLogs &&
|
|
||||||
scrollContainerRef.current &&
|
|
||||||
activeTab === 'logs'
|
|
||||||
) {
|
|
||||||
scrollContainerRef.current.scrollTop =
|
|
||||||
scrollContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
attemptData.activities,
|
|
||||||
attemptData.processes,
|
|
||||||
conversationUpdateTrigger,
|
|
||||||
shouldAutoScrollLogs,
|
|
||||||
activeTab,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-scroll setup script logs to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
if (setupScrollRef.current) {
|
|
||||||
setupScrollRef.current.scrollTop = setupScrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [attemptData.runningProcessDetails]);
|
|
||||||
|
|
||||||
// Handle scroll events to detect manual scrolling (for logs section)
|
|
||||||
const handleLogsScroll = useCallback(() => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } =
|
|
||||||
scrollContainerRef.current;
|
|
||||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
|
||||||
|
|
||||||
if (isAtBottom && !shouldAutoScrollLogs) {
|
|
||||||
setShouldAutoScrollLogs(true);
|
|
||||||
} else if (!isAtBottom && shouldAutoScrollLogs) {
|
|
||||||
setShouldAutoScrollLogs(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [shouldAutoScrollLogs]);
|
|
||||||
|
|
||||||
const handleOpenInEditor = async (editorType?: EditorType) => {
|
|
||||||
try {
|
|
||||||
await openInEditor(editorType);
|
|
||||||
} catch (err) {
|
|
||||||
if (!editorType) {
|
|
||||||
setShowEditorDialog(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFileClick = (filePath: string) => {
|
|
||||||
setFileToDelete(filePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setDeletingFiles((prev) => new Set(prev).add(fileToDelete));
|
|
||||||
const response = await makeRequest(
|
|
||||||
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/delete-file?file_path=${encodeURIComponent(
|
|
||||||
fileToDelete
|
|
||||||
)}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result: ApiResponse<null> = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
fetchDiff();
|
|
||||||
} else {
|
|
||||||
setDiffError(result.message || 'Failed to delete file');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDiffError('Failed to delete file');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setDiffError('Failed to delete file');
|
|
||||||
} finally {
|
|
||||||
setDeletingFiles((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(fileToDelete);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
setFileToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelDelete = () => {
|
|
||||||
setFileToDelete(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render tab content based on active tab
|
|
||||||
const renderTabContent = (): JSX.Element => {
|
|
||||||
console.log('renderTabContent called with activeTab:', activeTab);
|
|
||||||
if (activeTab === 'diffs') {
|
|
||||||
return renderDiffsContent();
|
|
||||||
}
|
|
||||||
return renderLogsContent();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render diffs content
|
|
||||||
const renderDiffsContent = (): JSX.Element => {
|
|
||||||
if (diffLoading) {
|
|
||||||
return (
|
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>
|
{!task || !isOpen ? null : (
|
||||||
<p className="text-muted-foreground ml-4">Loading changes...</p>
|
<TaskDetailsProvider
|
||||||
</div>
|
task={task}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
projectId={projectId}
|
||||||
onConversationUpdate={handleConversationUpdate}
|
setShowEditorDialog={setShowEditorDialog}
|
||||||
/>
|
activeTab={activeTab}
|
||||||
)}
|
setActiveTab={setActiveTab}
|
||||||
</div>
|
isOpen={isOpen}
|
||||||
);
|
userSelectedTab={userSelectedTab}
|
||||||
}
|
|
||||||
|
|
||||||
// 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 && (
|
|
||||||
<>
|
|
||||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||||
<div className={getBackdropClasses()} onClick={onClose} />
|
<div className={getBackdropClasses()} onClick={onClose} />
|
||||||
|
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div className={getTaskPanelClasses()}>
|
<div className={getTaskPanelClasses()}>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
|
||||||
<TaskDetailsHeader
|
<TaskDetailsHeader
|
||||||
task={task}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onEditTask={onEditTask}
|
onEditTask={onEditTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Collapsible Toolbar */}
|
<CollapsibleToolbar projectHasDevScript={projectHasDevScript} />
|
||||||
<div className="border-b">
|
|
||||||
<div className="px-4 pb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
|
||||||
Task Details
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsHeaderCollapsed(!isHeaderCollapsed)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{isHeaderCollapsed ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{!isHeaderCollapsed && (
|
|
||||||
<TaskDetailsToolbar
|
|
||||||
task={task}
|
|
||||||
project={project}
|
|
||||||
projectId={projectId}
|
|
||||||
selectedAttempt={selectedAttempt}
|
|
||||||
taskAttempts={taskAttempts}
|
|
||||||
isAttemptRunning={isAttemptRunning}
|
|
||||||
isStopping={isStopping}
|
|
||||||
selectedExecutor={selectedExecutor}
|
|
||||||
runningDevServer={runningDevServer}
|
|
||||||
isStartingDevServer={isStartingDevServer}
|
|
||||||
devServerDetails={devServerDetails}
|
|
||||||
processedDevServerLogs={processedDevServerLogs}
|
|
||||||
branches={branches}
|
|
||||||
selectedBranch={selectedBranch}
|
|
||||||
onAttemptChange={handleAttemptChange}
|
|
||||||
onCreateNewAttempt={createNewAttempt}
|
|
||||||
onStopAllExecutions={stopAllExecutions}
|
|
||||||
onStartDevServer={startDevServer}
|
|
||||||
onStopDevServer={stopDevServer}
|
|
||||||
onOpenInEditor={handleOpenInEditor}
|
|
||||||
onSetIsHoveringDevServer={setIsHoveringDevServer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
<TabNavigation
|
||||||
<div className="border-b bg-muted/30">
|
activeTab={activeTab}
|
||||||
<div className="flex px-4">
|
setActiveTab={setActiveTab}
|
||||||
<button
|
setUserSelectedTab={setUserSelectedTab}
|
||||||
onClick={() => {
|
/>
|
||||||
console.log(
|
|
||||||
'Logs tab clicked - setting activeTab to logs'
|
|
||||||
);
|
|
||||||
setActiveTab('logs');
|
|
||||||
setUserSelectedTab(true);
|
|
||||||
}}
|
|
||||||
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'logs'
|
|
||||||
? 'border-primary text-primary bg-background'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4 mr-2" />
|
|
||||||
Logs
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'Diffs tab clicked - setting activeTab to diffs'
|
|
||||||
);
|
|
||||||
setActiveTab('diffs');
|
|
||||||
setUserSelectedTab(true);
|
|
||||||
}}
|
|
||||||
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'diffs'
|
|
||||||
? 'border-primary text-primary bg-background'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<GitCompare className="h-4 w-4 mr-2" />
|
|
||||||
Diffs
|
|
||||||
{diff && diff.files.length > 0 && (
|
|
||||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary/10 text-primary rounded-full">
|
|
||||||
{diff.files.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`}
|
className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`}
|
||||||
>
|
>
|
||||||
{renderTabContent()}
|
{activeTab === 'diffs' ? <DiffTab /> : <LogsTab />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - Follow-up section */}
|
<TaskFollowUpSection />
|
||||||
{selectedAttempt && (
|
|
||||||
<TaskFollowUpSection
|
|
||||||
followUpMessage={followUpMessage}
|
|
||||||
setFollowUpMessage={setFollowUpMessage}
|
|
||||||
isSendingFollowUp={isSendingFollowUp}
|
|
||||||
followUpError={followUpError}
|
|
||||||
setFollowUpError={setFollowUpError}
|
|
||||||
canSendFollowUp={canSendFollowUp}
|
|
||||||
projectId={projectId}
|
|
||||||
onSendFollowUp={handleSendFollowUp}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Selection Dialog */}
|
|
||||||
<EditorSelectionDialog
|
<EditorSelectionDialog
|
||||||
isOpen={showEditorDialog}
|
isOpen={showEditorDialog}
|
||||||
onClose={() => setShowEditorDialog(false)}
|
onClose={() => setShowEditorDialog(false)}
|
||||||
onSelectEditor={handleOpenInEditor}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete File Confirmation Dialog */}
|
<DeleteFileConfirmationDialog />
|
||||||
<Dialog
|
</TaskDetailsProvider>
|
||||||
open={!!fileToDelete}
|
|
||||||
onOpenChange={() => handleCancelDelete()}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete File</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete the file{' '}
|
|
||||||
<span className="font-mono font-medium">
|
|
||||||
"{fileToDelete}"
|
|
||||||
</span>
|
|
||||||
?
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
|
||||||
<p className="text-sm text-red-800">
|
|
||||||
<strong>Warning:</strong> This action will permanently
|
|
||||||
remove the entire file from the worktree. This cannot be
|
|
||||||
undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCancelDelete}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleConfirmDelete}
|
|
||||||
disabled={deletingFiles.has(fileToDelete || '')}
|
|
||||||
>
|
|
||||||
{deletingFiles.has(fileToDelete || '')
|
|
||||||
? 'Deleting...'
|
|
||||||
: 'Delete File'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -50,13 +50,11 @@ import { makeRequest } from '@/lib/api';
|
|||||||
import type {
|
import type {
|
||||||
BranchStatus,
|
BranchStatus,
|
||||||
ExecutionProcess,
|
ExecutionProcess,
|
||||||
ExecutionProcessSummary,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Project,
|
|
||||||
TaskAttempt,
|
TaskAttempt,
|
||||||
TaskWithAttemptStatus,
|
|
||||||
} from 'shared/types';
|
} from 'shared/types';
|
||||||
import { ProvidePatDialog } from '@/components/ProvidePatDialog';
|
import { ProvidePatDialog } from '@/components/ProvidePatDialog';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -65,27 +63,7 @@ interface ApiResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TaskDetailsToolbarProps {
|
interface TaskDetailsToolbarProps {
|
||||||
task: TaskWithAttemptStatus;
|
projectHasDevScript?: boolean;
|
||||||
project: Project | null;
|
|
||||||
projectId: string;
|
|
||||||
selectedAttempt: TaskAttempt | null;
|
|
||||||
taskAttempts: TaskAttempt[];
|
|
||||||
isAttemptRunning: boolean;
|
|
||||||
isStopping: boolean;
|
|
||||||
selectedExecutor: string;
|
|
||||||
runningDevServer: ExecutionProcessSummary | undefined;
|
|
||||||
isStartingDevServer: boolean;
|
|
||||||
devServerDetails: ExecutionProcess | null;
|
|
||||||
processedDevServerLogs: string;
|
|
||||||
branches: GitBranch[];
|
|
||||||
selectedBranch: string | null;
|
|
||||||
onAttemptChange: (attemptId: string) => void;
|
|
||||||
onCreateNewAttempt: (executor?: string, baseBranch?: string) => void;
|
|
||||||
onStopAllExecutions: () => void;
|
|
||||||
onStartDevServer: () => void;
|
|
||||||
onStopDevServer: () => void;
|
|
||||||
onOpenInEditor: () => void;
|
|
||||||
onSetIsHoveringDevServer: (hovering: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableExecutors = [
|
const availableExecutors = [
|
||||||
@@ -97,31 +75,35 @@ const availableExecutors = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function TaskDetailsToolbar({
|
export function TaskDetailsToolbar({
|
||||||
task,
|
projectHasDevScript,
|
||||||
project,
|
|
||||||
projectId,
|
|
||||||
selectedAttempt,
|
|
||||||
taskAttempts,
|
|
||||||
isAttemptRunning,
|
|
||||||
isStopping,
|
|
||||||
selectedExecutor,
|
|
||||||
runningDevServer,
|
|
||||||
isStartingDevServer,
|
|
||||||
devServerDetails,
|
|
||||||
processedDevServerLogs,
|
|
||||||
branches,
|
|
||||||
selectedBranch,
|
|
||||||
onAttemptChange,
|
|
||||||
onCreateNewAttempt,
|
|
||||||
onStopAllExecutions,
|
|
||||||
onStartDevServer,
|
|
||||||
onStopDevServer,
|
|
||||||
onOpenInEditor,
|
|
||||||
onSetIsHoveringDevServer,
|
|
||||||
}: TaskDetailsToolbarProps) {
|
}: TaskDetailsToolbarProps) {
|
||||||
|
const {
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
setLoading,
|
||||||
|
setSelectedAttempt,
|
||||||
|
isStopping,
|
||||||
|
handleOpenInEditor,
|
||||||
|
isAttemptRunning,
|
||||||
|
setAttemptData,
|
||||||
|
fetchAttemptData,
|
||||||
|
fetchExecutionState,
|
||||||
|
selectedAttempt,
|
||||||
|
setIsStopping,
|
||||||
|
attemptData,
|
||||||
|
} = useContext(TaskDetailsContext);
|
||||||
|
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([]);
|
||||||
|
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const [branchSearchTerm, setBranchSearchTerm] = useState('');
|
const [branchSearchTerm, setBranchSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<GitBranch[]>([]);
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selectedExecutor, setSelectedExecutor] = useState<string>(
|
||||||
|
config?.executor.type || 'claude'
|
||||||
|
);
|
||||||
|
|
||||||
// State for create attempt mode
|
// State for create attempt mode
|
||||||
const [isInCreateAttemptMode, setIsInCreateAttemptMode] = useState(false);
|
const [isInCreateAttemptMode, setIsInCreateAttemptMode] = useState(false);
|
||||||
const [createAttemptBranch, setCreateAttemptBranch] = useState<string | null>(
|
const [createAttemptBranch, setCreateAttemptBranch] = useState<string | null>(
|
||||||
@@ -146,6 +128,88 @@ export function TaskDetailsToolbar({
|
|||||||
const [showPatDialog, setShowPatDialog] = useState(false);
|
const [showPatDialog, setShowPatDialog] = useState(false);
|
||||||
const [patDialogError, setPatDialogError] = useState<string | null>(null);
|
const [patDialogError, setPatDialogError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [devServerDetails, setDevServerDetails] =
|
||||||
|
useState<ExecutionProcess | null>(null);
|
||||||
|
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
|
||||||
|
|
||||||
|
// Find running dev server in current project
|
||||||
|
const runningDevServer = useMemo(() => {
|
||||||
|
return attemptData.processes.find(
|
||||||
|
(process) =>
|
||||||
|
process.process_type === 'devserver' && process.status === 'running'
|
||||||
|
);
|
||||||
|
}, [attemptData.processes]);
|
||||||
|
|
||||||
|
const fetchDevServerDetails = useCallback(async () => {
|
||||||
|
if (!runningDevServer || !task || !selectedAttempt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<ExecutionProcess> = await response.json();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDevServerDetails(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch dev server details:', err);
|
||||||
|
}
|
||||||
|
}, [runningDevServer, task, selectedAttempt, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHoveringDevServer || !runningDevServer) {
|
||||||
|
setDevServerDetails(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDevServerDetails();
|
||||||
|
const interval = setInterval(fetchDevServerDetails, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
|
||||||
|
|
||||||
|
const processedDevServerLogs = useMemo(() => {
|
||||||
|
if (!devServerDetails) return 'No output yet...';
|
||||||
|
|
||||||
|
const stdout = devServerDetails.stdout || '';
|
||||||
|
const stderr = devServerDetails.stderr || '';
|
||||||
|
const allOutput = stdout + (stderr ? '\n' + stderr : '');
|
||||||
|
const lines = allOutput.split('\n').filter((line) => line.trim());
|
||||||
|
const lastLines = lines.slice(-10);
|
||||||
|
return lastLines.length > 0 ? lastLines.join('\n') : 'No output yet...';
|
||||||
|
}, [devServerDetails]);
|
||||||
|
|
||||||
|
const fetchProjectBranches = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(`/api/projects/${projectId}/branches`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<GitBranch[]> = await response.json();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBranches(result.data);
|
||||||
|
// Set current branch as default
|
||||||
|
const currentBranch = result.data.find((b) => b.is_current);
|
||||||
|
if (currentBranch && !selectedBranch) {
|
||||||
|
setSelectedBranch(currentBranch.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch project branches:', err);
|
||||||
|
}
|
||||||
|
}, [projectId, selectedBranch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjectBranches();
|
||||||
|
}, [fetchProjectBranches]);
|
||||||
|
|
||||||
|
// Set default executor from config
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && config.executor.type !== selectedExecutor) {
|
||||||
|
setSelectedExecutor(config.executor.type);
|
||||||
|
}
|
||||||
|
}, [config, selectedExecutor]);
|
||||||
|
|
||||||
// Set create attempt mode when there are no attempts
|
// Set create attempt mode when there are no attempts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsInCreateAttemptMode(taskAttempts.length === 0);
|
setIsInCreateAttemptMode(taskAttempts.length === 0);
|
||||||
@@ -185,6 +249,165 @@ export function TaskDetailsToolbar({
|
|||||||
}
|
}
|
||||||
}, [selectedAttempt?.base_branch]);
|
}, [selectedAttempt?.base_branch]);
|
||||||
|
|
||||||
|
const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
executor: executor || selectedExecutor,
|
||||||
|
base_branch: baseBranch || selectedBranch,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
fetchTaskAttempts();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create new attempt:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTaskAttempts = useCallback(async () => {
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${task.id}/attempts`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<TaskAttempt[]> = await response.json();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTaskAttempts(result.data);
|
||||||
|
|
||||||
|
if (result.data.length > 0) {
|
||||||
|
const latestAttempt = result.data.reduce((latest, current) =>
|
||||||
|
new Date(current.created_at) > new Date(latest.created_at)
|
||||||
|
? current
|
||||||
|
: latest
|
||||||
|
);
|
||||||
|
setSelectedAttempt(latestAttempt);
|
||||||
|
fetchAttemptData(latestAttempt.id, latestAttempt.task_id);
|
||||||
|
fetchExecutionState(latestAttempt.id, latestAttempt.task_id);
|
||||||
|
} else {
|
||||||
|
setSelectedAttempt(null);
|
||||||
|
setAttemptData({
|
||||||
|
activities: [],
|
||||||
|
processes: [],
|
||||||
|
runningProcessDetails: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch task attempts:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [task, projectId, fetchAttemptData, fetchExecutionState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTaskAttempts();
|
||||||
|
}, [fetchTaskAttempts]);
|
||||||
|
|
||||||
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
|
|
||||||
|
const startDevServer = async () => {
|
||||||
|
if (!task || !selectedAttempt) return;
|
||||||
|
|
||||||
|
setIsStartingDevServer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/start-dev-server`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to start dev server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ApiResponse<null> = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Failed to start dev server');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start dev server:', err);
|
||||||
|
} finally {
|
||||||
|
setIsStartingDevServer(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDevServer = async () => {
|
||||||
|
if (!task || !selectedAttempt || !runningDevServer) return;
|
||||||
|
|
||||||
|
setIsStartingDevServer(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to stop dev server');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop dev server:', err);
|
||||||
|
} finally {
|
||||||
|
setIsStartingDevServer(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAllExecutions = async () => {
|
||||||
|
if (!task || !selectedAttempt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsStopping(true);
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/stop`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop executions:', err);
|
||||||
|
} finally {
|
||||||
|
setIsStopping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttemptChange = useCallback(
|
||||||
|
(attempt: TaskAttempt) => {
|
||||||
|
setSelectedAttempt(attempt);
|
||||||
|
fetchAttemptData(attempt.id, attempt.task_id);
|
||||||
|
fetchExecutionState(attempt.id, attempt.task_id);
|
||||||
|
},
|
||||||
|
[fetchAttemptData, fetchExecutionState, setSelectedAttempt]
|
||||||
|
);
|
||||||
|
|
||||||
// Branch status fetching
|
// Branch status fetching
|
||||||
const fetchBranchStatus = useCallback(async () => {
|
const fetchBranchStatus = useCallback(async () => {
|
||||||
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
|
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
|
||||||
@@ -722,7 +945,7 @@ export function TaskDetailsToolbar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onOpenInEditor()}
|
onClick={() => handleOpenInEditor()}
|
||||||
className="h-4 w-4 p-0 hover:bg-muted"
|
className="h-4 w-4 p-0 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
@@ -743,10 +966,10 @@ export function TaskDetailsToolbar({
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
!project?.dev_script ? 'cursor-not-allowed' : ''
|
!projectHasDevScript ? 'cursor-not-allowed' : ''
|
||||||
}
|
}
|
||||||
onMouseEnter={() => onSetIsHoveringDevServer(true)}
|
onMouseEnter={() => setIsHoveringDevServer(true)}
|
||||||
onMouseLeave={() => onSetIsHoveringDevServer(false)}
|
onMouseLeave={() => setIsHoveringDevServer(false)}
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -758,11 +981,11 @@ export function TaskDetailsToolbar({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={
|
onClick={
|
||||||
runningDevServer
|
runningDevServer
|
||||||
? onStopDevServer
|
? stopDevServer
|
||||||
: onStartDevServer
|
: startDevServer
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isStartingDevServer || !project?.dev_script
|
isStartingDevServer || !projectHasDevScript
|
||||||
}
|
}
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
@@ -787,7 +1010,7 @@ export function TaskDetailsToolbar({
|
|||||||
align="center"
|
align="center"
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
>
|
>
|
||||||
{!project?.dev_script ? (
|
{!projectHasDevScript ? (
|
||||||
<p>
|
<p>
|
||||||
Configure a dev server command in project
|
Configure a dev server command in project
|
||||||
settings
|
settings
|
||||||
@@ -838,7 +1061,7 @@ export function TaskDetailsToolbar({
|
|||||||
{taskAttempts.map((attempt) => (
|
{taskAttempts.map((attempt) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={attempt.id}
|
key={attempt.id}
|
||||||
onClick={() => onAttemptChange(attempt.id)}
|
onClick={() => handleAttemptChange(attempt)}
|
||||||
className={
|
className={
|
||||||
selectedAttempt?.id === attempt.id
|
selectedAttempt?.id === attempt.id
|
||||||
? 'bg-accent'
|
? 'bg-accent'
|
||||||
@@ -928,7 +1151,7 @@ export function TaskDetailsToolbar({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onStopAllExecutions}
|
onClick={stopAllExecutions}
|
||||||
disabled={isStopping}
|
disabled={isStopping}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,30 +1,89 @@
|
|||||||
import { Send, AlertCircle } from 'lucide-react';
|
import { AlertCircle, Send } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||||
|
import { useContext, useMemo, useState } from 'react';
|
||||||
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
interface TaskFollowUpSectionProps {
|
export function TaskFollowUpSection() {
|
||||||
followUpMessage: string;
|
const {
|
||||||
setFollowUpMessage: (message: string) => void;
|
task,
|
||||||
isSendingFollowUp: boolean;
|
|
||||||
followUpError: string | null;
|
|
||||||
setFollowUpError: (error: string | null) => void;
|
|
||||||
canSendFollowUp: boolean;
|
|
||||||
projectId: string;
|
|
||||||
onSendFollowUp: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskFollowUpSection({
|
|
||||||
followUpMessage,
|
|
||||||
setFollowUpMessage,
|
|
||||||
isSendingFollowUp,
|
|
||||||
followUpError,
|
|
||||||
setFollowUpError,
|
|
||||||
canSendFollowUp,
|
|
||||||
projectId,
|
projectId,
|
||||||
onSendFollowUp,
|
selectedAttempt,
|
||||||
}: TaskFollowUpSectionProps) {
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
selectedAttempt && (
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{followUpError && (
|
{followUpError && (
|
||||||
@@ -77,5 +136,6 @@ export function TaskFollowUpSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
KanbanProvider,
|
|
||||||
KanbanBoard,
|
|
||||||
KanbanHeader,
|
|
||||||
KanbanCards,
|
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanCards,
|
||||||
|
KanbanHeader,
|
||||||
|
KanbanProvider,
|
||||||
} from '@/components/ui/shadcn-io/kanban';
|
} from '@/components/ui/shadcn-io/kanban';
|
||||||
import { TaskCard } from './TaskCard';
|
import { TaskCard } from './TaskCard';
|
||||||
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
|
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
|
||||||
@@ -51,46 +52,39 @@ export function TaskKanbanBoard({
|
|||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onViewTaskDetails,
|
onViewTaskDetails,
|
||||||
}: TaskKanbanBoardProps) {
|
}: TaskKanbanBoardProps) {
|
||||||
const filterTasks = (tasks: Task[]) => {
|
// Memoize filtered tasks
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return tasks.filter(
|
return tasks.filter(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.title.toLowerCase().includes(query) ||
|
task.title.toLowerCase().includes(query) ||
|
||||||
(task.description && task.description.toLowerCase().includes(query))
|
(task.description && task.description.toLowerCase().includes(query))
|
||||||
);
|
);
|
||||||
};
|
}, [tasks, searchQuery]);
|
||||||
|
|
||||||
const groupTasksByStatus = () => {
|
// Memoize grouped tasks
|
||||||
|
const groupedTasks = useMemo(() => {
|
||||||
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>;
|
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>;
|
||||||
|
|
||||||
// Initialize groups for all possible statuses
|
|
||||||
allTaskStatuses.forEach((status) => {
|
allTaskStatuses.forEach((status) => {
|
||||||
groups[status] = [];
|
groups[status] = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTasks = filterTasks(tasks);
|
|
||||||
|
|
||||||
filteredTasks.forEach((task) => {
|
filteredTasks.forEach((task) => {
|
||||||
// Convert old capitalized status to lowercase if needed
|
|
||||||
const normalizedStatus = task.status.toLowerCase() as TaskStatus;
|
const normalizedStatus = task.status.toLowerCase() as TaskStatus;
|
||||||
if (groups[normalizedStatus]) {
|
if (groups[normalizedStatus]) {
|
||||||
groups[normalizedStatus].push(task);
|
groups[normalizedStatus].push(task);
|
||||||
} else {
|
} else {
|
||||||
// Default to todo if status doesn't match any expected value
|
|
||||||
groups['todo'].push(task);
|
groups['todo'].push(task);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
};
|
}, [filteredTasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KanbanProvider onDragEnd={onDragEnd}>
|
<KanbanProvider onDragEnd={onDragEnd}>
|
||||||
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
{Object.entries(groupedTasks).map(([status, statusTasks]) => (
|
||||||
<KanbanBoard key={status} id={status as TaskStatus}>
|
<KanbanBoard key={status} id={status as TaskStatus}>
|
||||||
<KanbanHeader
|
<KanbanHeader
|
||||||
name={statusLabels[status as TaskStatus]}
|
name={statusLabels[status as TaskStatus]}
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Plus, Settings, FolderOpen } from 'lucide-react';
|
import { FolderOpen, Plus, Settings } from 'lucide-react';
|
||||||
import { makeRequest } from '@/lib/api';
|
import { makeRequest } from '@/lib/api';
|
||||||
import { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
|
import { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
|
||||||
import { ProjectForm } from '@/components/projects/project-form';
|
import { ProjectForm } from '@/components/projects/project-form';
|
||||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
|
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
|
||||||
import {
|
import {
|
||||||
getMainContainerClasses,
|
|
||||||
getKanbanSectionClasses,
|
getKanbanSectionClasses,
|
||||||
|
getMainContainerClasses,
|
||||||
} from '@/lib/responsive-config';
|
} from '@/lib/responsive-config';
|
||||||
|
|
||||||
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
|
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
|
||||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||||
import type {
|
import type {
|
||||||
|
CreateTaskAndStart,
|
||||||
|
ExecutorConfig,
|
||||||
|
ProjectWithBranch,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TaskWithAttemptStatus,
|
TaskWithAttemptStatus,
|
||||||
ProjectWithBranch,
|
|
||||||
ExecutorConfig,
|
|
||||||
CreateTaskAndStart,
|
|
||||||
} from 'shared/types';
|
} from 'shared/types';
|
||||||
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ export function ProjectTasks() {
|
|||||||
{isPanelOpen && (
|
{isPanelOpen && (
|
||||||
<TaskDetailsPanel
|
<TaskDetailsPanel
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
project={project}
|
projectHasDevScript={!!project?.dev_script}
|
||||||
projectId={projectId!}
|
projectId={projectId!}
|
||||||
isOpen={isPanelOpen}
|
isOpen={isPanelOpen}
|
||||||
onClose={handleClosePanel}
|
onClose={handleClosePanel}
|
||||||
|
|||||||
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 ApiResponse<T> = { success: boolean, data: T | null, message: string | null, };
|
||||||
|
|
||||||
export type Config = { theme: ThemeMode, executor: ExecutorConfig, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, sound_alerts: boolean, sound_file: SoundFile, push_notifications: boolean, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, };
|
export type Config = {
|
||||||
|
theme: ThemeMode,
|
||||||
|
executor: ExecutorConfig,
|
||||||
|
disclaimer_acknowledged: boolean,
|
||||||
|
onboarding_acknowledged: boolean,
|
||||||
|
sound_alerts: boolean,
|
||||||
|
sound_file: SoundFile,
|
||||||
|
push_notifications: boolean,
|
||||||
|
editor: EditorConfig,
|
||||||
|
github: GitHubConfig,
|
||||||
|
analytics_enabled: boolean | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";
|
export type ThemeMode = "light" | "dark" | "system" | "purple" | "green" | "blue" | "orange" | "red";
|
||||||
|
|
||||||
export type EditorConfig = { editor_type: EditorType, custom_command: string | null, };
|
export type EditorConfig = { editor_type: EditorType, custom_command: string | null, };
|
||||||
|
|
||||||
export type GitHubConfig = { pat: string | null, token: string | null, username: string | null, primary_email: string | null, default_pr_base: string | null, };
|
export type GitHubConfig = {
|
||||||
|
pat: string | null,
|
||||||
|
token: string | null,
|
||||||
|
username: string | null,
|
||||||
|
primary_email: string | null,
|
||||||
|
default_pr_base: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom";
|
export type EditorType = "vscode" | "cursor" | "windsurf" | "intellij" | "zed" | "custom";
|
||||||
|
|
||||||
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };
|
export type EditorConstants = { editor_types: Array<EditorType>, editor_labels: Array<string>, };
|
||||||
|
|
||||||
export type SoundFile = "abstract-sound1" | "abstract-sound2" | "abstract-sound3" | "abstract-sound4" | "cow-mooing" | "phone-vibration" | "rooster";
|
export type SoundFile =
|
||||||
|
"abstract-sound1"
|
||||||
|
| "abstract-sound2"
|
||||||
|
| "abstract-sound3"
|
||||||
|
| "abstract-sound4"
|
||||||
|
| "cow-mooing"
|
||||||
|
| "phone-vibration"
|
||||||
|
| "rooster";
|
||||||
|
|
||||||
export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Array<string>, };
|
export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Array<string>, };
|
||||||
|
|
||||||
export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
|
export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
|
||||||
|
|
||||||
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | { "type": "opencode" };
|
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | {
|
||||||
|
"type": "opencode"
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
|
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
|
||||||
|
|
||||||
export type CreateProject = { name: string, git_repo_path: string, use_existing_repo: boolean, setup_script: string | null, dev_script: string | null, };
|
export type CreateProject = {
|
||||||
|
name: string,
|
||||||
|
git_repo_path: string,
|
||||||
|
use_existing_repo: boolean,
|
||||||
|
setup_script: string | null,
|
||||||
|
dev_script: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type Project = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, created_at: Date, updated_at: Date, };
|
export type Project = {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
git_repo_path: string,
|
||||||
|
setup_script: string | null,
|
||||||
|
dev_script: string | null,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date,
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectWithBranch = { id: string, name: string, git_repo_path: string, setup_script: string | null, dev_script: string | null, current_branch: string | null, created_at: Date, updated_at: Date, };
|
export type ProjectWithBranch = {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
git_repo_path: string,
|
||||||
|
setup_script: string | null,
|
||||||
|
dev_script: string | null,
|
||||||
|
current_branch: string | null,
|
||||||
|
created_at: Date,
|
||||||
|
updated_at: Date,
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateProject = { name: string | null, git_repo_path: string | null, setup_script: string | null, dev_script: string | null, };
|
export type UpdateProject = {
|
||||||
|
name: string | null,
|
||||||
|
git_repo_path: string | null,
|
||||||
|
setup_script: string | null,
|
||||||
|
dev_script: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, };
|
export type SearchResult = { path: string, is_file: boolean, match_type: SearchMatchType, };
|
||||||
|
|
||||||
@@ -44,19 +98,64 @@ export type CreateBranch = { name: string, base_branch: string | null, };
|
|||||||
|
|
||||||
export type CreateTask = { project_id: string, title: string, description: string | null, };
|
export type CreateTask = { project_id: string, title: string, description: string | null, };
|
||||||
|
|
||||||
export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, executor: ExecutorConfig | null, };
|
export type CreateTaskAndStart = {
|
||||||
|
project_id: string,
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
executor: ExecutorConfig | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled";
|
||||||
|
|
||||||
export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, };
|
export type Task = {
|
||||||
|
id: string,
|
||||||
|
project_id: string,
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
status: TaskStatus,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type TaskWithAttemptStatus = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, has_in_progress_attempt: boolean, has_merged_attempt: boolean, has_failed_attempt: boolean, };
|
export type TaskWithAttemptStatus = {
|
||||||
|
id: string,
|
||||||
|
project_id: string,
|
||||||
|
title: string,
|
||||||
|
description: string | null,
|
||||||
|
status: TaskStatus,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
has_in_progress_attempt: boolean,
|
||||||
|
has_merged_attempt: boolean,
|
||||||
|
has_failed_attempt: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, };
|
export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, };
|
||||||
|
|
||||||
export type TaskAttemptStatus = "setuprunning" | "setupcomplete" | "setupfailed" | "executorrunning" | "executorcomplete" | "executorfailed";
|
export type TaskAttemptStatus =
|
||||||
|
"setuprunning"
|
||||||
|
| "setupcomplete"
|
||||||
|
| "setupfailed"
|
||||||
|
| "executorrunning"
|
||||||
|
| "executorcomplete"
|
||||||
|
| "executorfailed";
|
||||||
|
|
||||||
export type TaskAttempt = { id: string, task_id: string, worktree_path: string, branch: string, base_branch: string, merge_commit: string | null, executor: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, pr_merged_at: string | null, worktree_deleted: boolean, created_at: string, updated_at: string, };
|
export type TaskAttempt = {
|
||||||
|
id: string,
|
||||||
|
task_id: string,
|
||||||
|
worktree_path: string,
|
||||||
|
branch: string,
|
||||||
|
base_branch: string,
|
||||||
|
merge_commit: string | null,
|
||||||
|
executor: string | null,
|
||||||
|
pr_url: string | null,
|
||||||
|
pr_number: bigint | null,
|
||||||
|
pr_status: string | null,
|
||||||
|
pr_merged_at: string | null,
|
||||||
|
worktree_deleted: boolean,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, };
|
export type CreateTaskAttempt = { executor: string | null, base_branch: string | null, };
|
||||||
|
|
||||||
@@ -64,11 +163,34 @@ export type UpdateTaskAttempt = Record<string, never>;
|
|||||||
|
|
||||||
export type CreateFollowUpAttempt = { prompt: string, };
|
export type CreateFollowUpAttempt = { prompt: string, };
|
||||||
|
|
||||||
export type TaskAttemptActivity = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, };
|
export type TaskAttemptActivity = {
|
||||||
|
id: string,
|
||||||
|
execution_process_id: string,
|
||||||
|
status: TaskAttemptStatus,
|
||||||
|
note: string | null,
|
||||||
|
created_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type TaskAttemptActivityWithPrompt = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, prompt: string | null, };
|
export type TaskAttemptActivityWithPrompt = {
|
||||||
|
id: string,
|
||||||
|
execution_process_id: string,
|
||||||
|
status: TaskAttemptStatus,
|
||||||
|
note: string | null,
|
||||||
|
created_at: string,
|
||||||
|
prompt: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateTaskAttemptActivity = { execution_process_id: string, status: TaskAttemptStatus | null, note: string | null, };
|
export type AttemptData = {
|
||||||
|
activities: TaskAttemptActivityWithPrompt[];
|
||||||
|
processes: ExecutionProcessSummary[];
|
||||||
|
runningProcessDetails: Record<string, ExecutionProcess>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateTaskAttemptActivity = {
|
||||||
|
execution_process_id: string,
|
||||||
|
status: TaskAttemptStatus | null,
|
||||||
|
note: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, };
|
export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, };
|
||||||
|
|
||||||
@@ -80,37 +202,125 @@ export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
|
|||||||
|
|
||||||
export type WorktreeDiff = { files: Array<FileDiff>, };
|
export type WorktreeDiff = { files: Array<FileDiff>, };
|
||||||
|
|
||||||
export type BranchStatus = { is_behind: boolean, commits_behind: number, commits_ahead: number, up_to_date: boolean, merged: boolean, has_uncommitted_changes: boolean, base_branch_name: string, };
|
export type BranchStatus = {
|
||||||
|
is_behind: boolean,
|
||||||
|
commits_behind: number,
|
||||||
|
commits_ahead: number,
|
||||||
|
up_to_date: boolean,
|
||||||
|
merged: boolean,
|
||||||
|
has_uncommitted_changes: boolean,
|
||||||
|
base_branch_name: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutionState = "NotStarted" | "SetupRunning" | "SetupComplete" | "SetupFailed" | "CodingAgentRunning" | "CodingAgentComplete" | "CodingAgentFailed" | "Complete";
|
export type ExecutionState =
|
||||||
|
"NotStarted"
|
||||||
|
| "SetupRunning"
|
||||||
|
| "SetupComplete"
|
||||||
|
| "SetupFailed"
|
||||||
|
| "CodingAgentRunning"
|
||||||
|
| "CodingAgentComplete"
|
||||||
|
| "CodingAgentFailed"
|
||||||
|
| "Complete";
|
||||||
|
|
||||||
export type TaskAttemptState = { execution_state: ExecutionState, has_changes: boolean, has_setup_script: boolean, setup_process_id: string | null, coding_agent_process_id: string | null, };
|
export type TaskAttemptState = {
|
||||||
|
execution_state: ExecutionState,
|
||||||
|
has_changes: boolean,
|
||||||
|
has_setup_script: boolean,
|
||||||
|
setup_process_id: string | null,
|
||||||
|
coding_agent_process_id: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutionProcess = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, stdout: string | null, stderr: string | null, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };
|
export type ExecutionProcess = {
|
||||||
|
id: string,
|
||||||
|
task_attempt_id: string,
|
||||||
|
process_type: ExecutionProcessType,
|
||||||
|
executor_type: string | null,
|
||||||
|
status: ExecutionProcessStatus,
|
||||||
|
command: string,
|
||||||
|
args: string | null,
|
||||||
|
working_directory: string,
|
||||||
|
stdout: string | null,
|
||||||
|
stderr: string | null,
|
||||||
|
exit_code: bigint | null,
|
||||||
|
started_at: string,
|
||||||
|
completed_at: string | null,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutionProcessSummary = { id: string, task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, status: ExecutionProcessStatus, command: string, args: string | null, working_directory: string, exit_code: bigint | null, started_at: string, completed_at: string | null, created_at: string, updated_at: string, };
|
export type ExecutionProcessSummary = {
|
||||||
|
id: string,
|
||||||
|
task_attempt_id: string,
|
||||||
|
process_type: ExecutionProcessType,
|
||||||
|
executor_type: string | null,
|
||||||
|
status: ExecutionProcessStatus,
|
||||||
|
command: string,
|
||||||
|
args: string | null,
|
||||||
|
working_directory: string,
|
||||||
|
exit_code: bigint | null,
|
||||||
|
started_at: string,
|
||||||
|
completed_at: string | null,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed";
|
export type ExecutionProcessStatus = "running" | "completed" | "failed" | "killed";
|
||||||
|
|
||||||
export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver";
|
export type ExecutionProcessType = "setupscript" | "codingagent" | "devserver";
|
||||||
|
|
||||||
export type CreateExecutionProcess = { task_attempt_id: string, process_type: ExecutionProcessType, executor_type: string | null, command: string, args: string | null, working_directory: string, };
|
export type CreateExecutionProcess = {
|
||||||
|
task_attempt_id: string,
|
||||||
|
process_type: ExecutionProcessType,
|
||||||
|
executor_type: string | null,
|
||||||
|
command: string,
|
||||||
|
args: string | null,
|
||||||
|
working_directory: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateExecutionProcess = { status: ExecutionProcessStatus | null, exit_code: bigint | null, completed_at: string | null, };
|
export type UpdateExecutionProcess = {
|
||||||
|
status: ExecutionProcessStatus | null,
|
||||||
|
exit_code: bigint | null,
|
||||||
|
completed_at: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecutorSession = { id: string, task_attempt_id: string, execution_process_id: string, session_id: string | null, prompt: string | null, summary: string | null, created_at: string, updated_at: string, };
|
export type ExecutorSession = {
|
||||||
|
id: string,
|
||||||
|
task_attempt_id: string,
|
||||||
|
execution_process_id: string,
|
||||||
|
session_id: string | null,
|
||||||
|
prompt: string | null,
|
||||||
|
summary: string | null,
|
||||||
|
created_at: string,
|
||||||
|
updated_at: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, };
|
export type CreateExecutorSession = { task_attempt_id: string, execution_process_id: string, prompt: string | null, };
|
||||||
|
|
||||||
export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, };
|
export type UpdateExecutorSession = { session_id: string | null, prompt: string | null, summary: string | null, };
|
||||||
|
|
||||||
export type NormalizedConversation = { entries: Array<NormalizedEntry>, session_id: string | null, executor_type: string, prompt: string | null, summary: string | null, };
|
export type NormalizedConversation = {
|
||||||
|
entries: Array<NormalizedEntry>,
|
||||||
|
session_id: string | null,
|
||||||
|
executor_type: string,
|
||||||
|
prompt: string | null,
|
||||||
|
summary: string | null,
|
||||||
|
};
|
||||||
|
|
||||||
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
|
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
|
||||||
|
|
||||||
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" };
|
export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | {
|
||||||
|
"type": "tool_use",
|
||||||
|
tool_name: string,
|
||||||
|
action_type: ActionType,
|
||||||
|
} | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" };
|
||||||
|
|
||||||
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "other", description: string, };
|
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | {
|
||||||
|
"action": "command_run",
|
||||||
|
command: string,
|
||||||
|
} | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | {
|
||||||
|
"action": "task_create",
|
||||||
|
description: string,
|
||||||
|
} | { "action": "other", description: string, };
|
||||||
|
|
||||||
// Generated constants
|
// Generated constants
|
||||||
export const EXECUTOR_TYPES: string[] = [
|
export const EXECUTOR_TYPES: string[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user