diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a40279e..94f98cbe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,9 @@ import { useEffect } from 'react'; -import { - BrowserRouter, - Navigate, - Route, - Routes, - useLocation, -} from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { Navbar } from '@/components/layout/navbar'; import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager'; import { AgentSettings, @@ -38,9 +33,9 @@ const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); function AppContent() { const { config, updateAndSaveConfig, loading } = useUserSystem(); - const location = useLocation(); + const { isFullscreen } = useTaskViewManager(); - const showNavbar = !location.pathname.endsWith('/full'); + const showNavbar = !isFullscreen; useEffect(() => { let cancelled = false; @@ -165,6 +160,10 @@ function AppContent() { path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/full" element={} /> + } + /> } diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx index aea28618..0ccc803e 100644 --- a/frontend/src/components/tasks/TaskDetailsHeader.tsx +++ b/frontend/src/components/tasks/TaskDetailsHeader.tsx @@ -11,6 +11,7 @@ import type { TaskWithAttemptStatus } from 'shared/types'; import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription'; import { Card } from '../ui/card'; import { statusBoardColors, statusLabels } from '@/utils/status-labels'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager'; interface TaskDetailsHeaderProps { task: TaskWithAttemptStatus; @@ -19,7 +20,6 @@ interface TaskDetailsHeaderProps { onDeleteTask?: (taskId: string) => void; hideCloseButton?: boolean; isFullScreen?: boolean; - setFullScreen?: (isFullScreen: boolean) => void; } // backgroundColor: `hsl(var(${statusBoardColors[task.status]}) / 0.03)`, @@ -31,8 +31,8 @@ function TaskDetailsHeader({ onDeleteTask, hideCloseButton = false, isFullScreen, - setFullScreen, }: TaskDetailsHeaderProps) { + const { toggleFullscreen } = useTaskViewManager(); return (
{statusLabels[task.status]}

