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
This commit is contained in:
Anastasiia Solop
2025-07-11 19:27:33 +02:00
committed by GitHub
parent 2454739d6b
commit 0d3a7a18f8
24 changed files with 2630 additions and 2226 deletions

View File

@@ -51,17 +51,12 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
const checkToken = async () => { const checkToken = async () => {
try { const response = await fetch('/api/auth/github/check');
const response = await fetch('/api/auth/github/check'); const data: ApiResponse<null> = await response.json();
const data: ApiResponse<null> = await response.json(); if (!data.success && data.message === 'github_token_invalid') {
if (!data.success && data.message === 'github_token_invalid') {
setGithubTokenInvalid(true);
} else {
setGithubTokenInvalid(false);
}
} catch (err) {
// If the check fails, assume token is invalid
setGithubTokenInvalid(true); setGithubTokenInvalid(true);
} else {
setGithubTokenInvalid(false);
} }
}; };
checkToken(); checkToken();

View File

@@ -22,7 +22,17 @@ import type {
WorktreeDiff, WorktreeDiff,
} from 'shared/types.ts'; } from 'shared/types.ts';
import { makeRequest } from '@/lib/api.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<{ const TaskDetailsProvider: FC<{
task: TaskWithAttemptStatus; task: TaskWithAttemptStatus;
@@ -33,6 +43,7 @@ const TaskDetailsProvider: FC<{
setShowEditorDialog: Dispatch<SetStateAction<boolean>>; setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
isOpen: boolean; isOpen: boolean;
userSelectedTab: boolean; userSelectedTab: boolean;
projectHasDevScript?: boolean;
}> = ({ }> = ({
task, task,
projectId, projectId,
@@ -42,6 +53,7 @@ const TaskDetailsProvider: FC<{
setShowEditorDialog, setShowEditorDialog,
isOpen, isOpen,
userSelectedTab, userSelectedTab,
projectHasDevScript,
}) => { }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isStopping, setIsStopping] = useState(false); const [isStopping, setIsStopping] = useState(false);
@@ -136,7 +148,11 @@ const TaskDetailsProvider: FC<{
if (response.ok) { if (response.ok) {
const result: ApiResponse<TaskAttemptState> = await response.json(); const result: ApiResponse<TaskAttemptState> = await response.json();
if (result.success && result.data) { 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) { } catch (err) {
@@ -256,10 +272,14 @@ const TaskDetailsProvider: FC<{
} }
} }
setAttemptData({ setAttemptData((prev) => {
activities: activitiesResult.data, const newData = {
processes: processesResult.data, activities: activitiesResult.data || [],
runningProcessDetails, processes: processesResult.data || [],
runningProcessDetails,
};
if (JSON.stringify(prev) === JSON.stringify(newData)) return prev;
return newData;
}); });
} }
} }
@@ -368,56 +388,98 @@ const TaskDetailsProvider: FC<{
() => ({ () => ({
task, task,
projectId, projectId,
loading, handleOpenInEditor,
setLoading, projectHasDevScript,
selectedAttempt, }),
setSelectedAttempt, [task, projectId, handleOpenInEditor, projectHasDevScript]
isStopping, );
setIsStopping,
const taskAttemptLoadingValue = useMemo(
() => ({ loading, setLoading }),
[loading]
);
const selectedAttemptValue = useMemo(
() => ({ selectedAttempt, setSelectedAttempt }),
[selectedAttempt]
);
const attemptStoppingValue = useMemo(
() => ({ isStopping, setIsStopping }),
[isStopping]
);
const deletingFilesValue = useMemo(
() => ({
deletingFiles, deletingFiles,
fileToDelete, fileToDelete,
setFileToDelete, setFileToDelete,
setDeletingFiles, setDeletingFiles,
fetchDiff, }),
[deletingFiles, fileToDelete]
);
const diffValue = useMemo(
() => ({
setDiffError, setDiffError,
fetchDiff,
diff, diff,
diffError, diffError,
diffLoading, diffLoading,
setDiffLoading,
setDiff, setDiff,
setDiffLoading,
}),
[fetchDiff, diff, diffError, diffLoading]
);
const backgroundRefreshingValue = useMemo(
() => ({
isBackgroundRefreshing, isBackgroundRefreshing,
handleOpenInEditor, }),
isAttemptRunning, [isBackgroundRefreshing]
fetchExecutionState, );
executionState,
const attemptDataValue = useMemo(
() => ({
attemptData, attemptData,
setAttemptData, setAttemptData,
fetchAttemptData, fetchAttemptData,
}),
[
task,
projectId,
loading,
selectedAttempt,
isStopping,
deletingFiles,
fileToDelete,
fetchDiff,
diff,
diffError,
diffLoading,
isBackgroundRefreshing,
handleOpenInEditor,
isAttemptRunning, isAttemptRunning,
fetchExecutionState, }),
executionState, [attemptData, fetchAttemptData, isAttemptRunning]
attemptData,
fetchAttemptData,
]
); );
const executionStateValue = useMemo(
() => ({
executionState,
fetchExecutionState,
}),
[executionState, fetchExecutionState]
);
return ( return (
<TaskDetailsContext.Provider value={value}> <TaskDetailsContext.Provider value={value}>
{children} <TaskAttemptLoadingContext.Provider value={taskAttemptLoadingValue}>
<TaskSelectedAttemptContext.Provider value={selectedAttemptValue}>
<TaskAttemptStoppingContext.Provider value={attemptStoppingValue}>
<TaskDeletingFilesContext.Provider value={deletingFilesValue}>
<TaskDiffContext.Provider value={diffValue}>
<TaskAttemptDataContext.Provider value={attemptDataValue}>
<TaskExecutionStateContext.Provider
value={executionStateValue}
>
<TaskBackgroundRefreshContext.Provider
value={backgroundRefreshingValue}
>
{children}
</TaskBackgroundRefreshContext.Provider>
</TaskExecutionStateContext.Provider>
</TaskAttemptDataContext.Provider>
</TaskDiffContext.Provider>
</TaskDeletingFilesContext.Provider>
</TaskAttemptStoppingContext.Provider>
</TaskSelectedAttemptContext.Provider>
</TaskAttemptLoadingContext.Provider>
</TaskDetailsContext.Provider> </TaskDetailsContext.Provider>
); );
}; };

View File

