diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d642d18..d3f8a3c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -161,6 +161,10 @@ function AppContent() { path="/projects/:projectId/tasks" element={} /> + } + /> } diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index 22edc87c..e7e130dd 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -6,7 +6,7 @@ import { useReducer, useState, } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { Play } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { attemptsApi, projectsApi } from '@/lib/api'; @@ -92,6 +92,8 @@ function TaskDetailsToolbar() { const [selectedProfile, setSelectedProfile] = useState(null); const location = useLocation(); + const navigate = useNavigate(); + const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>(); const { system, profiles } = useUserSystem(); // Memoize latest attempt calculation @@ -156,39 +158,68 @@ function TaskDetailsToolbar() { }); if (result.length > 0) { - // Check if there's an attempt query parameter + // Check if we have a new latest attempt (newly created) + const currentLatest = + taskAttempts.length > 0 + ? taskAttempts.reduce((latest, current) => + new Date(current.created_at) > new Date(latest.created_at) + ? current + : latest + ) + : null; + + const newLatest = result.reduce((latest, current) => + new Date(current.created_at) > new Date(latest.created_at) + ? current + : latest + ); + + // If we have a new attempt that wasn't there before, navigate to it immediately + const hasNewAttempt = + newLatest && (!currentLatest || newLatest.id !== currentLatest.id); + + if (hasNewAttempt) { + // Always navigate to newly created attempts + handleAttemptSelect(newLatest); + return; + } + + // Otherwise, follow existing logic for URL-based attempt selection const urlParams = new URLSearchParams(location.search); - const attemptParam = urlParams.get('attempt'); + const queryAttemptParam = urlParams.get('attempt'); + const attemptParam = urlAttemptId || queryAttemptParam; let selectedAttemptToUse: TaskAttempt; if (attemptParam) { - // Try to find the specific attempt const specificAttempt = result.find( (attempt) => attempt.id === attemptParam ); if (specificAttempt) { selectedAttemptToUse = specificAttempt; } else { - // Fall back to latest if specific attempt not found - selectedAttemptToUse = result.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ); + selectedAttemptToUse = newLatest; } } else { - // Use latest attempt if no specific attempt requested - selectedAttemptToUse = result.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ); + selectedAttemptToUse = newLatest; } setSelectedAttempt((prev) => { if (JSON.stringify(prev) === JSON.stringify(selectedAttemptToUse)) return prev; + + // Only navigate if we're not already on the correct attempt URL + if ( + selectedAttemptToUse && + task && + (!urlAttemptId || urlAttemptId !== selectedAttemptToUse.id) + ) { + navigate( + `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`, + { replace: true } + ); + } + return selectedAttemptToUse; }); } else { @@ -203,7 +234,16 @@ function TaskDetailsToolbar() { } finally { setLoading(false); } - }, [task, location.search, setLoading, setSelectedAttempt, setAttemptData]); + }, [ + task, + location.search, + urlAttemptId, + navigate, + projectId, + setLoading, + setSelectedAttempt, + setAttemptData, + ]); useEffect(() => { fetchTaskAttempts(); @@ -214,6 +254,20 @@ function TaskDetailsToolbar() { dispatch({ type: 'ENTER_CREATE_MODE' }); }, []); + // Handle attempt selection with URL navigation + const handleAttemptSelect = useCallback( + (attempt: TaskAttempt | null) => { + setSelectedAttempt(attempt); + if (attempt && task) { + navigate( + `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`, + { replace: true } + ); + } + }, + [navigate, projectId, task, setSelectedAttempt] + ); + // Stub handlers for backward compatibility with CreateAttempt const setCreateAttemptBranch = useCallback( (branch: string | null | ((prev: string | null) => string | null)) => { @@ -303,6 +357,7 @@ function TaskDetailsToolbar() { setShowCreatePRDialog={setShowCreatePRDialog} creatingPR={ui.creatingPR} handleEnterCreateAttemptMode={handleEnterCreateAttemptMode} + handleAttemptSelect={handleAttemptSelect} branches={branches} /> ) : ( diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx index e2f5c9d4..75494293 100644 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx @@ -48,7 +48,6 @@ import { TaskAttemptDataContext, TaskAttemptStoppingContext, TaskDetailsContext, - TaskSelectedAttemptContext, } from '@/components/context/taskDetailsContext.ts'; import { useConfig } from '@/components/config-provider.tsx'; import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts'; @@ -81,6 +80,7 @@ type Props = { taskAttempts: TaskAttempt[]; creatingPR: boolean; handleEnterCreateAttemptMode: () => void; + handleAttemptSelect: (attempt: TaskAttempt) => void; branches: GitBranch[]; }; @@ -92,12 +92,12 @@ function CurrentAttempt({ taskAttempts, creatingPR, handleEnterCreateAttemptMode, + handleAttemptSelect, branches, }: Props) { const { task, projectId, handleOpenInEditor, projectHasDevScript } = useContext(TaskDetailsContext); const { config } = useConfig(); - const { setSelectedAttempt } = useContext(TaskSelectedAttemptContext); const { isStopping, setIsStopping } = useContext(TaskAttemptStoppingContext); const { attemptData, fetchAttemptData, isAttemptRunning } = useContext( TaskAttemptDataContext @@ -223,10 +223,10 @@ function CurrentAttempt({ const handleAttemptChange = useCallback( (attempt: TaskAttempt) => { - setSelectedAttempt(attempt); + handleAttemptSelect(attempt); fetchAttemptData(attempt.id, attempt.task_id); }, - [fetchAttemptData, setSelectedAttempt] + [fetchAttemptData, handleAttemptSelect] ); const handleMergeClick = async () => { diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index 1c8a33d8..ff71ec67 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -249,11 +249,14 @@ export function ProjectTasks() { }, []); const handleViewTaskDetails = useCallback( - (task: Task) => { + (task: Task, attemptIdToShow?: string) => { // setSelectedTask(task); // setIsPanelOpen(true); - // Update URL to include task ID - navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true }); + // Update URL to include task ID and optionally attempt ID + const targetUrl = attemptIdToShow + ? `/projects/${projectId}/tasks/${task.id}/attempts/${attemptIdToShow}` + : `/projects/${projectId}/tasks/${task.id}`; + navigate(targetUrl, { replace: true }); }, [projectId, navigate] ); @@ -311,7 +314,7 @@ export function ProjectTasks() { // Setup keyboard shortcuts useKeyboardShortcuts({ navigate, - currentPath: `/projects/${projectId}/tasks`, + currentPath: window.location.pathname, hasOpenDialog: isTaskDialogOpen || isTemplateManagerOpen || isProjectSettingsOpen, closeDialog: () => setIsTaskDialogOpen(false),