From 0d3a7a18f80e9a3d2c2b92d3d1eeae33c2789049 Mon Sep 17 00:00:00 2001 From: Anastasiia Solop <35258279+anastasiya1155@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:27:33 +0200 Subject: [PATCH] Refactor TaskDetailsToolbar and LogsPanel, improve performance (#136) * separate CreatePRDialog from TaskDetailsToolbar * separate CreateAttempt from TaskDetailsToolbar * separate CurrentAttempt from TaskDetailsToolbar * refactor logs panel and diffs * split big context, add callbacks and memo, check prev state before update for big polled values --- frontend/src/components/config-provider.tsx | 15 +- .../context/TaskDetailsContextProvider.tsx | 140 +- .../components/context/taskDetailsContext.ts | 116 +- .../tasks/DeleteFileConfirmationDialog.tsx | 24 +- .../tasks/TaskDetails/CollapsibleToolbar.tsx | 16 +- .../tasks/TaskDetails/Conversation.tsx | 143 +++ .../components/tasks/TaskDetails/DiffCard.tsx | 399 +----- .../tasks/TaskDetails/DiffChunkSection.tsx | 134 ++ .../components/tasks/TaskDetails/DiffFile.tsx | 278 ++++ .../components/tasks/TaskDetails/DiffTab.tsx | 4 +- .../TaskDetails/DisplayConversationEntry.tsx | 379 ++++++ .../components/tasks/TaskDetails/LogsTab.tsx | 141 +-- .../NormalizedConversationViewer.tsx | 405 +----- .../tasks/TaskDetails/TabNavigation.tsx | 4 +- .../components/tasks/TaskDetailsHeader.tsx | 6 +- .../src/components/tasks/TaskDetailsPanel.tsx | 5 +- .../components/tasks/TaskDetailsToolbar.tsx | 1126 ++--------------- .../components/tasks/TaskFollowUpSection.tsx | 20 +- .../tasks/Toolbar/CreateAttempt.tsx | 271 ++++ .../tasks/Toolbar/CreatePRDialog.tsx | 235 ++++ .../tasks/Toolbar/CurrentAttempt.tsx | 634 ++++++++++ .../components/ui/file-search-textarea.tsx | 9 +- frontend/src/pages/project-tasks.tsx | 337 ++--- shared/types.ts | 15 + 24 files changed, 2630 insertions(+), 2226 deletions(-) create mode 100644 frontend/src/components/tasks/TaskDetails/Conversation.tsx create mode 100644 frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx create mode 100644 frontend/src/components/tasks/TaskDetails/DiffFile.tsx create mode 100644 frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx create mode 100644 frontend/src/components/tasks/Toolbar/CreateAttempt.tsx create mode 100644 frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx create mode 100644 frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx diff --git a/frontend/src/components/config-provider.tsx b/frontend/src/components/config-provider.tsx index abe7215c..d8ba7caa 100644 --- a/frontend/src/components/config-provider.tsx +++ b/frontend/src/components/config-provider.tsx @@ -51,17 +51,12 @@ export function ConfigProvider({ children }: ConfigProviderProps) { useEffect(() => { if (loading) return; const checkToken = async () => { - try { - const response = await fetch('/api/auth/github/check'); - const data: ApiResponse = await response.json(); - if (!data.success && data.message === 'github_token_invalid') { - setGithubTokenInvalid(true); - } else { - setGithubTokenInvalid(false); - } - } catch (err) { - // If the check fails, assume token is invalid + const response = await fetch('/api/auth/github/check'); + const data: ApiResponse = await response.json(); + if (!data.success && data.message === 'github_token_invalid') { setGithubTokenInvalid(true); + } else { + setGithubTokenInvalid(false); } }; checkToken(); diff --git a/frontend/src/components/context/TaskDetailsContextProvider.tsx b/frontend/src/components/context/TaskDetailsContextProvider.tsx index 0b859555..fd8e1c3b 100644 --- a/frontend/src/components/context/TaskDetailsContextProvider.tsx +++ b/frontend/src/components/context/TaskDetailsContextProvider.tsx @@ -22,7 +22,17 @@ import type { WorktreeDiff, } from 'shared/types.ts'; import { makeRequest } from '@/lib/api.ts'; -import { TaskDetailsContext } from './taskDetailsContext.ts'; +import { + TaskAttemptDataContext, + TaskAttemptLoadingContext, + TaskAttemptStoppingContext, + TaskBackgroundRefreshContext, + TaskDeletingFilesContext, + TaskDetailsContext, + TaskDiffContext, + TaskExecutionStateContext, + TaskSelectedAttemptContext, +} from './taskDetailsContext.ts'; const TaskDetailsProvider: FC<{ task: TaskWithAttemptStatus; @@ -33,6 +43,7 @@ const TaskDetailsProvider: FC<{ setShowEditorDialog: Dispatch>; isOpen: boolean; userSelectedTab: boolean; + projectHasDevScript?: boolean; }> = ({ task, projectId, @@ -42,6 +53,7 @@ const TaskDetailsProvider: FC<{ setShowEditorDialog, isOpen, userSelectedTab, + projectHasDevScript, }) => { const [loading, setLoading] = useState(false); const [isStopping, setIsStopping] = useState(false); @@ -136,7 +148,11 @@ const TaskDetailsProvider: FC<{ if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { - setExecutionState(result.data); + setExecutionState((prev) => { + if (JSON.stringify(prev) === JSON.stringify(result.data)) + return prev; + return result.data; + }); } } } catch (err) { @@ -256,10 +272,14 @@ const TaskDetailsProvider: FC<{ } } - setAttemptData({ - activities: activitiesResult.data, - processes: processesResult.data, - runningProcessDetails, + setAttemptData((prev) => { + const newData = { + activities: activitiesResult.data || [], + processes: processesResult.data || [], + runningProcessDetails, + }; + if (JSON.stringify(prev) === JSON.stringify(newData)) return prev; + return newData; }); } } @@ -368,56 +388,98 @@ const TaskDetailsProvider: FC<{ () => ({ task, projectId, - loading, - setLoading, - selectedAttempt, - setSelectedAttempt, - isStopping, - setIsStopping, + handleOpenInEditor, + projectHasDevScript, + }), + [task, projectId, handleOpenInEditor, projectHasDevScript] + ); + + const taskAttemptLoadingValue = useMemo( + () => ({ loading, setLoading }), + [loading] + ); + + const selectedAttemptValue = useMemo( + () => ({ selectedAttempt, setSelectedAttempt }), + [selectedAttempt] + ); + + const attemptStoppingValue = useMemo( + () => ({ isStopping, setIsStopping }), + [isStopping] + ); + + const deletingFilesValue = useMemo( + () => ({ deletingFiles, fileToDelete, setFileToDelete, setDeletingFiles, - fetchDiff, + }), + [deletingFiles, fileToDelete] + ); + + const diffValue = useMemo( + () => ({ setDiffError, + fetchDiff, diff, diffError, diffLoading, - setDiffLoading, setDiff, + setDiffLoading, + }), + [fetchDiff, diff, diffError, diffLoading] + ); + + const backgroundRefreshingValue = useMemo( + () => ({ isBackgroundRefreshing, - handleOpenInEditor, - isAttemptRunning, - fetchExecutionState, - executionState, + }), + [isBackgroundRefreshing] + ); + + const attemptDataValue = useMemo( + () => ({ attemptData, setAttemptData, fetchAttemptData, - }), - [ - task, - projectId, - loading, - selectedAttempt, - isStopping, - deletingFiles, - fileToDelete, - fetchDiff, - diff, - diffError, - diffLoading, - isBackgroundRefreshing, - handleOpenInEditor, isAttemptRunning, - fetchExecutionState, - executionState, - attemptData, - fetchAttemptData, - ] + }), + [attemptData, fetchAttemptData, isAttemptRunning] ); + + const executionStateValue = useMemo( + () => ({ + executionState, + fetchExecutionState, + }), + [executionState, fetchExecutionState] + ); + return ( - {children} + + + + + + + + + {children} + + + + + + + + ); }; diff --git a/frontend/src/components/context/taskDetailsContext.ts b/frontend/src/components/context/taskDetailsContext.ts index 24fc53a4..96aff9b6 100644 --- a/frontend/src/components/context/taskDetailsContext.ts +++ b/frontend/src/components/context/taskDetailsContext.ts @@ -11,36 +11,98 @@ import type { export interface TaskDetailsContextValue { task: TaskWithAttemptStatus; projectId: string; - loading: boolean; - setLoading: Dispatch>; - selectedAttempt: TaskAttempt | null; - setSelectedAttempt: Dispatch>; - isStopping: boolean; - setIsStopping: Dispatch>; - deletingFiles: Set; - setDeletingFiles: Dispatch>>; - fileToDelete: string | null; - setFileToDelete: Dispatch>; - setDiffError: Dispatch>; - fetchDiff: (isBackgroundRefresh?: boolean) => Promise; - diff: WorktreeDiff | null; - diffError: string | null; - diffLoading: boolean; - isBackgroundRefreshing: boolean; - setDiff: Dispatch>; - setDiffLoading: Dispatch>; handleOpenInEditor: (editorType?: EditorType) => Promise; - isAttemptRunning: boolean; - fetchExecutionState: ( - attemptId: string, - taskId: string - ) => Promise | void; - executionState: TaskAttemptState | null; - attemptData: AttemptData; - setAttemptData: Dispatch>; - fetchAttemptData: (attemptId: string, taskId: string) => Promise | void; + projectHasDevScript?: boolean; } export const TaskDetailsContext = createContext( {} as TaskDetailsContextValue ); + +interface TaskAttemptLoadingContextValue { + loading: boolean; + setLoading: Dispatch>; +} + +export const TaskAttemptLoadingContext = + createContext( + {} as TaskAttemptLoadingContextValue + ); + +interface TaskAttemptDataContextValue { + attemptData: AttemptData; + setAttemptData: Dispatch>; + fetchAttemptData: (attemptId: string, taskId: string) => Promise | void; + isAttemptRunning: boolean; +} + +export const TaskAttemptDataContext = + createContext({} as TaskAttemptDataContextValue); + +interface TaskSelectedAttemptContextValue { + selectedAttempt: TaskAttempt | null; + setSelectedAttempt: Dispatch>; +} + +export const TaskSelectedAttemptContext = + createContext( + {} as TaskSelectedAttemptContextValue + ); + +interface TaskAttemptStoppingContextValue { + isStopping: boolean; + setIsStopping: Dispatch>; +} + +export const TaskAttemptStoppingContext = + createContext( + {} as TaskAttemptStoppingContextValue + ); + +interface TaskDeletingFilesContextValue { + deletingFiles: Set; + setDeletingFiles: Dispatch>>; + fileToDelete: string | null; + setFileToDelete: Dispatch>; +} + +export const TaskDeletingFilesContext = + createContext( + {} as TaskDeletingFilesContextValue + ); + +interface TaskDiffContextValue { + setDiffError: Dispatch>; + fetchDiff: (isBackgroundRefresh?: boolean) => Promise; + diff: WorktreeDiff | null; + diffError: string | null; + diffLoading: boolean; + setDiff: Dispatch>; + setDiffLoading: Dispatch>; +} + +export const TaskDiffContext = createContext( + {} as TaskDiffContextValue +); + +interface TaskBackgroundRefreshContextValue { + isBackgroundRefreshing: boolean; +} + +export const TaskBackgroundRefreshContext = + createContext( + {} as TaskBackgroundRefreshContextValue + ); + +interface TaskExecutionStateContextValue { + executionState: TaskAttemptState | null; + fetchExecutionState: ( + attemptId: string, + taskId: string + ) => Promise | void; +} + +export const TaskExecutionStateContext = + createContext( + {} as TaskExecutionStateContextValue + ); diff --git a/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx b/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx index 1a31466d..d1d2a62e 100644 --- a/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx +++ b/frontend/src/components/tasks/DeleteFileConfirmationDialog.tsx @@ -10,20 +10,20 @@ 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'; +import { + TaskDeletingFilesContext, + TaskDetailsContext, + TaskDiffContext, + TaskSelectedAttemptContext, +} from '@/components/context/taskDetailsContext.ts'; function DeleteFileConfirmationDialog() { - const { - task, - projectId, - selectedAttempt, - setDeletingFiles, - fileToDelete, - deletingFiles, - setFileToDelete, - fetchDiff, - setDiffError, - } = useContext(TaskDetailsContext); + const { task, projectId } = useContext(TaskDetailsContext); + const { selectedAttempt } = useContext(TaskSelectedAttemptContext); + const { setDeletingFiles, fileToDelete, deletingFiles, setFileToDelete } = + useContext(TaskDeletingFilesContext); + const { fetchDiff, setDiffError } = useContext(TaskDiffContext); + const handleConfirmDelete = async () => { if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id) return; diff --git a/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx b/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx index 44a9df99..7b7f9458 100644 --- a/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetails/CollapsibleToolbar.tsx @@ -1,13 +1,9 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Button } from '@/components/ui/button.tsx'; import { ChevronDown, ChevronUp } from 'lucide-react'; -import { TaskDetailsToolbar } from '@/components/tasks/TaskDetailsToolbar.tsx'; +import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx'; -type Props = { - projectHasDevScript?: boolean; -}; - -function CollapsibleToolbar({ projectHasDevScript }: Props) { +function CollapsibleToolbar() { const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false); return ( @@ -29,11 +25,9 @@ function CollapsibleToolbar({ projectHasDevScript }: Props) { )} - {!isHeaderCollapsed && ( - - )} + {!isHeaderCollapsed && } ); } -export default CollapsibleToolbar; +export default memo(CollapsibleToolbar); diff --git a/frontend/src/components/tasks/TaskDetails/Conversation.tsx b/frontend/src/components/tasks/TaskDetails/Conversation.tsx new file mode 100644 index 00000000..4d9a1c40 --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/Conversation.tsx @@ -0,0 +1,143 @@ +import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { TaskAttemptDataContext } from '@/components/context/taskDetailsContext.ts'; + +type Props = { + conversationUpdateTrigger: number; + handleConversationUpdate: () => void; +}; + +function Conversation({ + conversationUpdateTrigger, + handleConversationUpdate, +}: Props) { + const { attemptData } = useContext(TaskAttemptDataContext); + const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true); + + const scrollContainerRef = useRef(null); + + useEffect(() => { + if (shouldAutoScrollLogs && scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = + scrollContainerRef.current.scrollHeight; + } + }, [ + attemptData.activities, + attemptData.processes, + conversationUpdateTrigger, + shouldAutoScrollLogs, + ]); + + 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 mainCodingAgentProcess = useMemo(() => { + let mainCAProcess = Object.values(attemptData.runningProcessDetails).find( + (process) => + process.process_type === 'codingagent' && process.command === 'executor' + ); + + if (!mainCAProcess) { + const mainCodingAgentSummary = attemptData.processes.find( + (process) => + process.process_type === 'codingagent' && + process.command === 'executor' + ); + + if (mainCodingAgentSummary) { + mainCAProcess = Object.values(attemptData.runningProcessDetails).find( + (process) => process.id === mainCodingAgentSummary.id + ); + + if (!mainCAProcess) { + mainCAProcess = { + ...mainCodingAgentSummary, + stdout: null, + stderr: null, + } as any; + } + } + } + return mainCAProcess; + }, [attemptData.processes, attemptData.runningProcessDetails]); + + const followUpProcesses = useMemo(() => { + return 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) + ); + }); + }, [attemptData.processes, attemptData.runningProcessDetails]); + + return ( +
+ {mainCodingAgentProcess || followUpProcesses.length > 0 ? ( +
+ {mainCodingAgentProcess && ( +
+ +
+ )} + {followUpProcesses.map((followUpProcess) => ( +
+
+ +
+ ))} +
+ ) : ( +
+
+

Coding Agent Starting

+

Initializing conversation...

+
+ )} +
+ ); +} + +export default Conversation; diff --git a/frontend/src/components/tasks/TaskDetails/DiffCard.tsx b/frontend/src/components/tasks/TaskDetails/DiffCard.tsx index ae364cc7..e8a863ed 100644 --- a/frontend/src/components/tasks/TaskDetails/DiffCard.tsx +++ b/frontend/src/components/tasks/TaskDetails/DiffCard.tsx @@ -1,23 +1,9 @@ -import { useCallback, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { Button } from '@/components/ui/button.tsx'; -import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react'; -import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts'; -import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; - -interface ProcessedLine { - content: string; - chunkType: DiffChunkType; - oldLineNumber?: number; - newLineNumber?: number; -} - -interface ProcessedSection { - type: 'context' | 'change' | 'expanded'; - lines: ProcessedLine[]; - expandKey?: string; - expandedAbove?: boolean; - expandedBelow?: boolean; -} +import { GitCompare } from 'lucide-react'; +import type { WorktreeDiff } from 'shared/types.ts'; +import { TaskBackgroundRefreshContext } from '@/components/context/taskDetailsContext.ts'; +import DiffFile from '@/components/tasks/TaskDetails/DiffFile.tsx'; interface DiffCardProps { diff: WorktreeDiff | null; @@ -32,212 +18,8 @@ export function DiffCard({ compact = false, className = '', }: DiffCardProps) { - const { deletingFiles, setFileToDelete, isBackgroundRefreshing } = - useContext(TaskDetailsContext); + const { isBackgroundRefreshing } = useContext(TaskBackgroundRefreshContext); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); - const [expandedSections, setExpandedSections] = useState>( - new Set() - ); - - const onDeleteFile = useCallback( - (filePath: string) => { - setFileToDelete(filePath); - }, - [setFileToDelete] - ); - - // Diff processing functions - const getChunkClassName = (chunkType: DiffChunkType) => { - const baseClass = 'font-mono text-sm whitespace-pre flex w-full'; - - switch (chunkType) { - case 'Insert': - return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-900 dark:text-green-100`; - case 'Delete': - return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100`; - case 'Equal': - default: - return `${baseClass} text-muted-foreground`; - } - }; - - const getLineNumberClassName = (chunkType: DiffChunkType) => { - const baseClass = - 'flex-shrink-0 w-12 px-1.5 text-xs border-r select-none min-h-[1.25rem] flex items-center'; - - switch (chunkType) { - case 'Insert': - return `${baseClass} text-green-800 dark:text-green-200 bg-green-100 dark:bg-green-900/40 border-green-300 dark:border-green-600`; - case 'Delete': - return `${baseClass} text-red-800 dark:text-red-200 bg-red-100 dark:bg-red-900/40 border-red-300 dark:border-red-600`; - case 'Equal': - default: - return `${baseClass} text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700`; - } - }; - - const getChunkPrefix = (chunkType: DiffChunkType) => { - switch (chunkType) { - case 'Insert': - return '+'; - case 'Delete': - return '-'; - case 'Equal': - default: - return ' '; - } - }; - - const processFileChunks = (chunks: DiffChunk[], fileIndex: number) => { - const CONTEXT_LINES = compact ? 2 : 3; - const lines: ProcessedLine[] = []; - let oldLineNumber = 1; - let newLineNumber = 1; - - // Convert chunks to lines with line numbers - chunks.forEach((chunk) => { - const chunkLines = chunk.content.split('\n'); - chunkLines.forEach((line, index) => { - if (index < chunkLines.length - 1 || line !== '') { - const processedLine: ProcessedLine = { - content: line, - chunkType: chunk.chunk_type, - }; - - switch (chunk.chunk_type) { - case 'Equal': - processedLine.oldLineNumber = oldLineNumber++; - processedLine.newLineNumber = newLineNumber++; - break; - case 'Delete': - processedLine.oldLineNumber = oldLineNumber++; - break; - case 'Insert': - processedLine.newLineNumber = newLineNumber++; - break; - } - - lines.push(processedLine); - } - }); - }); - - const sections: ProcessedSection[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - - if (line.chunkType === 'Equal') { - let nextChangeIndex = i + 1; - while ( - nextChangeIndex < lines.length && - lines[nextChangeIndex].chunkType === 'Equal' - ) { - nextChangeIndex++; - } - - const contextLength = nextChangeIndex - i; - const hasNextChange = nextChangeIndex < lines.length; - const hasPrevChange = - sections.length > 0 && - sections[sections.length - 1].type === 'change'; - - if ( - contextLength <= CONTEXT_LINES * 2 || - (!hasPrevChange && !hasNextChange) - ) { - sections.push({ - type: 'context', - lines: lines.slice(i, nextChangeIndex), - }); - } else { - if (hasPrevChange) { - sections.push({ - type: 'context', - lines: lines.slice(i, i + CONTEXT_LINES), - }); - i += CONTEXT_LINES; - } - - if (hasNextChange) { - const expandStart = hasPrevChange ? i : i + CONTEXT_LINES; - const expandEnd = nextChangeIndex - CONTEXT_LINES; - - if (expandEnd > expandStart) { - const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`; - const isExpanded = expandedSections.has(expandKey); - - if (isExpanded) { - sections.push({ - type: 'expanded', - lines: lines.slice(expandStart, expandEnd), - expandKey, - }); - } else { - sections.push({ - type: 'context', - lines: [], - expandKey, - }); - } - } - - sections.push({ - type: 'context', - lines: lines.slice( - nextChangeIndex - CONTEXT_LINES, - nextChangeIndex - ), - }); - } else if (!hasPrevChange) { - sections.push({ - type: 'context', - lines: lines.slice(i, i + CONTEXT_LINES), - }); - } - } - - i = nextChangeIndex; - } else { - const changeStart = i; - while (i < lines.length && lines[i].chunkType !== 'Equal') { - i++; - } - - sections.push({ - type: 'change', - lines: lines.slice(changeStart, i), - }); - } - } - - return sections; - }; - - const toggleExpandSection = (expandKey: string) => { - setExpandedSections((prev) => { - const newSet = new Set(prev); - if (newSet.has(expandKey)) { - newSet.delete(expandKey); - } else { - newSet.add(expandKey); - } - return newSet; - }); - }; - - const toggleFileCollapse = (filePath: string) => { - setCollapsedFiles((prev) => { - const newSet = new Set(prev); - if (newSet.has(filePath)) { - newSet.delete(filePath); - } else { - newSet.add(filePath); - } - return newSet; - }); - }; const collapseAllFiles = () => { if (diff) { @@ -312,168 +94,15 @@ export function DiffCard({ >
{diff.files.map((file, fileIndex) => ( -
-
-
- -

- {file.path} -

- {collapsedFiles.has(file.path) && ( -
- - + - {file.chunks - .filter((c) => c.chunk_type === 'Insert') - .reduce( - (acc, c) => acc + c.content.split('\n').length - 1, - 0 - )} - - - - - {file.chunks - .filter((c) => c.chunk_type === 'Delete') - .reduce( - (acc, c) => acc + c.content.split('\n').length - 1, - 0 - )} - -
- )} -
- {deletable && ( - - )} -
- {!collapsedFiles.has(file.path) && ( -
-
- {processFileChunks(file.chunks, fileIndex).map( - (section, sectionIndex) => { - if ( - section.type === 'context' && - section.lines.length === 0 && - section.expandKey - ) { - const lineCount = - parseInt(section.expandKey.split('-')[2]) - - parseInt(section.expandKey.split('-')[1]); - return ( -
- -
- ); - } - - return ( -
- {section.type === 'expanded' && - section.expandKey && ( -
- -
- )} - {section.lines.map((line, lineIndex) => ( -
-
- - {line.oldLineNumber || ''} - - - {line.newLineNumber || ''} - -
-
- - {getChunkPrefix(line.chunkType)} - - - {line.content} - -
-
- ))} -
- ); - } - )} -
-
- )} -
+ collapsedFiles={collapsedFiles} + compact={compact} + deletable={deletable} + file={file} + fileIndex={fileIndex} + setCollapsedFiles={setCollapsedFiles} + /> ))}
diff --git a/frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx b/frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx new file mode 100644 index 00000000..2a793e1a --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx @@ -0,0 +1,134 @@ +import { Button } from '@/components/ui/button.tsx'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import type { DiffChunkType, ProcessedSection } from 'shared/types.ts'; +import { Dispatch, SetStateAction } from 'react'; + +type Props = { + section: ProcessedSection; + sectionIndex: number; + setExpandedSections: Dispatch>>; +}; + +function DiffChunkSection({ + section, + sectionIndex, + setExpandedSections, +}: Props) { + const toggleExpandSection = (expandKey: string) => { + setExpandedSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(expandKey)) { + newSet.delete(expandKey); + } else { + newSet.add(expandKey); + } + return newSet; + }); + }; + + const getChunkClassName = (chunkType: DiffChunkType) => { + const baseClass = 'font-mono text-sm whitespace-pre flex w-full'; + + switch (chunkType) { + case 'Insert': + return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-900 dark:text-green-100`; + case 'Delete': + return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100`; + case 'Equal': + default: + return `${baseClass} text-muted-foreground`; + } + }; + + const getLineNumberClassName = (chunkType: DiffChunkType) => { + const baseClass = + 'flex-shrink-0 w-12 px-1.5 text-xs border-r select-none min-h-[1.25rem] flex items-center'; + + switch (chunkType) { + case 'Insert': + return `${baseClass} text-green-800 dark:text-green-200 bg-green-100 dark:bg-green-900/40 border-green-300 dark:border-green-600`; + case 'Delete': + return `${baseClass} text-red-800 dark:text-red-200 bg-red-100 dark:bg-red-900/40 border-red-300 dark:border-red-600`; + case 'Equal': + default: + return `${baseClass} text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700`; + } + }; + + const getChunkPrefix = (chunkType: DiffChunkType) => { + switch (chunkType) { + case 'Insert': + return '+'; + case 'Delete': + return '-'; + case 'Equal': + default: + return ' '; + } + }; + + if ( + section.type === 'context' && + section.lines.length === 0 && + section.expandKey + ) { + const lineCount = + parseInt(section.expandKey.split('-')[2]) - + parseInt(section.expandKey.split('-')[1]); + return ( +
+ +
+ ); + } + + return ( +
+ {section.type === 'expanded' && section.expandKey && ( +
+ +
+ )} + {section.lines.map((line, lineIndex) => ( +
+
+ + {line.oldLineNumber || ''} + + + {line.newLineNumber || ''} + +
+
+ + {getChunkPrefix(line.chunkType)} + + {line.content} +
+
+ ))} +
+ ); +} + +export default DiffChunkSection; diff --git a/frontend/src/components/tasks/TaskDetails/DiffFile.tsx b/frontend/src/components/tasks/TaskDetails/DiffFile.tsx new file mode 100644 index 00000000..90bd88ce --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/DiffFile.tsx @@ -0,0 +1,278 @@ +import { Button } from '@/components/ui/button.tsx'; +import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react'; +import DiffChunkSection from '@/components/tasks/TaskDetails/DiffChunkSection.tsx'; +import { + FileDiff, + type ProcessedLine, + type ProcessedSection, +} from 'shared/types.ts'; +import { + Dispatch, + SetStateAction, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { TaskDeletingFilesContext } from '@/components/context/taskDetailsContext.ts'; + +type Props = { + collapsedFiles: Set; + compact: boolean; + deletable: boolean; + file: FileDiff; + fileIndex: number; + setCollapsedFiles: Dispatch>>; +}; + +function DiffFile({ + collapsedFiles, + file, + deletable, + compact, + fileIndex, + setCollapsedFiles, +}: Props) { + const { deletingFiles, setFileToDelete } = useContext( + TaskDeletingFilesContext + ); + const [expandedSections, setExpandedSections] = useState>( + new Set() + ); + + const onDeleteFile = useCallback( + (filePath: string) => { + setFileToDelete(filePath); + }, + [setFileToDelete] + ); + + const toggleFileCollapse = (filePath: string) => { + setCollapsedFiles((prev) => { + const newSet = new Set(prev); + if (newSet.has(filePath)) { + newSet.delete(filePath); + } else { + newSet.add(filePath); + } + return newSet; + }); + }; + + const processedFileChunks = useMemo(() => { + const CONTEXT_LINES = compact ? 2 : 3; + const lines: ProcessedLine[] = []; + let oldLineNumber = 1; + let newLineNumber = 1; + + // Convert chunks to lines with line numbers + file.chunks.forEach((chunk) => { + const chunkLines = chunk.content.split('\n'); + chunkLines.forEach((line, index) => { + if (index < chunkLines.length - 1 || line !== '') { + const processedLine: ProcessedLine = { + content: line, + chunkType: chunk.chunk_type, + }; + + switch (chunk.chunk_type) { + case 'Equal': + processedLine.oldLineNumber = oldLineNumber++; + processedLine.newLineNumber = newLineNumber++; + break; + case 'Delete': + processedLine.oldLineNumber = oldLineNumber++; + break; + case 'Insert': + processedLine.newLineNumber = newLineNumber++; + break; + } + + lines.push(processedLine); + } + }); + }); + + const sections: ProcessedSection[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.chunkType === 'Equal') { + let nextChangeIndex = i + 1; + while ( + nextChangeIndex < lines.length && + lines[nextChangeIndex].chunkType === 'Equal' + ) { + nextChangeIndex++; + } + + const contextLength = nextChangeIndex - i; + const hasNextChange = nextChangeIndex < lines.length; + const hasPrevChange = + sections.length > 0 && + sections[sections.length - 1].type === 'change'; + + if ( + contextLength <= CONTEXT_LINES * 2 || + (!hasPrevChange && !hasNextChange) + ) { + sections.push({ + type: 'context', + lines: lines.slice(i, nextChangeIndex), + }); + } else { + if (hasPrevChange) { + sections.push({ + type: 'context', + lines: lines.slice(i, i + CONTEXT_LINES), + }); + i += CONTEXT_LINES; + } + + if (hasNextChange) { + const expandStart = hasPrevChange ? i : i + CONTEXT_LINES; + const expandEnd = nextChangeIndex - CONTEXT_LINES; + + if (expandEnd > expandStart) { + const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`; + const isExpanded = expandedSections.has(expandKey); + + if (isExpanded) { + sections.push({ + type: 'expanded', + lines: lines.slice(expandStart, expandEnd), + expandKey, + }); + } else { + sections.push({ + type: 'context', + lines: [], + expandKey, + }); + } + } + + sections.push({ + type: 'context', + lines: lines.slice( + nextChangeIndex - CONTEXT_LINES, + nextChangeIndex + ), + }); + } else if (!hasPrevChange) { + sections.push({ + type: 'context', + lines: lines.slice(i, i + CONTEXT_LINES), + }); + } + } + + i = nextChangeIndex; + } else { + const changeStart = i; + while (i < lines.length && lines[i].chunkType !== 'Equal') { + i++; + } + + sections.push({ + type: 'change', + lines: lines.slice(changeStart, i), + }); + } + } + + return sections; + }, [file.chunks, expandedSections, compact, fileIndex]); + + return ( +
+
+
+ +