@@ -11,36 +11,98 @@ import type {
export interface TaskDetailsContextValue { export interface TaskDetailsContextValue {
task: TaskWithAttemptStatus; task: TaskWithAttemptStatus;
projectId: string; projectId: string;
loading: boolean;
setLoading: Dispatch<SetStateAction<boolean>>;
selectedAttempt: TaskAttempt | null;
setSelectedAttempt: Dispatch<SetStateAction<TaskAttempt | null>>;
isStopping: boolean;
setIsStopping: Dispatch<SetStateAction<boolean>>;
deletingFiles: Set<string>;
setDeletingFiles: Dispatch<SetStateAction<Set<string>>>;
fileToDelete: string | null;
setFileToDelete: Dispatch<SetStateAction<string | null>>;
setDiffError: Dispatch<SetStateAction<string | null>>;
fetchDiff: (isBackgroundRefresh?: boolean) => Promise<void>;
diff: WorktreeDiff | null;
diffError: string | null;
diffLoading: boolean;
isBackgroundRefreshing: boolean;
setDiff: Dispatch<SetStateAction<WorktreeDiff | null>>;
setDiffLoading: Dispatch<SetStateAction<boolean>>;
handleOpenInEditor: (editorType?: EditorType) => Promise<void>; handleOpenInEditor: (editorType?: EditorType) => Promise<void>;
isAttemptRunning: boolean; projectHasDevScript?: boolean;
fetchExecutionState: (
attemptId: string,
taskId: string
) => Promise<void> | void;
executionState: TaskAttemptState | null;
attemptData: AttemptData;
setAttemptData: Dispatch<SetStateAction<AttemptData>>;
fetchAttemptData: (attemptId: string, taskId: string) => Promise<void> | void;
} }
export const TaskDetailsContext = createContext<TaskDetailsContextValue>( export const TaskDetailsContext = createContext<TaskDetailsContextValue>(
{} as TaskDetailsContextValue {} as TaskDetailsContextValue
); );
interface TaskAttemptLoadingContextValue {
loading: boolean;
setLoading: Dispatch<SetStateAction<boolean>>;
}
export const TaskAttemptLoadingContext =
createContext<TaskAttemptLoadingContextValue>(
{} as TaskAttemptLoadingContextValue
);
interface TaskAttemptDataContextValue {
attemptData: AttemptData;
setAttemptData: Dispatch<SetStateAction<AttemptData>>;
fetchAttemptData: (attemptId: string, taskId: string) => Promise<void> | void;
isAttemptRunning: boolean;
}
export const TaskAttemptDataContext =
createContext<TaskAttemptDataContextValue>({} as TaskAttemptDataContextValue);
interface TaskSelectedAttemptContextValue {
selectedAttempt: TaskAttempt | null;
setSelectedAttempt: Dispatch<SetStateAction<TaskAttempt | null>>;
}
export const TaskSelectedAttemptContext =
createContext<TaskSelectedAttemptContextValue>(
{} as TaskSelectedAttemptContextValue
);
interface TaskAttemptStoppingContextValue {
isStopping: boolean;
setIsStopping: Dispatch<SetStateAction<boolean>>;
}
export const TaskAttemptStoppingContext =
createContext<TaskAttemptStoppingContextValue>(
{} as TaskAttemptStoppingContextValue
);
interface TaskDeletingFilesContextValue {
deletingFiles: Set<string>;
setDeletingFiles: Dispatch<SetStateAction<Set<string>>>;
fileToDelete: string | null;
setFileToDelete: Dispatch<SetStateAction<string | null>>;
}
export const TaskDeletingFilesContext =
createContext<TaskDeletingFilesContextValue>(
{} as TaskDeletingFilesContextValue
);
interface TaskDiffContextValue {
setDiffError: Dispatch<SetStateAction<string | null>>;
fetchDiff: (isBackgroundRefresh?: boolean) => Promise<void>;
diff: WorktreeDiff | null;
diffError: string | null;
diffLoading: boolean;
setDiff: Dispatch<SetStateAction<WorktreeDiff | null>>;
setDiffLoading: Dispatch<SetStateAction<boolean>>;
}
export const TaskDiffContext = createContext<TaskDiffContextValue>(
{} as TaskDiffContextValue
);
interface TaskBackgroundRefreshContextValue {
isBackgroundRefreshing: boolean;
}
export const TaskBackgroundRefreshContext =
createContext<TaskBackgroundRefreshContextValue>(
{} as TaskBackgroundRefreshContextValue
);
interface TaskExecutionStateContextValue {
executionState: TaskAttemptState | null;
fetchExecutionState: (
attemptId: string,
taskId: string
) => Promise<void> | void;
}
export const TaskExecutionStateContext =
createContext<TaskExecutionStateContextValue>(
{} as TaskExecutionStateContextValue
);

View File

@@ -10,20 +10,20 @@ import { Button } from '@/components/ui/button.tsx';
import { makeRequest } from '@/lib/api.ts'; import { makeRequest } from '@/lib/api.ts';
import { useContext } from 'react'; import { useContext } from 'react';
import { ApiResponse } from 'shared/types.ts'; 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() { function DeleteFileConfirmationDialog() {
const { const { task, projectId } = useContext(TaskDetailsContext);
task, const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
projectId, const { setDeletingFiles, fileToDelete, deletingFiles, setFileToDelete } =
selectedAttempt, useContext(TaskDeletingFilesContext);
setDeletingFiles, const { fetchDiff, setDiffError } = useContext(TaskDiffContext);
fileToDelete,
deletingFiles,
setFileToDelete,
fetchDiff,
setDiffError,
} = useContext(TaskDetailsContext);
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id) if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
return; return;

View File

@@ -1,13 +1,9 @@
import { useState } from 'react'; import { memo, useState } from 'react';
import { Button } from '@/components/ui/button.tsx'; import { Button } from '@/components/ui/button.tsx';
import { ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp } from 'lucide-react';
import { TaskDetailsToolbar } from '@/components/tasks/TaskDetailsToolbar.tsx'; import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx';
type Props = { function CollapsibleToolbar() {
projectHasDevScript?: boolean;
};
function CollapsibleToolbar({ projectHasDevScript }: Props) {
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false); const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
return ( return (
@@ -29,11 +25,9 @@ function CollapsibleToolbar({ projectHasDevScript }: Props) {
)} )}
</Button> </Button>
</div> </div>
{!isHeaderCollapsed && ( {!isHeaderCollapsed && <TaskDetailsToolbar />}
<TaskDetailsToolbar projectHasDevScript={projectHasDevScript} />
)}
</div> </div>
); );
} }
export default CollapsibleToolbar; export default memo(CollapsibleToolbar);

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={scrollContainerRef}
onScroll={handleLogsScroll}
className="h-full overflow-y-auto"
>
{mainCodingAgentProcess || followUpProcesses.length > 0 ? (
<div className="space-y-8">
{mainCodingAgentProcess && (
<div className="space-y-6">
<NormalizedConversationViewer
executionProcess={mainCodingAgentProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
)}
{followUpProcesses.map((followUpProcess) => (
<div key={followUpProcess.id}>
<div className="border-t border-border mb-8"></div>
<NormalizedConversationViewer
executionProcess={followUpProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold mb-2">Coding Agent Starting</p>
<p>Initializing conversation...</p>
</div>
)}
</div>
);
}
export default Conversation;

View File

@@ -1,23 +1,9 @@
import { useCallback, useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { Button } from '@/components/ui/button.tsx'; import { Button } from '@/components/ui/button.tsx';
import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react'; import { GitCompare } from 'lucide-react';
import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts'; import type { WorktreeDiff } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; import { TaskBackgroundRefreshContext } from '@/components/context/taskDetailsContext.ts';
import DiffFile from '@/components/tasks/TaskDetails/DiffFile.tsx';
interface ProcessedLine {
content: string;
chunkType: DiffChunkType;
oldLineNumber?: number;
newLineNumber?: number;
}
interface ProcessedSection {
type: 'context' | 'change' | 'expanded';
lines: ProcessedLine[];
expandKey?: string;
expandedAbove?: boolean;
expandedBelow?: boolean;
}
interface DiffCardProps { interface DiffCardProps {
diff: WorktreeDiff | null; diff: WorktreeDiff | null;
@@ -32,212 +18,8 @@ export function DiffCard({
compact = false, compact = false,
className = '', className = '',
}: DiffCardProps) { }: DiffCardProps) {
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } = const { isBackgroundRefreshing } = useContext(TaskBackgroundRefreshContext);
useContext(TaskDetailsContext);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = useState<Set<string>>(
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 = () => { const collapseAllFiles = () => {
if (diff) { if (diff) {
@@ -312,168 +94,15 @@ export function DiffCard({
> >
<div className="space-y-2 p-3"> <div className="space-y-2 p-3">
{diff.files.map((file, fileIndex) => ( {diff.files.map((file, fileIndex) => (
<div <DiffFile
key={fileIndex} key={fileIndex}
className={`border rounded-lg overflow-hidden ${ collapsedFiles={collapsedFiles}
collapsedFiles.has(file.path) ? 'border-muted' : 'border-border' compact={compact}
}`} deletable={deletable}
> file={file}
<div fileIndex={fileIndex}
className={`bg-muted px-3 py-1.5 flex items-center justify-between ${ setCollapsedFiles={setCollapsedFiles}
!collapsedFiles.has(file.path) ? 'border-b' : '' />
}`}
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleFileCollapse(file.path)}
className="h-5 w-5 p-0 hover:bg-muted-foreground/10"
title={
collapsedFiles.has(file.path)
? 'Expand diff'
: 'Collapse diff'
}
>
{collapsedFiles.has(file.path) ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</Button>
<p className="text-xs font-medium text-muted-foreground font-mono">
{file.path}
</p>
{collapsedFiles.has(file.path) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-2">
<span className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 px-1 py-0.5 rounded text-xs">
+
{file.chunks
.filter((c) => c.chunk_type === 'Insert')
.reduce(
(acc, c) => acc + c.content.split('\n').length - 1,
0
)}
</span>
<span className="bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 px-1 py-0.5 rounded text-xs">
-
{file.chunks
.filter((c) => c.chunk_type === 'Delete')
.reduce(
(acc, c) => acc + c.content.split('\n').length - 1,
0
)}
</span>
</div>
)}
</div>
{deletable && (
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteFile(file.path)}
disabled={deletingFiles.has(file.path)}
className="text-red-600 hover:text-red-800 hover:bg-red-50 h-6 px-2 gap-1"
title={`Delete ${file.path}`}
>
<Trash2 className="h-3 w-3" />
{!compact && (
<span className="text-xs">
{deletingFiles.has(file.path)
? 'Deleting...'
: 'Delete'}
</span>
)}
</Button>
)}
</div>
{!collapsedFiles.has(file.path) && (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
{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 (
<div
key={`expand-${section.expandKey}`}
className="w-full"
>
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleExpandSection(section.expandKey!)
}
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
>
<ChevronDown className="h-3 w-3 mr-1" />
Show {lineCount} more lines
</Button>
</div>
);
}
return (
<div key={`section-${sectionIndex}`}>
{section.type === 'expanded' &&
section.expandKey && (
<div className="w-full">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleExpandSection(section.expandKey!)
}
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
>
<ChevronUp className="h-3 w-3 mr-1" />
Hide expanded lines
</Button>
</div>
)}
{section.lines.map((line, lineIndex) => (
<div
key={`${sectionIndex}-${lineIndex}`}
className={getChunkClassName(line.chunkType)}
style={{ minWidth: 'max-content' }}
>
<div
className={getLineNumberClassName(
line.chunkType
)}
>
<span className="inline-block w-4 text-right text-xs">
{line.oldLineNumber || ''}
</span>
<span className="inline-block w-4 text-right ml-1 text-xs">
{line.newLineNumber || ''}
</span>
</div>
<div className="flex-1 px-2 min-h-[1rem] flex items-center">
<span className="inline-block w-3 text-xs">
{getChunkPrefix(line.chunkType)}
</span>
<span className="text-xs">
{line.content}
</span>
</div>
</div>
))}
</div>
);
}
)}
</div>
</div>
)}
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -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<SetStateAction<Set<string>>>;
};
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 (
<div className="w-full">
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpandSection(section.expandKey!)}
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
>
<ChevronDown className="h-3 w-3 mr-1" />
Show {lineCount} more lines
</Button>
</div>
);
}
return (
<div>
{section.type === 'expanded' && section.expandKey && (
<div className="w-full">
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpandSection(section.expandKey!)}
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
>
<ChevronUp className="h-3 w-3 mr-1" />
Hide expanded lines
</Button>
</div>
)}
{section.lines.map((line, lineIndex) => (
<div
key={`${sectionIndex}-${lineIndex}`}
className={getChunkClassName(line.chunkType)}
style={{ minWidth: 'max-content' }}
>
<div className={getLineNumberClassName(line.chunkType)}>
<span className="inline-block w-4 text-right text-xs">
{line.oldLineNumber || ''}
</span>
<span className="inline-block w-4 text-right ml-1 text-xs">
{line.newLineNumber || ''}
</span>
</div>
<div className="flex-1 px-2 min-h-[1rem] flex items-center">
<span className="inline-block w-3 text-xs">
{getChunkPrefix(line.chunkType)}
</span>
<span className="text-xs">{line.content}</span>
</div>
</div>
))}
</div>
);
}
export default DiffChunkSection;

View File

@@ -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<string>;
compact: boolean;
deletable: boolean;
file: FileDiff;
fileIndex: number;
setCollapsedFiles: Dispatch<SetStateAction<Set<string>>>;
};
function DiffFile({
collapsedFiles,
file,
deletable,
compact,
fileIndex,
setCollapsedFiles,
}: Props) {
const { deletingFiles, setFileToDelete } = useContext(
TaskDeletingFilesContext
);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
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 (
<div
className={`border rounded-lg overflow-hidden ${
collapsedFiles.has(file.path) ? 'border-muted' : 'border-border'
}`}
>
<div
className={`bg-muted px-3 py-1.5 flex items-center justify-between ${
!collapsedFiles.has(file.path) ? 'border-b' : ''
}`}
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleFileCollapse(file.path)}
className="h-5 w-5 p-0 hover:bg-muted-foreground/10"
title={
collapsedFiles.has(file.path) ? 'Expand diff' : 'Collapse diff'
}
>
{collapsedFiles.has(file.path) ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</Button>
<p className="text-xs font-medium text-muted-foreground font-mono">
{file.path}
</p>
{collapsedFiles.has(file.path) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-2">
<span className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 px-1 py-0.5 rounded text-xs">
+
{file.chunks
.filter((c) => c.chunk_type === 'Insert')
.reduce(
(acc, c) => acc + c.content.split('\n').length - 1,
0
)}
</span>
<span className="bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 px-1 py-0.5 rounded text-xs">
-
{file.chunks
.filter((c) => c.chunk_type === 'Delete')
.reduce(
(acc, c) => acc + c.content.split('\n').length - 1,
0
)}
</span>
</div>
)}
</div>
{deletable && (
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteFile(file.path)}
disabled={deletingFiles.has(file.path)}
className="text-red-600 hover:text-red-800 hover:bg-red-50 h-6 px-2 gap-1"
title={`Delete ${file.path}`}
>
<Trash2 className="h-3 w-3" />
{!compact && (
<span className="text-xs">
{deletingFiles.has(file.path) ? 'Deleting...' : 'Delete'}
</span>
)}
</Button>
)}
</div>
{!collapsedFiles.has(file.path) && (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
{processedFileChunks.map((section, sectionIndex) => (
<DiffChunkSection
key={`expand-${sectionIndex}`}
section={section}
sectionIndex={sectionIndex}
setExpandedSections={setExpandedSections}
/>
))}
</div>
</div>
)}
</div>
);
}
export default DiffFile;

View File

@@ -1,9 +1,9 @@
import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx'; import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx';
import { useContext } from 'react'; import { useContext } from 'react';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
function DiffTab() { function DiffTab() {
const { diff, diffLoading, diffError } = useContext(TaskDetailsContext); const { diff, diffLoading, diffError } = useContext(TaskDiffContext);
if (diffLoading) { if (diffLoading) {
return ( return (

View File

@@ -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 <User className="h-4 w-4 text-blue-600" />;
}
if (entryType.type === 'assistant_message') {
return <Bot className="h-4 w-4 text-green-600" />;
}
if (entryType.type === 'system_message') {
return <Settings className="h-4 w-4 text-gray-600" />;
}
if (entryType.type === 'thinking') {
return <Brain className="h-4 w-4 text-purple-600" />;
}
if (entryType.type === 'error_message') {
return <AlertCircle className="h-4 w-4 text-red-600" />;
}
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 <CheckSquare className="h-4 w-4 text-purple-600" />;
}
if (action_type.action === 'file_read') {
return <Eye className="h-4 w-4 text-orange-600" />;
}
if (action_type.action === 'file_write') {
return <Edit className="h-4 w-4 text-red-600" />;
}
if (action_type.action === 'command_run') {
return <Terminal className="h-4 w-4 text-yellow-600" />;
}
if (action_type.action === 'search') {
return <Search className="h-4 w-4 text-indigo-600" />;
}
if (action_type.action === 'web_fetch') {
return <Globe className="h-4 w-4 text-cyan-600" />;
}
if (action_type.action === 'task_create') {
return <Plus className="h-4 w-4 text-teal-600" />;
}
return <Settings className="h-4 w-4 text-gray-600" />;
}
return <Settings className="h-4 w-4 text-gray-400" />;
};
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<Set<number>>(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 (
<div key={index}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
{isErrorMessage && hasMultipleLines ? (
<button
onClick={() => toggleErrorExpansion(index)}
className="transition-colors hover:opacity-70"
>
{getEntryIcon(entry.entry_type)}
</button>
) : (
getEntryIcon(entry.entry_type)
)}
</div>
<div className="flex-1 min-w-0">
{isErrorMessage && hasMultipleLines ? (
<div className={isExpanded ? 'space-y-2' : ''}>
<div className={getContentClassName(entry.entry_type)}>
{isExpanded ? (
shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)
) : (
<>
{entry.content.split('\n')[0]}
<button
onClick={() => toggleErrorExpansion(index)}
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronRight className="h-3 w-3" />
Show more
</button>
</>
)}
</div>
{isExpanded && (
<button
onClick={() => toggleErrorExpansion(index)}
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronUp className="h-3 w-3" />
Show less
</button>
)}
</div>
) : (
<div className={getContentClassName(entry.entry_type)}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)}
</div>
)}
</div>
</div>
{/* Render incremental diff card inline after file modification entries */}
{shouldShowDiff && incrementalDiff && (
<div className="mt-4 mb-2">
<DiffCard
diff={incrementalDiff}
deletable={diffDeletable}
compact={true}
/>
</div>
)}
</div>
);
}
export default DisplayConversationEntry;

View File

@@ -1,30 +1,24 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx'; 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() { function LogsTab() {
const { loading, selectedAttempt, executionState, attemptData } = const { loading } = useContext(TaskAttemptLoadingContext);
useContext(TaskDetailsContext); const { executionState } = useContext(TaskExecutionStateContext);
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
const { attemptData } = useContext(TaskAttemptDataContext);
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0); const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const setupScrollRef = useRef<HTMLDivElement>(null); const setupScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (shouldAutoScrollLogs && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [
attemptData.activities,
attemptData.processes,
conversationUpdateTrigger,
shouldAutoScrollLogs,
]);
// Auto-scroll setup script logs to bottom // Auto-scroll setup script logs to bottom
useEffect(() => { useEffect(() => {
if (setupScrollRef.current) { if (setupScrollRef.current) {
@@ -32,20 +26,6 @@ function LogsTab() {
} }
}, [attemptData.runningProcessDetails]); }, [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 // Callback to trigger auto-scroll when conversation updates
const handleConversationUpdate = useCallback(() => { const handleConversationUpdate = useCallback(() => {
setConversationUpdateTrigger((prev) => prev + 1); setConversationUpdateTrigger((prev) => prev + 1);
@@ -220,101 +200,10 @@ function LogsTab() {
// When coding agent is running or complete, show conversation // When coding agent is running or complete, show conversation
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) { if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
return ( return (
<div <Conversation
ref={scrollContainerRef} conversationUpdateTrigger={conversationUpdateTrigger}
onScroll={handleLogsScroll} handleConversationUpdate={handleConversationUpdate}
className="h-full overflow-y-auto" />
>
{(() => {
// Find main coding agent process (command: "executor")
let mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (!mainCodingAgentProcess) {
const mainCodingAgentSummary = attemptData.processes.find(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'executor'
);
if (mainCodingAgentSummary) {
mainCodingAgentProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === mainCodingAgentSummary.id);
if (!mainCodingAgentProcess) {
mainCodingAgentProcess = {
...mainCodingAgentSummary,
stdout: null,
stderr: null,
} as any;
}
}
}
// Find follow up executor processes (command: "followup_executor")
const followUpProcesses = attemptData.processes
.filter(
(process) =>
process.process_type === 'codingagent' &&
process.command === 'followup_executor'
)
.map((summary) => {
const detailedProcess = Object.values(
attemptData.runningProcessDetails
).find((process) => process.id === summary.id);
return (
detailedProcess ||
({
...summary,
stdout: null,
stderr: null,
} as any)
);
});
if (mainCodingAgentProcess || followUpProcesses.length > 0) {
return (
<div className="space-y-8">
{mainCodingAgentProcess && (
<div className="space-y-6">
<NormalizedConversationViewer
executionProcess={mainCodingAgentProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
)}
{followUpProcesses.map((followUpProcess) => (
<div key={followUpProcess.id}>
<div className="border-t border-border mb-8"></div>
<NormalizedConversationViewer
executionProcess={followUpProcess}
onConversationUpdate={handleConversationUpdate}
diffDeletable
/>
</div>
))}
</div>
);
}
return (
<div className="text-center py-8 text-muted-foreground">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold mb-2">
Coding Agent Starting
</p>
<p>Initializing conversation...</p>
</div>
);
})()}
</div>
); );
} }

View File

@@ -1,35 +1,16 @@
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { import { Bot, Hammer, ToggleLeft, ToggleRight } from 'lucide-react';
AlertCircle,
Bot,
Brain,
CheckSquare,
ChevronRight,
ChevronUp,
Edit,
Eye,
Globe,
Hammer,
Plus,
Search,
Settings,
Terminal,
ToggleLeft,
ToggleRight,
User,
} from 'lucide-react';
import { makeRequest } from '@/lib/api.ts'; import { makeRequest } from '@/lib/api.ts';
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx'; import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
import { DiffCard } from './DiffCard.tsx';
import type { import type {
ApiResponse, ApiResponse,
ExecutionProcess, ExecutionProcess,
NormalizedConversation, NormalizedConversation,
NormalizedEntry, NormalizedEntry,
NormalizedEntryType,
WorktreeDiff, WorktreeDiff,
} from 'shared/types.ts'; } from 'shared/types.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
import DisplayConversationEntry from '@/components/tasks/TaskDetails/DisplayConversationEntry.tsx';
interface NormalizedConversationViewerProps { interface NormalizedConversationViewerProps {
executionProcess: ExecutionProcess; executionProcess: ExecutionProcess;
@@ -39,88 +20,6 @@ interface NormalizedConversationViewerProps {
diffDeletable?: boolean; diffDeletable?: boolean;
} }
const getEntryIcon = (entryType: NormalizedEntryType) => {
if (entryType.type === 'user_message') {
return <User className="h-4 w-4 text-blue-600" />;
}
if (entryType.type === 'assistant_message') {
return <Bot className="h-4 w-4 text-green-600" />;
}
if (entryType.type === 'system_message') {
return <Settings className="h-4 w-4 text-gray-600" />;
}
if (entryType.type === 'thinking') {
return <Brain className="h-4 w-4 text-purple-600" />;
}
if (entryType.type === 'error_message') {
return <AlertCircle className="h-4 w-4 text-red-600" />;
}
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 <CheckSquare className="h-4 w-4 text-purple-600" />;
}
if (action_type.action === 'file_read') {
return <Eye className="h-4 w-4 text-orange-600" />;
}
if (action_type.action === 'file_write') {
return <Edit className="h-4 w-4 text-red-600" />;
}
if (action_type.action === 'command_run') {
return <Terminal className="h-4 w-4 text-yellow-600" />;
}
if (action_type.action === 'search') {
return <Search className="h-4 w-4 text-indigo-600" />;
}
if (action_type.action === 'web_fetch') {
return <Globe className="h-4 w-4 text-cyan-600" />;
}
if (action_type.action === 'task_create') {
return <Plus className="h-4 w-4 text-teal-600" />;
}
return <Settings className="h-4 w-4 text-gray-600" />;
}
return <Settings className="h-4 w-4 text-gray-400" />;
};
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 // Configuration for Gemini message clustering
const GEMINI_CLUSTERING_CONFIG = { const GEMINI_CLUSTERING_CONFIG = {
enabled: true, enabled: true,
@@ -212,174 +111,20 @@ const clusterGeminiMessages = (
return clustered; 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({ export function NormalizedConversationViewer({
executionProcess, executionProcess,
diffDeletable, diffDeletable,
onConversationUpdate, onConversationUpdate,
}: NormalizedConversationViewerProps) { }: NormalizedConversationViewerProps) {
const { projectId, diff } = useContext(TaskDetailsContext); const { projectId } = useContext(TaskDetailsContext);
const [conversation, setConversation] = const [conversation, setConversation] =
useState<NormalizedConversation | null>(null); useState<NormalizedConversation | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
const [clusteringEnabled, setClusteringEnabled] = useState( const [clusteringEnabled, setClusteringEnabled] = useState(
GEMINI_CLUSTERING_CONFIG.enabled 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( const fetchNormalizedLogs = useCallback(
async (isPolling = false) => { async (isPolling = false) => {
try { try {
@@ -449,6 +194,26 @@ export function NormalizedConversationViewer({
} }
}, [executionProcess.status, fetchNormalizedLogs]); }, [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) { if (loading) {
return ( return (
<div className="text-xs text-muted-foreground italic text-center"> <div className="text-xs text-muted-foreground italic text-center">
@@ -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 ( return (
<div> <div>
{/* Display clustering controls for Gemini */} {/* Display clustering controls for Gemini */}
@@ -541,111 +297,14 @@ export function NormalizedConversationViewer({
{/* Display conversation entries */} {/* Display conversation entries */}
<div className="space-y-2"> <div className="space-y-2">
{displayEntries.map((entry, index) => { {displayEntries.map((entry, index) => (
const isErrorMessage = entry.entry_type.type === 'error_message'; <DisplayConversationEntry
const isExpanded = expandedErrors.has(index); key={index}
const hasMultipleLines = entry={entry}
isErrorMessage && entry.content.includes('\n'); index={index}
const isFileModification = isFileModificationToolCall( diffDeletable={diffDeletable}
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 (
<div key={index}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
{isErrorMessage && hasMultipleLines ? (
<button
onClick={() => toggleErrorExpansion(index)}
className="transition-colors hover:opacity-70"
>
{getEntryIcon(entry.entry_type)}
</button>
) : (
getEntryIcon(entry.entry_type)
)}
</div>
<div className="flex-1 min-w-0">
{isErrorMessage && hasMultipleLines ? (
<div className={isExpanded ? 'space-y-2' : ''}>
<div className={getContentClassName(entry.entry_type)}>
{isExpanded ? (
shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)
) : (
<>
{entry.content.split('\n')[0]}
<button
onClick={() => toggleErrorExpansion(index)}
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronRight className="h-3 w-3" />
Show more
</button>
</>
)}
</div>
{isExpanded && (
<button
onClick={() => toggleErrorExpansion(index)}
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronUp className="h-3 w-3" />
Show less
</button>
)}
</div>
) : (
<div className={getContentClassName(entry.entry_type)}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)}
</div>
)}
</div>
</div>
{/* Render incremental diff card inline after file modification entries */}
{shouldShowDiff && incrementalDiff && (
<div className="mt-4 mb-2">
<DiffCard
diff={incrementalDiff}
deletable={diffDeletable}
compact={true}
/>
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { GitCompare, MessageSquare } from 'lucide-react'; import { GitCompare, MessageSquare } from 'lucide-react';
import { useContext } from 'react'; import { useContext } from 'react';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
type Props = { type Props = {
activeTab: 'logs' | 'diffs'; activeTab: 'logs' | 'diffs';
@@ -9,7 +9,7 @@ type Props = {
}; };
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) { function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
const { diff } = useContext(TaskDetailsContext); const { diff } = useContext(TaskDiffContext);
return ( return (
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="flex px-4"> <div className="flex px-4">

View File

@@ -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 { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Chip } from '@/components/ui/chip'; import { Chip } from '@/components/ui/chip';
@@ -42,7 +42,7 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
} }
}; };
export function TaskDetailsHeader({ function TaskDetailsHeader({
onClose, onClose,
onEditTask, onEditTask,
onDeleteTask, onDeleteTask,
@@ -165,3 +165,5 @@ export function TaskDetailsHeader({
</div> </div>
); );
} }
export default memo(TaskDetailsHeader);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { TaskDetailsHeader } from './TaskDetailsHeader'; import TaskDetailsHeader from './TaskDetailsHeader';
import { TaskFollowUpSection } from './TaskFollowUpSection'; import { TaskFollowUpSection } from './TaskFollowUpSection';
import { EditorSelectionDialog } from './EditorSelectionDialog'; import { EditorSelectionDialog } from './EditorSelectionDialog';
import { import {
@@ -76,6 +76,7 @@ export function TaskDetailsPanel({
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
isOpen={isOpen} isOpen={isOpen}
userSelectedTab={userSelectedTab} userSelectedTab={userSelectedTab}
projectHasDevScript={projectHasDevScript}
> >
{/* Backdrop - only on smaller screens (overlay mode) */} {/* Backdrop - only on smaller screens (overlay mode) */}
<div className={getBackdropClasses()} onClick={onClose} /> <div className={getBackdropClasses()} onClick={onClose} />
@@ -89,7 +90,7 @@ export function TaskDetailsPanel({
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
<CollapsibleToolbar projectHasDevScript={projectHasDevScript} /> <CollapsibleToolbar />
<TabNavigation <TabNavigation
activeTab={activeTab} activeTab={activeTab}

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { useContext, useMemo, useState } from 'react'; import { useContext, useMemo, useState } from 'react';
import { makeRequest } from '@/lib/api.ts'; import { makeRequest } from '@/lib/api.ts';
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts'; import {
TaskAttemptDataContext,
TaskDetailsContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
export function TaskFollowUpSection() { export function TaskFollowUpSection() {
const { const { task, projectId } = useContext(TaskDetailsContext);
task, const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
projectId, const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
selectedAttempt, TaskAttemptDataContext
isAttemptRunning, );
attemptData,
fetchAttemptData,
} = useContext(TaskDetailsContext);
const [followUpMessage, setFollowUpMessage] = useState(''); const [followUpMessage, setFollowUpMessage] = useState('');
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null); const [followUpError, setFollowUpError] = useState<string | null>(null);

View File

@@ -0,0 +1,271 @@
import { Dispatch, SetStateAction, useContext, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button.tsx';
import {
ArrowDown,
GitBranch as GitBranchIcon,
Play,
Search,
Settings2,
X,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { Input } from '@/components/ui/input.tsx';
import type { GitBranch, TaskAttempt } from 'shared/types.ts';
import { makeRequest } from '@/lib/api.ts';
import {
TaskAttemptDataContext,
TaskDetailsContext,
} from '@/components/context/taskDetailsContext.ts';
import { useConfig } from '@/components/config-provider.tsx';
type Props = {
branches: GitBranch[];
taskAttempts: TaskAttempt[];
createAttemptExecutor: string;
createAttemptBranch: string | null;
selectedExecutor: string;
selectedBranch: string | null;
fetchTaskAttempts: () => void;
setIsInCreateAttemptMode: Dispatch<SetStateAction<boolean>>;
setCreateAttemptBranch: Dispatch<SetStateAction<string | null>>;
setCreateAttemptExecutor: Dispatch<SetStateAction<string>>;
availableExecutors: {
id: string;
name: string;
}[];
};
function CreateAttempt({
branches,
taskAttempts,
createAttemptExecutor,
createAttemptBranch,
selectedExecutor,
selectedBranch,
fetchTaskAttempts,
setIsInCreateAttemptMode,
setCreateAttemptBranch,
setCreateAttemptExecutor,
availableExecutors,
}: Props) {
const { task, projectId } = useContext(TaskDetailsContext);
const { isAttemptRunning } = useContext(TaskAttemptDataContext);
const { config } = useConfig();
const [branchSearchTerm, setBranchSearchTerm] = useState('');
// 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]);
const onCreateNewAttempt = async (executor?: string, baseBranch?: string) => {
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 handleExitCreateAttemptMode = () => {
setIsInCreateAttemptMode(false);
};
const handleCreateAttempt = () => {
onCreateNewAttempt(createAttemptExecutor, createAttemptBranch || undefined);
handleExitCreateAttemptMode();
};
return (
<div className="p-4 bg-muted/20 rounded-lg border">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Create Attempt</h3>
{taskAttempts.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleExitCreateAttemptMode}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center w-4/5">
<label className="text-xs font-medium text-muted-foreground">
Each time you start an attempt, a new session is initiated with your
selected coding agent, and a git worktree and corresponding task
branch are created.
</label>
</div>
<div className="grid grid-cols-3 gap-3 items-end">
{/* Step 1: Choose Base Branch */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Base branch
</label>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
<div className="flex items-center gap-1.5">
<GitBranchIcon className="h-3 w-3" />
<span className="truncate">
{createAttemptBranch
? createAttemptBranch.includes('/')
? createAttemptBranch.split('/').pop()
: createAttemptBranch
: 'current'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80">
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
value={branchSearchTerm}
onChange={(e) => setBranchSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto">
{filteredBranches.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
No branches found
</div>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => {
setCreateAttemptBranch(branch.name);
setBranchSearchTerm('');
}}
className={
createAttemptBranch === branch.name ? 'bg-accent' : ''
}
>
<div className="flex items-center justify-between w-full">
<span
className={branch.is_current ? 'font-medium' : ''}
>
{branch.name}
</span>
<div className="flex gap-1">
{branch.is_current && (
<span className="text-xs bg-green-100 text-green-800 px-1 rounded">
current
</span>
)}
{branch.is_remote && (
<span className="text-xs bg-blue-100 text-blue-800 px-1 rounded">
remote
</span>
)}
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Step 2: Choose Coding Agent */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Coding agent
</label>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
<div className="flex items-center gap-1.5">
<Settings2 className="h-3 w-3" />
<span className="truncate">
{availableExecutors.find(
(e) => e.id === createAttemptExecutor
)?.name || 'Select agent'}
</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{availableExecutors.map((executor) => (
<DropdownMenuItem
key={executor.id}
onClick={() => setCreateAttemptExecutor(executor.id)}
className={
createAttemptExecutor === executor.id ? 'bg-accent' : ''
}
>
{executor.name}
{config?.executor.type === executor.id && ' (Default)'}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Step 3: Start Attempt */}
<div className="space-y-1">
<Button
onClick={handleCreateAttempt}
disabled={!createAttemptExecutor || isAttemptRunning}
size="sm"
className="w-full text-xs"
>
<Play className="h-3 w-3 mr-1.5" />
Start
</Button>
</div>
</div>
</div>
</div>
);
}
export default CreateAttempt;

View File

@@ -0,0 +1,235 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@radix-ui/react-label';
import { Textarea } from '@/components/ui/textarea.tsx';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@radix-ui/react-select';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCallback, useContext, useEffect, useState } from 'react';
import {
TaskDetailsContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
import { makeRequest } from '@/lib/api.ts';
import { ProvidePatDialog } from '@/components/ProvidePatDialog';
import { ApiResponse, GitBranch } from 'shared/types.ts';
type Props = {
showCreatePRDialog: boolean;
setShowCreatePRDialog: (show: boolean) => void;
creatingPR: boolean;
setCreatingPR: (creating: boolean) => void;
setError: (error: string | null) => void;
branches: GitBranch[];
};
function CreatePrDialog({
showCreatePRDialog,
setCreatingPR,
setShowCreatePRDialog,
creatingPR,
setError,
branches,
}: Props) {
const { projectId, task } = useContext(TaskDetailsContext);
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
const [prTitle, setPrTitle] = useState('');
const [prBody, setPrBody] = useState('');
const [prBaseBranch, setPrBaseBranch] = useState(
selectedAttempt?.base_branch || 'main'
);
const [showPatDialog, setShowPatDialog] = useState(false);
const [patDialogError, setPatDialogError] = useState<string | null>(null);
useEffect(() => {
if (showCreatePRDialog) {
setPrTitle(`${task.title} (vibe-kanban)`);
setPrBody(task.description || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showCreatePRDialog]);
// Update PR base branch when selected attempt changes
useEffect(() => {
if (selectedAttempt?.base_branch) {
setPrBaseBranch(selectedAttempt.base_branch);
}
}, [selectedAttempt?.base_branch]);
const handleConfirmCreatePR = useCallback(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',
body: JSON.stringify({
title: prTitle,
body: prBody || null,
base_branch: prBaseBranch || null,
}),
}
);
if (response.ok) {
const result: ApiResponse<string> = await response.json();
console.log(result);
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);
}
}, [
projectId,
selectedAttempt,
prBaseBranch,
prBody,
prTitle,
setCreatingPR,
setError,
setShowCreatePRDialog,
]);
const handleCancelCreatePR = useCallback(() => {
setShowCreatePRDialog(false);
// Reset form to empty state
setPrTitle('');
setPrBody('');
setPrBaseBranch('main');
}, [setShowCreatePRDialog]);
return (
<>
<Dialog
open={showCreatePRDialog}
onOpenChange={() => handleCancelCreatePR()}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Create GitHub Pull Request</DialogTitle>
<DialogDescription>
Create a pull request for this task attempt on GitHub.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="pr-title">Title</Label>
<Input
id="pr-title"
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
placeholder="Enter PR title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-body">Description (optional)</Label>
<Textarea
id="pr-body"
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
placeholder="Enter PR description"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pr-base">Base Branch</Label>
<Select value={prBaseBranch} onValueChange={setPrBaseBranch}>
<SelectTrigger>
<SelectValue placeholder="Select base branch" />
</SelectTrigger>
<SelectContent>
{branches
.filter((branch) => !branch.is_remote) // Only show local branches
.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
{branch.is_current && ' (current)'}
</SelectItem>
))}
{/* Add common branches as fallback if not in the list */}
{!branches.some((b) => b.name === 'main' && !b.is_remote) && (
<SelectItem value="main">main</SelectItem>
)}
{!branches.some(
(b) => b.name === 'master' && !b.is_remote
) && <SelectItem value="master">master</SelectItem>}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelCreatePR}>
Cancel
</Button>
<Button
onClick={handleConfirmCreatePR}
disabled={creatingPR || !prTitle.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
{creatingPR ? 'Creating...' : 'Create PR'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProvidePatDialog
open={showPatDialog}
onOpenChange={(open) => {
setShowPatDialog(open);
if (!open) setPatDialogError(null);
}}
errorMessage={patDialogError || undefined}
/>
</>
);
}
export default CreatePrDialog;

View File

@@ -0,0 +1,634 @@
import {
ExternalLink,
GitBranch as GitBranchIcon,
GitPullRequest,
History,
Play,
Plus,
RefreshCw,
StopCircle,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { makeRequest } from '@/lib/api.ts';
import {
Dispatch,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type {
ApiResponse,
BranchStatus,
ExecutionProcess,
TaskAttempt,
} from 'shared/types.ts';
import {
TaskAttemptDataContext,
TaskAttemptStoppingContext,
TaskDetailsContext,
TaskExecutionStateContext,
TaskSelectedAttemptContext,
} from '@/components/context/taskDetailsContext.ts';
type Props = {
setError: Dispatch<SetStateAction<string | null>>;
setShowCreatePRDialog: Dispatch<SetStateAction<boolean>>;
selectedBranch: string | null;
selectedAttempt: TaskAttempt;
taskAttempts: TaskAttempt[];
creatingPR: boolean;
handleEnterCreateAttemptMode: () => void;
availableExecutors: {
id: string;
name: string;
}[];
};
function CurrentAttempt({
setError,
setShowCreatePRDialog,
selectedBranch,
selectedAttempt,
taskAttempts,
creatingPR,
handleEnterCreateAttemptMode,
availableExecutors,
}: Props) {
const { task, projectId, handleOpenInEditor, projectHasDevScript } =
useContext(TaskDetailsContext);
const { setSelectedAttempt } = useContext(TaskSelectedAttemptContext);
const { isStopping, setIsStopping } = useContext(TaskAttemptStoppingContext);
const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
TaskAttemptDataContext
);
const { fetchExecutionState } = useContext(TaskExecutionStateContext);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [merging, setMerging] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
const [branchStatus, setBranchStatus] = useState<BranchStatus | null>(null);
const [branchStatusLoading, setBranchStatusLoading] = useState(false);
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]);
// Find running dev server in current project
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
(process) =>
process.process_type === 'devserver' && process.status === 'running'
);
}, [attemptData.processes]);
const fetchDevServerDetails = useCallback(async () => {
if (!runningDevServer || !task || !selectedAttempt) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/execution-processes/${runningDevServer.id}`
);
if (response.ok) {
const result: ApiResponse<ExecutionProcess> = await response.json();
if (result.success && result.data) {
setDevServerDetails(result.data);
}
}
} catch (err) {
console.error('Failed to fetch dev server details:', err);
}
}, [runningDevServer, task, selectedAttempt, projectId]);
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
fetchDevServerDetails();
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [isHoveringDevServer, runningDevServer, fetchDevServerDetails]);
const startDevServer = async () => {
if (!task || !selectedAttempt) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: 'POST',
}
);
if (!response.ok) {
throw new Error('Failed to start dev server');
}
const data: ApiResponse<null> = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to start dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (err) {
console.error('Failed to start dev server:', err);
} finally {
setIsStartingDevServer(false);
}
};
const stopDevServer = async () => {
if (!task || !selectedAttempt || !runningDevServer) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: 'POST',
}
);
if (!response.ok) {
throw new Error('Failed to stop dev server');
}
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
} catch (err) {
console.error('Failed to stop dev server:', err);
} finally {
setIsStartingDevServer(false);
}
};
const stopAllExecutions = async () => {
if (!task || !selectedAttempt) return;
try {
setIsStopping(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${selectedAttempt.task_id}/attempts/${selectedAttempt.id}/stop`,
{
method: 'POST',
}
);
if (response.ok) {
await fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
setTimeout(() => {
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
}, 1000);
}
} catch (err) {
console.error('Failed to stop executions:', err);
} finally {
setIsStopping(false);
}
};
const handleAttemptChange = useCallback(
(attempt: TaskAttempt) => {
setSelectedAttempt(attempt);
fetchAttemptData(attempt.id, attempt.task_id);
fetchExecutionState(attempt.id, attempt.task_id);
},
[fetchAttemptData, fetchExecutionState, setSelectedAttempt]
);
const handleMergeClick = async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
// Directly perform merge without checking branch status
await performMerge();
};
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<BranchStatus> = await response.json();
if (result.success && result.data) {
setBranchStatus((prev) => {
if (JSON.stringify(prev) === JSON.stringify(result.data))
return prev;
return 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]);
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<string> = 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<string> = 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;
}
setShowCreatePRDialog(true);
};
// 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]);
return (
<div className="space-y-2">
<div className="grid grid-cols-4 gap-3 items-start">
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Started
</div>
<div className="text-sm font-medium">
{new Date(selectedAttempt.created_at).toLocaleDateString()}{' '}
{new Date(selectedAttempt.created_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Agent
</div>
<div className="text-sm font-medium">
{availableExecutors.find((e) => e.id === selectedAttempt.executor)
?.name ||
selectedAttempt.executor ||
'Unknown'}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Base Branch
</div>
<div className="flex items-center gap-1.5">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">
{branchStatus?.base_branch_name || selectedBranchDisplayName}
</span>
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Merge Status
</div>
<div className="flex items-center gap-1.5">
{selectedAttempt.merge_commit ? (
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 bg-green-500 rounded-full" />
<span className="text-sm font-medium text-green-700">
Merged
</span>
<span className="text-xs font-mono text-muted-foreground">
({selectedAttempt.merge_commit.slice(0, 8)})
</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-700">
Not merged
</span>
</div>
)}
</div>
</div>
</div>
<div className="col-span-4">
<div className="flex items-center gap-1.5 mb-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Worktree Path
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenInEditor()}
className="h-4 w-4 p-0 hover:bg-muted"
>
<ExternalLink className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open in editor</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="text-xs font-mono text-muted-foreground bg-muted px-2 py-1 rounded break-all">
{selectedAttempt.worktree_path}
</div>
</div>
<div className="col-span-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<div
className={!projectHasDevScript ? 'cursor-not-allowed' : ''}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={runningDevServer ? 'destructive' : 'outline'}
size="sm"
onClick={runningDevServer ? stopDevServer : startDevServer}
disabled={isStartingDevServer || !projectHasDevScript}
className="gap-1"
>
{runningDevServer ? (
<>
<StopCircle className="h-3 w-3" />
Stop Dev
</>
) : (
<>
<Play className="h-3 w-3" />
Dev Server
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent
className={runningDevServer ? 'max-w-2xl p-4' : ''}
side="top"
align="center"
avoidCollisions={true}
>
{!projectHasDevScript ? (
<p>Configure a dev server command in project settings</p>
) : runningDevServer && devServerDetails ? (
<div className="space-y-2">
<p className="text-sm font-medium">
Dev Server Logs (Last 10 lines):
</p>
<pre className="text-xs bg-muted p-2 rounded max-h-64 overflow-y-auto whitespace-pre-wrap">
{processedDevServerLogs}
</pre>
</div>
) : runningDevServer ? (
<p>Stop the running dev server</p>
) : (
<p>Start the dev server</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{taskAttempts.length > 1 && (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<History className="h-4 w-4" />
History
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>View attempt history</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="start" className="w-64">
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => handleAttemptChange(attempt)}
className={
selectedAttempt?.id === attempt.id ? 'bg-accent' : ''
}
>
<div className="flex flex-col w-full">
<span className="font-medium text-sm">
{new Date(attempt.created_at).toLocaleDateString()}{' '}
{new Date(attempt.created_at).toLocaleTimeString()}
</span>
<span className="text-xs text-muted-foreground">
{attempt.executor || 'executor'}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Git Operations */}
{selectedAttempt && branchStatus && (
<>
{branchStatus.is_behind && !branchStatus.merged && (
<Button
onClick={handleRebaseClick}
disabled={rebasing || branchStatusLoading || isAttemptRunning}
variant="outline"
size="sm"
className="border-orange-300 text-orange-700 hover:bg-orange-50 gap-1"
>
<RefreshCw
className={`h-3 w-3 ${rebasing ? 'animate-spin' : ''}`}
/>
{rebasing ? 'Rebasing...' : `Rebase`}
</Button>
)}
{!branchStatus.merged && (
<>
<Button
onClick={handleCreatePRClick}
disabled={
creatingPR ||
Boolean(branchStatus.is_behind) ||
isAttemptRunning
}
variant="outline"
size="sm"
className="border-blue-300 text-blue-700 hover:bg-blue-50 gap-1"
>
<GitPullRequest className="h-3 w-3" />
{selectedAttempt.pr_url
? 'Open PR'
: creatingPR
? 'Creating...'
: 'Create PR'}
</Button>
<Button
onClick={handleMergeClick}
disabled={
merging ||
Boolean(branchStatus.is_behind) ||
isAttemptRunning
}
size="sm"
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 gap-1"
>
<GitBranchIcon className="h-3 w-3" />
{merging ? 'Merging...' : 'Merge'}
</Button>
</>
)}
</>
)}
{isStopping || isAttemptRunning ? (
<Button
variant="destructive"
size="sm"
onClick={stopAllExecutions}
disabled={isStopping}
className="gap-2"
>
<StopCircle className="h-4 w-4" />
{isStopping ? 'Stopping...' : 'Stop Attempt'}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleEnterCreateAttemptMode}
className="gap-2"
>
<Plus className="h-4 w-4" />
New Attempt
</Button>
)}
</div>
</div>
</div>
);
}
export default CurrentAttempt;

View File

@@ -1,19 +1,14 @@
import { useState, useRef, useEffect, KeyboardEvent } from 'react'; import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { makeRequest } from '@/lib/api'; import { makeRequest } from '@/lib/api';
import { ApiResponse } from 'shared/types.ts';
interface FileSearchResult { interface FileSearchResult {
path: string; path: string;
name: string; name: string;
} }
interface ApiResponse<T> {
success: boolean;
data: T | null;
message: string | null;
}
interface FileSearchTextareaProps { interface FileSearchTextareaProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;

View File

@@ -16,6 +16,7 @@ import {
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard'; import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel'; import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
import type { import type {
ApiResponse,
CreateTaskAndStart, CreateTaskAndStart,
ExecutorConfig, ExecutorConfig,
ProjectWithBranch, ProjectWithBranch,
@@ -26,12 +27,6 @@ import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
type Task = TaskWithAttemptStatus; type Task = TaskWithAttemptStatus;
interface ApiResponse<T> {
success: boolean;
data: T | null;
message: string | null;
}
export function ProjectTasks() { export function ProjectTasks() {
const { projectId, taskId } = useParams<{ const { projectId, taskId } = useParams<{
projectId: string; projectId: string;
@@ -52,12 +47,12 @@ export function ProjectTasks() {
const [isPanelOpen, setIsPanelOpen] = useState(false); const [isPanelOpen, setIsPanelOpen] = useState(false);
// Define task creation handler // Define task creation handler
const handleCreateNewTask = () => { const handleCreateNewTask = useCallback(() => {
setEditingTask(null); setEditingTask(null);
setIsTaskDialogOpen(true); setIsTaskDialogOpen(true);
}; }, []);
const handleOpenInIDE = async () => { const handleOpenInIDE = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
try { try {
@@ -79,7 +74,7 @@ export function ProjectTasks() {
console.error('Failed to open project in IDE:', error); console.error('Failed to open project in IDE:', error);
setError('Failed to open project in IDE'); setError('Failed to open project in IDE');
} }
}; }, [projectId]);
// Setup keyboard shortcuts // Setup keyboard shortcuts
useKeyboardShortcuts({ useKeyboardShortcuts({
@@ -110,7 +105,10 @@ export function ProjectTasks() {
if (taskId && tasks.length > 0) { if (taskId && tasks.length > 0) {
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (task) { if (task) {
setSelectedTask(task); setSelectedTask((prev) => {
if (JSON.stringify(prev) === JSON.stringify(task)) return prev;
return task;
});
setIsPanelOpen(true); setIsPanelOpen(true);
} }
} }
@@ -154,19 +152,19 @@ export function ProjectTasks() {
return prevTasks; // Return same reference to prevent re-render return prevTasks; // Return same reference to prevent re-render
} }
// Update selectedTask if it exists and has been modified setSelectedTask((prev) => {
if (selectedTask) { if (!prev) return prev;
const updatedSelectedTask = newTasks.find( const updatedSelectedTask = newTasks.find(
(task) => task.id === selectedTask.id (task) => task.id === prev.id
); );
if ( if (
updatedSelectedTask && JSON.stringify(prev) === JSON.stringify(updatedSelectedTask)
JSON.stringify(selectedTask) !== )
JSON.stringify(updatedSelectedTask) return prev;
) { return updatedSelectedTask || prev;
setSelectedTask(updatedSelectedTask); });
}
}
return newTasks; return newTasks;
}); });
@@ -182,173 +180,190 @@ export function ProjectTasks() {
} }
} }
}, },
[projectId, selectedTask] [projectId]
); );
const handleCreateTask = async (title: string, description: string) => { const handleCreateTask = useCallback(
try { async (title: string, description: string) => {
const response = await makeRequest(`/api/projects/${projectId}/tasks`, { try {
method: 'POST', const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
body: JSON.stringify({
project_id: projectId,
title,
description: description || null,
}),
});
if (response.ok) {
await fetchTasks();
} else {
setError('Failed to create task');
}
} catch (err) {
setError('Failed to create task');
}
};
const handleCreateAndStartTask = async (
title: string,
description: string,
executor?: ExecutorConfig
) => {
try {
const payload: CreateTaskAndStart = {
project_id: projectId!,
title,
description: description || null,
executor: executor || null,
};
const response = await makeRequest(
`/api/projects/${projectId}/tasks/create-and-start`,
{
method: 'POST', method: 'POST',
body: JSON.stringify(payload),
}
);
if (response.ok) {
const result: ApiResponse<Task> = await response.json();
if (result.success && result.data) {
await fetchTasks();
// Open the newly created task in the details panel
handleViewTaskDetails(result.data);
}
} else {
setError('Failed to create and start task');
}
} catch (err) {
setError('Failed to create and start task');
}
};
const handleUpdateTask = async (
title: string,
description: string,
status: TaskStatus
) => {
if (!editingTask) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${editingTask.id}`,
{
method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
project_id: projectId,
title, title,
description: description || null, description: description || null,
status,
}), }),
} });
);
if (response.ok) { if (response.ok) {
await fetchTasks(); await fetchTasks();
setEditingTask(null); } else {
} else { setError('Failed to create task');
}
} catch (err) {
setError('Failed to create task');
}
},
[projectId, fetchTasks]
);
const handleCreateAndStartTask = useCallback(
async (title: string, description: string, executor?: ExecutorConfig) => {
try {
const payload: CreateTaskAndStart = {
project_id: projectId!,
title,
description: description || null,
executor: executor || null,
};
const response = await makeRequest(
`/api/projects/${projectId}/tasks/create-and-start`,
{
method: 'POST',
body: JSON.stringify(payload),
}
);
if (response.ok) {
const result: ApiResponse<Task> = await response.json();
if (result.success && result.data) {
await fetchTasks();
// Open the newly created task in the details panel
handleViewTaskDetails(result.data);
}
} else {
setError('Failed to create and start task');
}
} catch (err) {
setError('Failed to create and start task');
}
},
[projectId, fetchTasks]
);
const handleUpdateTask = useCallback(
async (title: string, description: string, status: TaskStatus) => {
if (!editingTask) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${editingTask.id}`,
{
method: 'PUT',
body: JSON.stringify({
title,
description: description || null,
status,
}),
}
);
if (response.ok) {
await fetchTasks();
setEditingTask(null);
} else {
setError('Failed to update task');
}
} catch (err) {
setError('Failed to update task'); setError('Failed to update task');
} }
} catch (err) { },
setError('Failed to update task'); [projectId, editingTask, fetchTasks]
} );
};
const handleDeleteTask = async (taskId: string) => { const handleDeleteTask = useCallback(
if (!confirm('Are you sure you want to delete this task?')) return; async (taskId: string) => {
if (!confirm('Are you sure you want to delete this task?')) return;
try { try {
const response = await makeRequest( const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`, `/api/projects/${projectId}/tasks/${taskId}`,
{ {
method: 'DELETE', method: 'DELETE',
}
);
if (response.ok) {
await fetchTasks();
} else {
setError('Failed to delete task');
} }
); } catch (err) {
if (response.ok) {
await fetchTasks();
} else {
setError('Failed to delete task'); setError('Failed to delete task');
} }
} catch (err) { },
setError('Failed to delete task'); [projectId, fetchTasks]
} );
};
const handleEditTask = (task: Task) => { const handleEditTask = useCallback((task: Task) => {
setEditingTask(task); setEditingTask(task);
setIsTaskDialogOpen(true); setIsTaskDialogOpen(true);
}; }, []);
const handleViewTaskDetails = (task: Task) => { const handleViewTaskDetails = useCallback(
setSelectedTask(task); (task: Task) => {
setIsPanelOpen(true); setSelectedTask(task);
// Update URL to include task ID setIsPanelOpen(true);
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true }); // Update URL to include task ID
}; navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
},
[projectId, navigate]
);
const handleClosePanel = () => { const handleClosePanel = useCallback(() => {
setIsPanelOpen(false); setIsPanelOpen(false);
setSelectedTask(null); setSelectedTask(null);
// Remove task ID from URL when closing panel // Remove task ID from URL when closing panel
navigate(`/projects/${projectId}/tasks`, { replace: true }); navigate(`/projects/${projectId}/tasks`, { replace: true });
}; }, [projectId, navigate]);
const handleProjectSettingsSuccess = () => { const handleProjectSettingsSuccess = useCallback(() => {
setIsProjectSettingsOpen(false); setIsProjectSettingsOpen(false);
fetchProject(); // Refresh project data after settings change fetchProject(); // Refresh project data after settings change
}; }, [fetchProject]);
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = useCallback(
const { active, over } = event; async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !active.data.current) return; if (!over || !active.data.current) return;
const taskId = active.id as string; const taskId = active.id as string;
const newStatus = over.id as Task['status']; const newStatus = over.id as Task['status'];
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (!task || task.status === newStatus) return; if (!task || task.status === newStatus) return;
// Optimistically update the UI immediately // Optimistically update the UI immediately
const previousStatus = task.status; const previousStatus = task.status;
setTasks((prev) => setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)) prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'PUT',
body: JSON.stringify({
title: task.title,
description: task.description,
status: newStatus,
}),
}
); );
if (!response.ok) { try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: 'PUT',
body: JSON.stringify({
title: task.title,
description: task.description,
status: newStatus,
}),
}
);
if (!response.ok) {
// Revert the optimistic update if the API call failed
setTasks((prev) =>
prev.map((t) =>
t.id === taskId ? { ...t, status: previousStatus } : t
)
);
setError('Failed to update task status');
}
} catch (err) {
// Revert the optimistic update if the API call failed // Revert the optimistic update if the API call failed
setTasks((prev) => setTasks((prev) =>
prev.map((t) => prev.map((t) =>
@@ -357,16 +372,9 @@ export function ProjectTasks() {
); );
setError('Failed to update task status'); setError('Failed to update task status');
} }
} catch (err) { },
// Revert the optimistic update if the API call failed [projectId, tasks]
setTasks((prev) => );
prev.map((t) =>
t.id === taskId ? { ...t, status: previousStatus } : t
)
);
setError('Failed to update task status');
}
};
if (loading) { if (loading) {
return <div className="text-center py-8">Loading tasks...</div>; return <div className="text-center py-8">Loading tasks...</div>;
@@ -375,6 +383,7 @@ export function ProjectTasks() {
if (error) { if (error) {
return <div className="text-center py-8 text-destructive">{error}</div>; return <div className="text-center py-8 text-destructive">{error}</div>;
} }
console.log('selectedTask', selectedTask);
return ( return (
<div className={getMainContainerClasses(isPanelOpen)}> <div className={getMainContainerClasses(isPanelOpen)}>

View File

@@ -198,6 +198,21 @@ export type DiffChunkType = "Equal" | "Insert" | "Delete";
export type DiffChunk = { chunk_type: DiffChunkType, content: string, }; export type DiffChunk = { chunk_type: DiffChunkType, content: string, };
export interface ProcessedLine {
content: string;
chunkType: DiffChunkType;
oldLineNumber?: number;
newLineNumber?: number;
}
export interface ProcessedSection {
type: 'context' | 'change' | 'expanded';
lines: ProcessedLine[];
expandKey?: string;
expandedAbove?: boolean;
expandedBelow?: boolean;
}
export type FileDiff = { path: string, chunks: Array<DiffChunk>, }; export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
export type WorktreeDiff = { files: Array<FileDiff>, }; export type WorktreeDiff = { files: Array<FileDiff>, };