- {setFullScreen && ( - - - - - - -

- {isFullScreen + + + + + + +

+ {isFullScreen + ? 'Collapse to sidebar' + : 'Expand to fullscreen'} +

+
+
+
{onEditTask && ( diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 2e6cbe1a..2fe82d53 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -22,6 +22,7 @@ import { ReviewProvider } from '@/contexts/ReviewProvider'; import { AttemptHeaderCard } from './AttemptHeaderCard'; import { inIframe } from '@/vscode/bridge'; import { TaskRelationshipViewer } from './TaskRelationshipViewer'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager.ts'; interface TaskDetailsPanelProps { task: TaskWithAttemptStatus | null; @@ -36,13 +37,13 @@ interface TaskDetailsPanelProps { className?: string; hideHeader?: boolean; isFullScreen?: boolean; - setFullScreen?: (value: boolean) => void; forceCreateAttempt?: boolean; onLeaveForceCreateAttempt?: () => void; onNewAttempt?: () => void; selectedAttempt: TaskAttempt | null; attempts: TaskAttempt[]; setSelectedAttempt: (attempt: TaskAttempt | null) => void; + tasksById?: Record; } export function TaskDetailsPanel({ @@ -57,12 +58,12 @@ export function TaskDetailsPanel({ hideBackdrop = false, className, isFullScreen, - setFullScreen, forceCreateAttempt, onLeaveForceCreateAttempt, selectedAttempt, attempts, setSelectedAttempt, + tasksById, }: TaskDetailsPanelProps) { // Attempt number, find the current attempt number const attemptNumber = @@ -73,8 +74,10 @@ export function TaskDetailsPanel({ const [activeTab, setActiveTab] = useState('logs'); // Handler for jumping to diff tab in full screen + const { toggleFullscreen } = useTaskViewManager(); + const jumpToDiffFullScreen = () => { - setFullScreen?.(true); + toggleFullscreen(true); setActiveTab('diffs'); }; @@ -137,7 +140,6 @@ export function TaskDetailsPanel({ onDeleteTask={onDeleteTask} hideCloseButton={hideBackdrop} isFullScreen={isFullScreen} - setFullScreen={setFullScreen} /> )} @@ -172,6 +174,8 @@ export function TaskDetailsPanel({ diff --git a/frontend/src/components/tasks/TaskRelationshipViewer.tsx b/frontend/src/components/tasks/TaskRelationshipViewer.tsx index f3268436..9eac4593 100644 --- a/frontend/src/components/tasks/TaskRelationshipViewer.tsx +++ b/frontend/src/components/tasks/TaskRelationshipViewer.tsx @@ -2,26 +2,38 @@ import { useEffect, useState } from 'react'; import { Card } from '@/components/ui/card'; import { TaskRelationshipCard } from './TaskRelationshipCard'; import { attemptsApi } from '@/lib/api'; -import type { TaskAttempt, TaskRelationships } from 'shared/types'; +import type { + TaskAttempt, + TaskRelationships, + TaskWithAttemptStatus, +} from 'shared/types'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; interface TaskRelationshipViewerProps { selectedAttempt: TaskAttempt | null; onNavigateToTask?: (taskId: string) => void; + task?: TaskWithAttemptStatus | null; + tasksById?: Record; } export function TaskRelationshipViewer({ selectedAttempt, onNavigateToTask, + task, + tasksById, }: TaskRelationshipViewerProps) { const [relationships, setRelationships] = useState( null ); + const [parentTask, setParentTask] = useState( + null + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [childrenExpanded, setChildrenExpanded] = useState(true); + // Effect for attempt-based relationships (existing behavior) useEffect(() => { if (!selectedAttempt?.id) { setRelationships(null); @@ -47,9 +59,31 @@ export function TaskRelationshipViewer({ fetchRelationships(); }, [selectedAttempt?.id]); - const parentTask = relationships?.parent_task; + // Effect for parent task when child has no attempts (one request + tasksById lookup) + useEffect(() => { + if (selectedAttempt?.id) { + // If we have an attempt, clear parent task since relationships will handle it + setParentTask(null); + return; + } + + if (task?.parent_task_attempt && tasksById) { + attemptsApi + .get(task.parent_task_attempt) + .then((parentAttempt) => { + // Use existing tasksById instead of second API call + const parentTaskData = tasksById[parentAttempt.task_id]; + setParentTask(parentTaskData || null); + }) + .catch(() => setParentTask(null)); + } else { + setParentTask(null); + } + }, [selectedAttempt?.id, task?.parent_task_attempt, tasksById]); + + const displayParentTask = relationships?.parent_task || parentTask; const childTasks = relationships?.children || []; - const hasParent = parentTask !== null; + const hasParent = displayParentTask !== null; const hasChildren = childTasks.length > 0; // Don't render if no relationships and no current task @@ -74,7 +108,7 @@ export function TaskRelationshipViewer({ ) : (
{/* Parent Task Section */} - {hasParent && parentTask && ( + {hasParent && displayParentTask && (

@@ -85,9 +119,9 @@ export function TaskRelationshipViewer({
onNavigateToTask?.(parentTask.id)} + onClick={() => onNavigateToTask?.(displayParentTask.id)} className="shadow-sm" />
diff --git a/frontend/src/hooks/useAttemptCreation.ts b/frontend/src/hooks/useAttemptCreation.ts index 364b55ea..7bacf3ed 100644 --- a/frontend/src/hooks/useAttemptCreation.ts +++ b/frontend/src/hooks/useAttemptCreation.ts @@ -1,13 +1,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { attemptsApi } from '@/lib/api'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager'; import type { TaskAttempt } from 'shared/types'; import type { ExecutorProfileId } from 'shared/types'; export function useAttemptCreation(taskId: string) { const queryClient = useQueryClient(); - const navigate = useNavigate(); const { projectId } = useParams<{ projectId: string }>(); + const { navigateToAttempt } = useTaskViewManager(); const mutation = useMutation({ mutationFn: ({ @@ -31,10 +32,7 @@ export function useAttemptCreation(taskId: string) { // Navigate to new attempt (triggers polling switch) if (projectId) { - navigate( - `/projects/${projectId}/tasks/${taskId}/attempts/${newAttempt.id}`, - { replace: true } - ); + navigateToAttempt(projectId, taskId, newAttempt.id); } }, }); diff --git a/frontend/src/hooks/useTaskMutations.ts b/frontend/src/hooks/useTaskMutations.ts index bef69d1a..1dc60549 100644 --- a/frontend/src/hooks/useTaskMutations.ts +++ b/frontend/src/hooks/useTaskMutations.ts @@ -1,6 +1,6 @@ -import { useNavigate } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { tasksApi } from '@/lib/api'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager'; import type { CreateTask, CreateAndStartTaskRequest, @@ -10,8 +10,8 @@ import type { } from 'shared/types'; export function useTaskMutations(projectId?: string) { - const navigate = useNavigate(); const queryClient = useQueryClient(); + const { navigateToTask } = useTaskViewManager(); const invalidateQueries = (taskId?: string) => { queryClient.invalidateQueries({ queryKey: ['tasks', projectId] }); @@ -24,9 +24,9 @@ export function useTaskMutations(projectId?: string) { mutationFn: (data: CreateTask) => tasksApi.create(data), onSuccess: (createdTask: Task) => { invalidateQueries(); - navigate(`/projects/${projectId}/tasks/${createdTask.id}`, { - replace: true, - }); + if (projectId) { + navigateToTask(projectId, createdTask.id); + } }, onError: (err) => { console.error('Failed to create task:', err); @@ -38,9 +38,9 @@ export function useTaskMutations(projectId?: string) { tasksApi.createAndStart(data), onSuccess: (createdTask: TaskWithAttemptStatus) => { invalidateQueries(); - navigate(`/projects/${projectId}/tasks/${createdTask.id}`, { - replace: true, - }); + if (projectId) { + navigateToTask(projectId, createdTask.id); + } }, onError: (err) => { console.error('Failed to create and start task:', err); diff --git a/frontend/src/hooks/useTaskViewManager.ts b/frontend/src/hooks/useTaskViewManager.ts new file mode 100644 index 00000000..44c0131d --- /dev/null +++ b/frontend/src/hooks/useTaskViewManager.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +interface NavigateOptions { + attemptId?: string; + fullscreen?: boolean; + replace?: boolean; + state?: unknown; +} + +/** + * Centralised hook for task routing and fullscreen controls + * Exposes navigation helpers alongside fullscreen state/toggles + */ +export function useTaskViewManager() { + const navigate = useNavigate(); + const location = useLocation(); + + const isFullscreen = location.pathname.endsWith('/full'); + + const toggleFullscreen = useCallback( + (fullscreen: boolean) => { + const currentPath = location.pathname; + let targetPath: string; + + if (fullscreen) { + targetPath = currentPath.endsWith('/full') + ? currentPath + : `${currentPath}/full`; + } else { + targetPath = currentPath.endsWith('/full') + ? currentPath.slice(0, -5) + : currentPath; + } + + navigate(targetPath, { replace: true }); + }, + [location.pathname, navigate] + ); + + const buildTaskUrl = useCallback( + (projectId: string, taskId: string, options?: NavigateOptions) => { + const baseUrl = `/projects/${projectId}/tasks/${taskId}`; + const attemptUrl = options?.attemptId + ? `/attempts/${options.attemptId}` + : ''; + const fullscreenSuffix = + (options?.fullscreen ?? isFullscreen) ? '/full' : ''; + + return `${baseUrl}${attemptUrl}${fullscreenSuffix}`; + }, + [isFullscreen] + ); + + const navigateToTask = useCallback( + (projectId: string, taskId: string, options?: NavigateOptions) => { + const targetUrl = buildTaskUrl(projectId, taskId, options); + + navigate(targetUrl, { + replace: options?.replace ?? true, + state: options?.state, + }); + }, + [buildTaskUrl, navigate] + ); + + const navigateToAttempt = useCallback( + ( + projectId: string, + taskId: string, + attemptId: string, + options?: Omit + ) => { + navigateToTask(projectId, taskId, { + ...options, + attemptId, + }); + }, + [navigateToTask] + ); + + return { + isFullscreen, + toggleFullscreen, + buildTaskUrl, + navigateToTask, + navigateToAttempt, + }; +} diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index daaa3009..3c9ccd1a 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, useMemo } from 'react'; -import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { AlertTriangle, Plus } from 'lucide-react'; @@ -9,6 +9,7 @@ import { openTaskForm } from '@/lib/openTaskForm'; import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts'; import { useSearch } from '@/contexts/search-context'; import { useQuery } from '@tanstack/react-query'; +import { useTaskViewManager } from '@/hooks/useTaskViewManager'; import { getKanbanSectionClasses, @@ -32,7 +33,6 @@ export function ProjectTasks() { attemptId?: string; }>(); const navigate = useNavigate(); - const location = useLocation(); const [project, setProject] = useState(null); const [error, setError] = useState(null); @@ -60,8 +60,9 @@ export function ProjectTasks() { const [selectedTask, setSelectedTask] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(false); - // Fullscreen state from pathname - const isFullscreen = location.pathname.endsWith('/full'); + // Fullscreen state using custom hook + const { isFullscreen, navigateToTask, navigateToAttempt } = + useTaskViewManager(); // Attempts fetching (only when task is selected) const { data: attempts = [] } = useQuery({ @@ -86,14 +87,13 @@ export function ProjectTasks() { (attempt: TaskAttempt | null) => { if (!selectedTask) return; - const baseUrl = `/projects/${projectId}/tasks/${selectedTask.id}`; - const attemptUrl = attempt ? `/attempts/${attempt.id}` : ''; - const fullSuffix = isFullscreen ? '/full' : ''; - const fullUrl = `${baseUrl}${attemptUrl}${fullSuffix}`; - - navigate(fullUrl, { replace: true }); + if (attempt) { + navigateToAttempt(projectId!, selectedTask.id, attempt.id); + } else { + navigateToTask(projectId!, selectedTask.id); + } }, - [navigate, projectId, selectedTask, isFullscreen] + [navigateToTask, navigateToAttempt, projectId, selectedTask] ); // Stream tasks for this project @@ -178,16 +178,14 @@ export function ProjectTasks() { ); const handleViewTaskDetails = useCallback( - (task: Task, attemptIdToShow?: string) => { - // setSelectedTask(task); - // setIsPanelOpen(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 }); + (task: Task, attemptIdToShow?: string, fullscreen?: boolean) => { + if (attemptIdToShow) { + navigateToAttempt(projectId!, task.id, attemptIdToShow, { fullscreen }); + } else { + navigateToTask(projectId!, task.id, { fullscreen }); + } }, - [projectId, navigate] + [projectId, navigateToTask, navigateToAttempt] ); const handleDragEnd = useCallback( @@ -312,22 +310,14 @@ export function ProjectTasks() { onNavigateToTask={(taskId) => { const task = tasksById[taskId]; if (task) { - handleViewTaskDetails(task); + handleViewTaskDetails(task, undefined, true); } }} isFullScreen={isFullscreen} - setFullScreen={ - selectedAttempt - ? (fullscreen) => { - const baseUrl = `/projects/${projectId}/tasks/${selectedTask!.id}/attempts/${selectedAttempt.id}`; - const fullUrl = fullscreen ? `${baseUrl}/full` : baseUrl; - navigate(fullUrl, { replace: true }); - } - : undefined - } selectedAttempt={selectedAttempt} attempts={attempts} setSelectedAttempt={setSelectedAttempt} + tasksById={tasksById} /> )}