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