+ {file.path} +

+ {collapsedFiles.has(file.path) && ( +
+ + + + {file.chunks + .filter((c) => c.chunk_type === 'Insert') + .reduce( + (acc, c) => acc + c.content.split('\n').length - 1, + 0 + )} + + + - + {file.chunks + .filter((c) => c.chunk_type === 'Delete') + .reduce( + (acc, c) => acc + c.content.split('\n').length - 1, + 0 + )} + +
+ )} +
+ {deletable && ( + + )} +
+ {!collapsedFiles.has(file.path) && ( +
+
+ {processedFileChunks.map((section, sectionIndex) => ( + + ))} +
+
+ )} +
+ ); +} + +export default DiffFile; diff --git a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx b/frontend/src/components/tasks/TaskDetails/DiffTab.tsx index 68e955dc..4c6e2991 100644 --- a/frontend/src/components/tasks/TaskDetails/DiffTab.tsx +++ b/frontend/src/components/tasks/TaskDetails/DiffTab.tsx @@ -1,9 +1,9 @@ import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx'; import { useContext } from 'react'; -import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; +import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts'; function DiffTab() { - const { diff, diffLoading, diffError } = useContext(TaskDetailsContext); + const { diff, diffLoading, diffError } = useContext(TaskDiffContext); if (diffLoading) { return ( diff --git a/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx b/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx new file mode 100644 index 00000000..7e09da99 --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx @@ -0,0 +1,379 @@ +import { useContext, useMemo, useState } from 'react'; +import { DiffCard } from './DiffCard'; +import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx'; +import { + AlertCircle, + Bot, + Brain, + CheckSquare, + ChevronRight, + ChevronUp, + Edit, + Eye, + Globe, + Plus, + Search, + Settings, + Terminal, + User, +} from 'lucide-react'; +import { + NormalizedEntry, + type NormalizedEntryType, + type WorktreeDiff, +} from 'shared/types.ts'; +import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts'; + +type Props = { + entry: NormalizedEntry; + index: number; + diffDeletable?: boolean; +}; + +const getEntryIcon = (entryType: NormalizedEntryType) => { + if (entryType.type === 'user_message') { + return ; + } + if (entryType.type === 'assistant_message') { + return ; + } + if (entryType.type === 'system_message') { + return ; + } + if (entryType.type === 'thinking') { + return ; + } + if (entryType.type === 'error_message') { + return ; + } + if (entryType.type === 'tool_use') { + const { action_type, tool_name } = entryType; + + // Special handling for TODO tools + if ( + tool_name && + (tool_name.toLowerCase() === 'todowrite' || + tool_name.toLowerCase() === 'todoread' || + tool_name.toLowerCase() === 'todo_write' || + tool_name.toLowerCase() === 'todo_read') + ) { + return ; + } + + if (action_type.action === 'file_read') { + return ; + } + if (action_type.action === 'file_write') { + return ; + } + if (action_type.action === 'command_run') { + return ; + } + if (action_type.action === 'search') { + return ; + } + if (action_type.action === 'web_fetch') { + return ; + } + if (action_type.action === 'task_create') { + return ; + } + return ; + } + return ; +}; + +const getContentClassName = (entryType: NormalizedEntryType) => { + const baseClasses = 'text-sm whitespace-pre-wrap break-words'; + + if ( + entryType.type === 'tool_use' && + entryType.action_type.action === 'command_run' + ) { + return `${baseClasses} font-mono`; + } + + if (entryType.type === 'error_message') { + return `${baseClasses} text-red-600 font-mono bg-red-50 dark:bg-red-950/20 px-2 py-1 rounded`; + } + + // Special styling for TODO lists + if ( + entryType.type === 'tool_use' && + entryType.tool_name && + (entryType.tool_name.toLowerCase() === 'todowrite' || + entryType.tool_name.toLowerCase() === 'todoread' || + entryType.tool_name.toLowerCase() === 'todo_write' || + entryType.tool_name.toLowerCase() === 'todo_read') + ) { + return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`; + } + + return baseClasses; +}; + +// Parse file path from content (handles various formats) +const parseFilePathFromContent = (content: string): string | null => { + // Try to extract path from backticks: `path/to/file.ext` + const backtickMatch = content.match(/`([^`]+)`/); + if (backtickMatch) { + return backtickMatch[1]; + } + + // Try to extract from common patterns like "Edit file: path" or "Write file: path" + const actionMatch = content.match( + /(?:Edit|Write|Create)\s+file:\s*([^\s\n]+)/i + ); + if (actionMatch) { + return actionMatch[1]; + } + + return null; +}; + +// Helper function to determine if a tool call modifies files +const isFileModificationToolCall = ( + entryType: NormalizedEntryType +): boolean => { + if (entryType.type !== 'tool_use') { + return false; + } + + // Check for direct file write action + if (entryType.action_type.action === 'file_write') { + return true; + } + + // Check for "other" actions that are file modification tools + if (entryType.action_type.action === 'other') { + const fileModificationTools = [ + 'edit', + 'write', + 'create_file', + 'multiedit', + 'edit_file', + ]; + return fileModificationTools.includes( + entryType.tool_name?.toLowerCase() || '' + ); + } + + return false; +}; + +// Extract file path from tool call +const extractFilePathFromToolCall = (entry: NormalizedEntry): string | null => { + if (entry.entry_type.type !== 'tool_use') { + return null; + } + + const { action_type, tool_name } = entry.entry_type; + + // Direct path extraction from action_type + if (action_type.action === 'file_write') { + return action_type.path || null; + } + + // For "other" actions, check if it's a known file modification tool + if (action_type.action === 'other') { + const fileModificationTools = [ + 'edit', + 'write', + 'create_file', + 'multiedit', + 'edit_file', + ]; + + if (fileModificationTools.includes(tool_name.toLowerCase())) { + // Parse file path from content field + return parseFilePathFromContent(entry.content); + } + } + + return null; +}; + +// Create filtered diff showing only specific files +const createIncrementalDiff = ( + fullDiff: WorktreeDiff | null, + targetFilePaths: string[] +): WorktreeDiff | null => { + if (!fullDiff || targetFilePaths.length === 0) { + return null; + } + + // Filter files to only include the target file paths + const filteredFiles = fullDiff.files.filter((file) => + targetFilePaths.some( + (targetPath) => + file.path === targetPath || + file.path.endsWith('/' + targetPath) || + targetPath.endsWith('/' + file.path) + ) + ); + + if (filteredFiles.length === 0) { + return null; + } + + return { + ...fullDiff, + files: filteredFiles, + }; +}; + +// Helper function to determine if content should be rendered as markdown +const shouldRenderMarkdown = (entryType: NormalizedEntryType) => { + // Render markdown for assistant messages and tool outputs that contain backticks + return ( + entryType.type === 'assistant_message' || + (entryType.type === 'tool_use' && + entryType.tool_name && + (entryType.tool_name.toLowerCase() === 'todowrite' || + entryType.tool_name.toLowerCase() === 'todoread' || + entryType.tool_name.toLowerCase() === 'todo_write' || + entryType.tool_name.toLowerCase() === 'todo_read' || + entryType.tool_name.toLowerCase() === 'glob' || + entryType.tool_name.toLowerCase() === 'ls' || + entryType.tool_name.toLowerCase() === 'list_directory' || + entryType.tool_name.toLowerCase() === 'read' || + entryType.tool_name.toLowerCase() === 'read_file' || + entryType.tool_name.toLowerCase() === 'write' || + entryType.tool_name.toLowerCase() === 'create_file' || + entryType.tool_name.toLowerCase() === 'edit' || + entryType.tool_name.toLowerCase() === 'edit_file' || + entryType.tool_name.toLowerCase() === 'multiedit' || + entryType.tool_name.toLowerCase() === 'bash' || + entryType.tool_name.toLowerCase() === 'run_command' || + entryType.tool_name.toLowerCase() === 'grep' || + entryType.tool_name.toLowerCase() === 'search' || + entryType.tool_name.toLowerCase() === 'webfetch' || + entryType.tool_name.toLowerCase() === 'web_fetch' || + entryType.tool_name.toLowerCase() === 'task')) + ); +}; + +function DisplayConversationEntry({ entry, index, diffDeletable }: Props) { + const { diff } = useContext(TaskDiffContext); + const [expandedErrors, setExpandedErrors] = useState>(new Set()); + + const toggleErrorExpansion = (index: number) => { + setExpandedErrors((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const isErrorMessage = entry.entry_type.type === 'error_message'; + const isExpanded = expandedErrors.has(index); + const hasMultipleLines = isErrorMessage && entry.content.includes('\n'); + const isFileModification = useMemo( + () => isFileModificationToolCall(entry.entry_type), + [entry.entry_type] + ); + + // Extract file path from this specific tool call + const modifiedFilePath = useMemo( + () => (isFileModification ? extractFilePathFromToolCall(entry) : null), + [isFileModification, entry] + ); + + // Create incremental diff showing only the files modified by this specific tool call + const incrementalDiff = useMemo( + () => + modifiedFilePath && diff + ? createIncrementalDiff(diff, [modifiedFilePath]) + : null, + [modifiedFilePath, diff] + ); + + // Show incremental diff for this specific file modification + const shouldShowDiff = + isFileModification && incrementalDiff && incrementalDiff.files.length > 0; + + return ( +
+
+
+ {isErrorMessage && hasMultipleLines ? ( + + ) : ( + getEntryIcon(entry.entry_type) + )} +
+
+ {isErrorMessage && hasMultipleLines ? ( +
+
+ {isExpanded ? ( + shouldRenderMarkdown(entry.entry_type) ? ( + + ) : ( + entry.content + ) + ) : ( + <> + {entry.content.split('\n')[0]} + + + )} +
+ {isExpanded && ( + + )} +
+ ) : ( +
+ {shouldRenderMarkdown(entry.entry_type) ? ( + + ) : ( + entry.content + )} +
+ )} +
+
+ + {/* Render incremental diff card inline after file modification entries */} + {shouldShowDiff && incrementalDiff && ( +
+ +
+ )} +
+ ); +} + +export default DisplayConversationEntry; diff --git a/frontend/src/components/tasks/TaskDetails/LogsTab.tsx b/frontend/src/components/tasks/TaskDetails/LogsTab.tsx index e25cbd6e..885a3ceb 100644 --- a/frontend/src/components/tasks/TaskDetails/LogsTab.tsx +++ b/frontend/src/components/tasks/TaskDetails/LogsTab.tsx @@ -1,30 +1,24 @@ 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'; +import { + TaskAttemptDataContext, + TaskAttemptLoadingContext, + TaskExecutionStateContext, + TaskSelectedAttemptContext, +} from '@/components/context/taskDetailsContext.ts'; +import Conversation from '@/components/tasks/TaskDetails/Conversation.tsx'; function LogsTab() { - const { loading, selectedAttempt, executionState, attemptData } = - useContext(TaskDetailsContext); + const { loading } = useContext(TaskAttemptLoadingContext); + const { executionState } = useContext(TaskExecutionStateContext); + const { selectedAttempt } = useContext(TaskSelectedAttemptContext); + const { attemptData } = useContext(TaskAttemptDataContext); - const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true); const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0); - const scrollContainerRef = useRef(null); const setupScrollRef = useRef(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) { @@ -32,20 +26,6 @@ function LogsTab() { } }, [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); @@ -220,101 +200,10 @@ function LogsTab() { // When coding agent is running or complete, show conversation if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) { return ( -
- {(() => { - // 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 ( -
- {mainCodingAgentProcess && ( -
- -
- )} - {followUpProcesses.map((followUpProcess) => ( -
-
- -
- ))} -
- ); - } - - return ( -
-
-

- Coding Agent Starting -

-

Initializing conversation...

-
- ); - })()} -
+ ); } diff --git a/frontend/src/components/tasks/TaskDetails/NormalizedConversationViewer.tsx b/frontend/src/components/tasks/TaskDetails/NormalizedConversationViewer.tsx index 32ef61ed..8a27b080 100644 --- a/frontend/src/components/tasks/TaskDetails/NormalizedConversationViewer.tsx +++ b/frontend/src/components/tasks/TaskDetails/NormalizedConversationViewer.tsx @@ -1,35 +1,16 @@ -import { useCallback, useContext, useEffect, useState } from 'react'; -import { - AlertCircle, - Bot, - Brain, - CheckSquare, - ChevronRight, - ChevronUp, - Edit, - Eye, - Globe, - Hammer, - Plus, - Search, - Settings, - Terminal, - ToggleLeft, - ToggleRight, - User, -} from 'lucide-react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { Bot, Hammer, ToggleLeft, ToggleRight } from 'lucide-react'; import { makeRequest } from '@/lib/api.ts'; import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx'; -import { DiffCard } from './DiffCard.tsx'; import type { ApiResponse, ExecutionProcess, NormalizedConversation, NormalizedEntry, - NormalizedEntryType, WorktreeDiff, } from 'shared/types.ts'; import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; +import DisplayConversationEntry from '@/components/tasks/TaskDetails/DisplayConversationEntry.tsx'; interface NormalizedConversationViewerProps { executionProcess: ExecutionProcess; @@ -39,88 +20,6 @@ interface NormalizedConversationViewerProps { diffDeletable?: boolean; } -const getEntryIcon = (entryType: NormalizedEntryType) => { - if (entryType.type === 'user_message') { - return ; - } - if (entryType.type === 'assistant_message') { - return ; - } - if (entryType.type === 'system_message') { - return ; - } - if (entryType.type === 'thinking') { - return ; - } - if (entryType.type === 'error_message') { - return ; - } - if (entryType.type === 'tool_use') { - const { action_type, tool_name } = entryType; - - // Special handling for TODO tools - if ( - tool_name && - (tool_name.toLowerCase() === 'todowrite' || - tool_name.toLowerCase() === 'todoread' || - tool_name.toLowerCase() === 'todo_write' || - tool_name.toLowerCase() === 'todo_read') - ) { - return ; - } - - if (action_type.action === 'file_read') { - return ; - } - if (action_type.action === 'file_write') { - return ; - } - if (action_type.action === 'command_run') { - return ; - } - if (action_type.action === 'search') { - return ; - } - if (action_type.action === 'web_fetch') { - return ; - } - if (action_type.action === 'task_create') { - return ; - } - return ; - } - return ; -}; - -const getContentClassName = (entryType: NormalizedEntryType) => { - const baseClasses = 'text-sm whitespace-pre-wrap break-words'; - - if ( - entryType.type === 'tool_use' && - entryType.action_type.action === 'command_run' - ) { - return `${baseClasses} font-mono`; - } - - if (entryType.type === 'error_message') { - return `${baseClasses} text-red-600 font-mono bg-red-50 dark:bg-red-950/20 px-2 py-1 rounded`; - } - - // Special styling for TODO lists - if ( - entryType.type === 'tool_use' && - entryType.tool_name && - (entryType.tool_name.toLowerCase() === 'todowrite' || - entryType.tool_name.toLowerCase() === 'todoread' || - entryType.tool_name.toLowerCase() === 'todo_write' || - entryType.tool_name.toLowerCase() === 'todo_read') - ) { - return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`; - } - - return baseClasses; -}; - // Configuration for Gemini message clustering const GEMINI_CLUSTERING_CONFIG = { enabled: true, @@ -212,174 +111,20 @@ const clusterGeminiMessages = ( return clustered; }; -// Helper function to determine if a tool call modifies files -const isFileModificationToolCall = ( - entryType: NormalizedEntryType -): boolean => { - if (entryType.type !== 'tool_use') { - return false; - } - - // Check for direct file write action - if (entryType.action_type.action === 'file_write') { - return true; - } - - // Check for "other" actions that are file modification tools - if (entryType.action_type.action === 'other') { - const fileModificationTools = [ - 'edit', - 'write', - 'create_file', - 'multiedit', - 'edit_file', - ]; - return fileModificationTools.includes( - entryType.tool_name?.toLowerCase() || '' - ); - } - - return false; -}; - -// Extract file path from tool call -const extractFilePathFromToolCall = (entry: NormalizedEntry): string | null => { - if (entry.entry_type.type !== 'tool_use') { - return null; - } - - const { action_type, tool_name } = entry.entry_type; - - // Direct path extraction from action_type - if (action_type.action === 'file_write') { - return action_type.path || null; - } - - // For "other" actions, check if it's a known file modification tool - if (action_type.action === 'other') { - const fileModificationTools = [ - 'edit', - 'write', - 'create_file', - 'multiedit', - 'edit_file', - ]; - - if (fileModificationTools.includes(tool_name.toLowerCase())) { - // Parse file path from content field - return parseFilePathFromContent(entry.content); - } - } - - return null; -}; - -// Parse file path from content (handles various formats) -const parseFilePathFromContent = (content: string): string | null => { - // Try to extract path from backticks: `path/to/file.ext` - const backtickMatch = content.match(/`([^`]+)`/); - if (backtickMatch) { - return backtickMatch[1]; - } - - // Try to extract from common patterns like "Edit file: path" or "Write file: path" - const actionMatch = content.match( - /(?:Edit|Write|Create)\s+file:\s*([^\s\n]+)/i - ); - if (actionMatch) { - return actionMatch[1]; - } - - return null; -}; - -// Create filtered diff showing only specific files -const createIncrementalDiff = ( - fullDiff: WorktreeDiff | null, - targetFilePaths: string[] -): WorktreeDiff | null => { - if (!fullDiff || targetFilePaths.length === 0) { - return null; - } - - // Filter files to only include the target file paths - const filteredFiles = fullDiff.files.filter((file) => - targetFilePaths.some( - (targetPath) => - file.path === targetPath || - file.path.endsWith('/' + targetPath) || - targetPath.endsWith('/' + file.path) - ) - ); - - if (filteredFiles.length === 0) { - return null; - } - - return { - ...fullDiff, - files: filteredFiles, - }; -}; - -// Helper function to determine if content should be rendered as markdown -const shouldRenderMarkdown = (entryType: NormalizedEntryType) => { - // Render markdown for assistant messages and tool outputs that contain backticks - return ( - entryType.type === 'assistant_message' || - (entryType.type === 'tool_use' && - entryType.tool_name && - (entryType.tool_name.toLowerCase() === 'todowrite' || - entryType.tool_name.toLowerCase() === 'todoread' || - entryType.tool_name.toLowerCase() === 'todo_write' || - entryType.tool_name.toLowerCase() === 'todo_read' || - entryType.tool_name.toLowerCase() === 'glob' || - entryType.tool_name.toLowerCase() === 'ls' || - entryType.tool_name.toLowerCase() === 'list_directory' || - entryType.tool_name.toLowerCase() === 'read' || - entryType.tool_name.toLowerCase() === 'read_file' || - entryType.tool_name.toLowerCase() === 'write' || - entryType.tool_name.toLowerCase() === 'create_file' || - entryType.tool_name.toLowerCase() === 'edit' || - entryType.tool_name.toLowerCase() === 'edit_file' || - entryType.tool_name.toLowerCase() === 'multiedit' || - entryType.tool_name.toLowerCase() === 'bash' || - entryType.tool_name.toLowerCase() === 'run_command' || - entryType.tool_name.toLowerCase() === 'grep' || - entryType.tool_name.toLowerCase() === 'search' || - entryType.tool_name.toLowerCase() === 'webfetch' || - entryType.tool_name.toLowerCase() === 'web_fetch' || - entryType.tool_name.toLowerCase() === 'task')) - ); -}; - export function NormalizedConversationViewer({ executionProcess, diffDeletable, onConversationUpdate, }: NormalizedConversationViewerProps) { - const { projectId, diff } = useContext(TaskDetailsContext); + const { projectId } = useContext(TaskDetailsContext); const [conversation, setConversation] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [expandedErrors, setExpandedErrors] = useState>(new Set()); const [clusteringEnabled, setClusteringEnabled] = useState( GEMINI_CLUSTERING_CONFIG.enabled ); - const toggleErrorExpansion = (index: number) => { - setExpandedErrors((prev) => { - const newSet = new Set(prev); - if (newSet.has(index)) { - newSet.delete(index); - } else { - newSet.add(index); - } - return newSet; - }); - }; - const fetchNormalizedLogs = useCallback( async (isPolling = false) => { try { @@ -449,6 +194,26 @@ export function NormalizedConversationViewer({ } }, [executionProcess.status, fetchNormalizedLogs]); + // Apply clustering for Gemini executor conversations + const isGeminiExecutor = useMemo( + () => conversation?.executor_type === 'gemini', + [conversation?.executor_type] + ); + const hasAssistantMessages = useMemo( + () => + conversation?.entries.some( + (entry) => entry.entry_type.type === 'assistant_message' + ), + [conversation?.entries] + ); + const displayEntries = useMemo( + () => + isGeminiExecutor && conversation?.entries + ? clusterGeminiMessages(conversation.entries, clusteringEnabled) + : conversation?.entries || [], + [isGeminiExecutor, conversation?.entries, clusteringEnabled] + ); + if (loading) { return (
@@ -478,15 +243,6 @@ export function NormalizedConversationViewer({ ); } - // Apply clustering for Gemini executor conversations - const isGeminiExecutor = conversation.executor_type === 'gemini'; - const hasAssistantMessages = conversation.entries.some( - (entry) => entry.entry_type.type === 'assistant_message' - ); - const displayEntries = isGeminiExecutor - ? clusterGeminiMessages(conversation.entries, clusteringEnabled) - : conversation.entries; - return (
{/* Display clustering controls for Gemini */} @@ -541,111 +297,14 @@ export function NormalizedConversationViewer({ {/* Display conversation entries */}
- {displayEntries.map((entry, index) => { - const isErrorMessage = entry.entry_type.type === 'error_message'; - const isExpanded = expandedErrors.has(index); - const hasMultipleLines = - isErrorMessage && entry.content.includes('\n'); - const isFileModification = isFileModificationToolCall( - entry.entry_type - ); - - // Extract file path from this specific tool call - const modifiedFilePath = isFileModification - ? extractFilePathFromToolCall(entry) - : null; - - // Create incremental diff showing only the files modified by this specific tool call - const incrementalDiff = - modifiedFilePath && diff - ? createIncrementalDiff(diff, [modifiedFilePath]) - : null; - - // Show incremental diff for this specific file modification - const shouldShowDiff = - isFileModification && - incrementalDiff && - incrementalDiff.files.length > 0; - - return ( -
-
-
- {isErrorMessage && hasMultipleLines ? ( - - ) : ( - getEntryIcon(entry.entry_type) - )} -
-
- {isErrorMessage && hasMultipleLines ? ( -
-
- {isExpanded ? ( - shouldRenderMarkdown(entry.entry_type) ? ( - - ) : ( - entry.content - ) - ) : ( - <> - {entry.content.split('\n')[0]} - - - )} -
- {isExpanded && ( - - )} -
- ) : ( -
- {shouldRenderMarkdown(entry.entry_type) ? ( - - ) : ( - entry.content - )} -
- )} -
-
- - {/* Render incremental diff card inline after file modification entries */} - {shouldShowDiff && incrementalDiff && ( -
- -
- )} -
- ); - })} + {displayEntries.map((entry, index) => ( + + ))}
); diff --git a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx index d5783c80..81778f94 100644 --- a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx +++ b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx @@ -1,6 +1,6 @@ import { GitCompare, MessageSquare } from 'lucide-react'; import { useContext } from 'react'; -import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; +import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts'; type Props = { activeTab: 'logs' | 'diffs'; @@ -9,7 +9,7 @@ type Props = { }; function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) { - const { diff } = useContext(TaskDetailsContext); + const { diff } = useContext(TaskDiffContext); return (
diff --git a/frontend/src/components/tasks/TaskDetailsHeader.tsx b/frontend/src/components/tasks/TaskDetailsHeader.tsx index 7800efee..65cf8ca3 100644 --- a/frontend/src/components/tasks/TaskDetailsHeader.tsx +++ b/frontend/src/components/tasks/TaskDetailsHeader.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { memo, useContext, useState } from 'react'; import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Chip } from '@/components/ui/chip'; @@ -42,7 +42,7 @@ const getTaskStatusDotColor = (status: TaskStatus): string => { } }; -export function TaskDetailsHeader({ +function TaskDetailsHeader({ onClose, onEditTask, onDeleteTask, @@ -165,3 +165,5 @@ export function TaskDetailsHeader({
); } + +export default memo(TaskDetailsHeader); diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index b1c35d8d..1bbab33e 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { TaskDetailsHeader } from './TaskDetailsHeader'; +import TaskDetailsHeader from './TaskDetailsHeader'; import { TaskFollowUpSection } from './TaskFollowUpSection'; import { EditorSelectionDialog } from './EditorSelectionDialog'; import { @@ -76,6 +76,7 @@ export function TaskDetailsPanel({ setActiveTab={setActiveTab} isOpen={isOpen} userSelectedTab={userSelectedTab} + projectHasDevScript={projectHasDevScript} > {/* Backdrop - only on smaller screens (overlay mode) */}
@@ -89,7 +90,7 @@ export function TaskDetailsPanel({ onDeleteTask={onDeleteTask} /> - + { - success: boolean; - data: T | null; - message: string | null; -} - -interface TaskDetailsToolbarProps { - projectHasDevScript?: boolean; -} +import type { ApiResponse, GitBranch, TaskAttempt } from 'shared/types'; +import { + TaskAttemptDataContext, + TaskAttemptLoadingContext, + TaskAttemptStoppingContext, + TaskDetailsContext, + TaskExecutionStateContext, + TaskSelectedAttemptContext, +} from '@/components/context/taskDetailsContext.ts'; +import CreatePRDialog from '@/components/tasks/Toolbar/CreatePRDialog.tsx'; +import CreateAttempt from '@/components/tasks/Toolbar/CreateAttempt.tsx'; +import CurrentAttempt from '@/components/tasks/Toolbar/CurrentAttempt.tsx'; const availableExecutors = [ { id: 'echo', name: 'Echo' }, @@ -74,28 +24,21 @@ const availableExecutors = [ { id: 'opencode', name: 'OpenCode' }, ]; -export function TaskDetailsToolbar({ - projectHasDevScript, -}: TaskDetailsToolbarProps) { - const { - task, - projectId, - setLoading, - setSelectedAttempt, - isStopping, - handleOpenInEditor, - isAttemptRunning, - setAttemptData, - fetchAttemptData, - fetchExecutionState, - selectedAttempt, - setIsStopping, - attemptData, - } = useContext(TaskDetailsContext); +function TaskDetailsToolbar() { + const { task, projectId } = useContext(TaskDetailsContext); + const { setLoading } = useContext(TaskAttemptLoadingContext); + const { selectedAttempt, setSelectedAttempt } = useContext( + TaskSelectedAttemptContext + ); + const { isStopping } = useContext(TaskAttemptStoppingContext); + const { fetchAttemptData, setAttemptData, isAttemptRunning } = useContext( + TaskAttemptDataContext + ); + const { fetchExecutionState } = useContext(TaskExecutionStateContext); + const [taskAttempts, setTaskAttempts] = useState([]); const { config } = useConfig(); - const [branchSearchTerm, setBranchSearchTerm] = useState(''); const [branches, setBranches] = useState([]); const [selectedBranch, setSelectedBranch] = useState(null); @@ -113,72 +56,9 @@ export function TaskDetailsToolbar({ useState(selectedExecutor); // Branch status and git operations state - const [branchStatus, setBranchStatus] = useState(null); - const [branchStatusLoading, setBranchStatusLoading] = useState(false); - const [merging, setMerging] = useState(false); - const [rebasing, setRebasing] = useState(false); const [creatingPR, setCreatingPR] = useState(false); const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); - const [prTitle, setPrTitle] = useState(''); - const [prBody, setPrBody] = useState(''); - const [prBaseBranch, setPrBaseBranch] = useState( - selectedAttempt?.base_branch || 'main' - ); const [error, setError] = useState(null); - const [showPatDialog, setShowPatDialog] = useState(false); - const [patDialogError, setPatDialogError] = useState(null); - - const [devServerDetails, setDevServerDetails] = - useState(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 = 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 { @@ -242,36 +122,6 @@ export function TaskDetailsToolbar({ } }, [taskAttempts, branches, availableExecutors]); - // Update PR base branch when selected attempt changes - useEffect(() => { - if (selectedAttempt?.base_branch) { - setPrBaseBranch(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; @@ -284,7 +134,11 @@ export function TaskDetailsToolbar({ if (response.ok) { const result: ApiResponse = await response.json(); if (result.success && result.data) { - setTaskAttempts(result.data); + setTaskAttempts((prev) => { + if (JSON.stringify(prev) === JSON.stringify(result.data)) + return prev; + return result.data || prev; + }); if (result.data.length > 0) { const latestAttempt = result.data.reduce((latest, current) => @@ -292,7 +146,11 @@ export function TaskDetailsToolbar({ ? current : latest ); - setSelectedAttempt(latestAttempt); + setSelectedAttempt((prev) => { + if (JSON.stringify(prev) === JSON.stringify(latestAttempt)) + return prev; + return latestAttempt; + }); fetchAttemptData(latestAttempt.id, latestAttempt.task_id); fetchExecutionState(latestAttempt.id, latestAttempt.task_id); } else { @@ -316,311 +174,8 @@ export function TaskDetailsToolbar({ 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 = 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; - - try { - setBranchStatusLoading(true); - const response = await makeRequest( - `/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/branch-status` - ); - - if (response.ok) { - const result: ApiResponse = await response.json(); - if (result.success && result.data) { - setBranchStatus(result.data); - } else { - setError('Failed to load branch status'); - } - } else { - setError('Failed to load branch status'); - } - } catch (err) { - setError('Failed to load branch status'); - } finally { - setBranchStatusLoading(false); - } - }, [projectId, selectedAttempt?.id, selectedAttempt?.task_id]); - - // Fetch branch status when selected attempt changes - useEffect(() => { - if (selectedAttempt) { - fetchBranchStatus(); - } - }, [selectedAttempt, fetchBranchStatus]); - - // Git operations - const handleMergeClick = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - // Directly perform merge without checking branch status - await performMerge(); - }; - - const performMerge = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - try { - setMerging(true); - const response = await makeRequest( - `/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/merge`, - { - method: 'POST', - } - ); - - if (response.ok) { - const result: ApiResponse = await response.json(); - if (result.success) { - // Refetch branch status to show updated state - fetchBranchStatus(); - } else { - setError(result.message || 'Failed to merge changes'); - } - } else { - setError('Failed to merge changes'); - } - } catch (err) { - setError('Failed to merge changes'); - } finally { - setMerging(false); - } - }; - - const handleRebaseClick = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - try { - setRebasing(true); - const response = await makeRequest( - `/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/rebase`, - { - method: 'POST', - } - ); - - if (response.ok) { - const result: ApiResponse = await response.json(); - if (result.success) { - // Refresh branch status after rebase - fetchBranchStatus(); - } else { - setError(result.message || 'Failed to rebase branch'); - } - } else { - setError('Failed to rebase branch'); - } - } catch (err) { - setError('Failed to rebase branch'); - } finally { - setRebasing(false); - } - }; - - const handleCreatePRClick = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - // If PR already exists, open it - if (selectedAttempt.pr_url) { - window.open(selectedAttempt.pr_url, '_blank'); - return; - } - - // Auto-fill with task details if available - setPrTitle(`${task.title} (vibe-kanban)`); - setPrBody(task.description || ''); - - setShowCreatePRDialog(true); - }; - - const handleConfirmCreatePR = async () => { - if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return; - - try { - setCreatingPR(true); - const response = await makeRequest( - `/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/create-pr`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - title: prTitle, - body: prBody || null, - base_branch: prBaseBranch || null, - }), - } - ); - - if (response.ok) { - const result: ApiResponse = await response.json(); - if (result.success && result.data) { - // Open the PR URL in a new tab - window.open(result.data, '_blank'); - setShowCreatePRDialog(false); - // Reset form - setPrTitle(''); - setPrBody(''); - setPrBaseBranch(selectedAttempt?.base_branch || 'main'); - } else if (result.message === 'insufficient_github_permissions') { - setShowCreatePRDialog(false); - setPatDialogError(null); - setShowPatDialog(true); - } else if (result.message === 'github_repo_not_found_or_no_access') { - setShowCreatePRDialog(false); - setPatDialogError( - 'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.' - ); - setShowPatDialog(true); - } else { - setError(result.message || 'Failed to create GitHub PR'); - } - } else if (response.status === 403) { - setShowCreatePRDialog(false); - setPatDialogError(null); - setShowPatDialog(true); - } else if (response.status === 404) { - setShowCreatePRDialog(false); - setPatDialogError( - 'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.' - ); - setShowPatDialog(true); - } else { - setError('Failed to create GitHub PR'); - } - } catch (err) { - setError('Failed to create GitHub PR'); - } finally { - setCreatingPR(false); - } - }; - - const handleCancelCreatePR = () => { - setShowCreatePRDialog(false); - // Reset form to empty state - setPrTitle(''); - setPrBody(''); - setPrBaseBranch('main'); - }; - - // Filter branches based on search term - const filteredBranches = useMemo(() => { - if (!branchSearchTerm.trim()) { - return branches; - } - return branches.filter((branch) => - branch.name.toLowerCase().includes(branchSearchTerm.toLowerCase()) - ); - }, [branches, branchSearchTerm]); - - // Get display name for selected branch - const selectedBranchDisplayName = useMemo(() => { - if (!selectedBranch) return 'current'; - - // For remote branches, show just the branch name without the remote prefix - if (selectedBranch.includes('/')) { - const parts = selectedBranch.split('/'); - return parts[parts.length - 1]; - } - return selectedBranch; - }, [selectedBranch]); - // Handle entering create attempt mode - const handleEnterCreateAttemptMode = () => { + const handleEnterCreateAttemptMode = useCallback(() => { setIsInCreateAttemptMode(true); // Use latest attempt's settings as defaults if available @@ -655,195 +210,10 @@ export function TaskDetailsToolbar({ setCreateAttemptBranch(selectedBranch); setCreateAttemptExecutor(selectedExecutor); } - }; - - // Handle exiting create attempt mode - const handleExitCreateAttemptMode = () => { - setIsInCreateAttemptMode(false); - }; - - // Handle creating the attempt - const handleCreateAttempt = () => { - onCreateNewAttempt(createAttemptExecutor, createAttemptBranch || undefined); - handleExitCreateAttemptMode(); - }; - - // Render create attempt UI - const renderCreateAttemptUI = () => ( -
-
-

Create Attempt

- {taskAttempts.length > 0 && ( - - )} -
-
- -
- -
- {/* Step 1: Choose Base Branch */} -
-
- -
- - - - - -
-
- - setBranchSearchTerm(e.target.value)} - className="pl-8" - /> -
-
- -
- {filteredBranches.length === 0 ? ( -
- No branches found -
- ) : ( - filteredBranches.map((branch) => ( - { - setCreateAttemptBranch(branch.name); - setBranchSearchTerm(''); - }} - className={ - createAttemptBranch === branch.name ? 'bg-accent' : '' - } - > -
- - {branch.name} - -
- {branch.is_current && ( - - current - - )} - {branch.is_remote && ( - - remote - - )} -
-
-
- )) - )} -
-
-
-
- - {/* Step 2: Choose Coding Agent */} -
-
- -
- - - - - - {availableExecutors.map((executor) => ( - setCreateAttemptExecutor(executor.id)} - className={ - createAttemptExecutor === executor.id ? 'bg-accent' : '' - } - > - {executor.name} - {config?.executor.type === executor.id && ' (Default)'} - - ))} - - -
- - {/* Step 3: Start Attempt */} -
- -
-
-
- ); + }, [taskAttempts, branches, selectedBranch, selectedExecutor]); return ( <> - { - setShowPatDialog(open); - if (!open) setPatDialogError(null); - }} - errorMessage={patDialogError || undefined} - />
{/* Error Display */} {error && ( @@ -853,326 +223,34 @@ export function TaskDetailsToolbar({ )} {isInCreateAttemptMode ? ( -
- {renderCreateAttemptUI()} -
+ ) : (
{/* Current Attempt Info */}
{selectedAttempt ? ( - <> -
-
-
-
- Started -
-
- {new Date( - selectedAttempt.created_at - ).toLocaleDateString()}{' '} - {new Date( - selectedAttempt.created_at - ).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} -
-
- -
-
- Agent -
-
- {availableExecutors.find( - (e) => e.id === selectedAttempt.executor - )?.name || - selectedAttempt.executor || - 'Unknown'} -
-
- -
-
- Base Branch -
-
- - - {branchStatus?.base_branch_name || - selectedBranchDisplayName} - -
-
- -
-
- Merge Status -
-
- {selectedAttempt.merge_commit ? ( -
-
- - Merged - - - ({selectedAttempt.merge_commit.slice(0, 8)}) - -
- ) : ( -
-
- - Not merged - -
- )} -
-
-
- -
-
-
- Worktree Path -
- - - - - - -

Open in editor

-
-
-
-
-
- {selectedAttempt.worktree_path} -
-
- -
-
-
setIsHoveringDevServer(true)} - onMouseLeave={() => setIsHoveringDevServer(false)} - > - - - - - - - {!projectHasDevScript ? ( -

- Configure a dev server command in project - settings -

- ) : runningDevServer && devServerDetails ? ( -
-

- Dev Server Logs (Last 10 lines): -

-
-                                      {processedDevServerLogs}
-                                    
-
- ) : runningDevServer ? ( -

Stop the running dev server

- ) : ( -

Start the dev server

- )} -
-
-
-
-
- -
- {taskAttempts.length > 1 && ( - - - - - - - - - -

View attempt history

-
-
-
- - {taskAttempts.map((attempt) => ( - handleAttemptChange(attempt)} - className={ - selectedAttempt?.id === attempt.id - ? 'bg-accent' - : '' - } - > -
- - {new Date( - attempt.created_at - ).toLocaleDateString()}{' '} - {new Date( - attempt.created_at - ).toLocaleTimeString()} - - - {attempt.executor || 'executor'} - -
-
- ))} -
-
- )} - - {/* Git Operations */} - {selectedAttempt && branchStatus && ( - <> - {branchStatus.is_behind === true && - !branchStatus.merged && ( - - )} - {!branchStatus.merged && ( - <> - - - - )} - - )} - - {isStopping || isAttemptRunning ? ( - - ) : ( - - )} -
-
-
- + ) : (
@@ -1202,78 +280,16 @@ export function TaskDetailsToolbar({ )}
- {/* Create PR Dialog */} - handleCancelCreatePR()} - > - - - Create GitHub Pull Request - - Create a pull request for this task attempt on GitHub. - - -
-
- - setPrTitle(e.target.value)} - placeholder="Enter PR title" - /> -
-
- -