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:
@@ -51,17 +51,12 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const checkToken = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/github/check');
|
||||
const data: ApiResponse<null> = await response.json();
|
||||
if (!data.success && data.message === 'github_token_invalid') {
|
||||
setGithubTokenInvalid(true);
|
||||
} else {
|
||||
setGithubTokenInvalid(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// If the check fails, assume token is invalid
|
||||
const response = await fetch('/api/auth/github/check');
|
||||
const data: ApiResponse<null> = await response.json();
|
||||
if (!data.success && data.message === 'github_token_invalid') {
|
||||
setGithubTokenInvalid(true);
|
||||
} else {
|
||||
setGithubTokenInvalid(false);
|
||||
}
|
||||
};
|
||||
checkToken();
|
||||
|
||||
@@ -22,7 +22,17 @@ import type {
|
||||
WorktreeDiff,
|
||||
} from 'shared/types.ts';
|
||||
import { makeRequest } from '@/lib/api.ts';
|
||||
import { TaskDetailsContext } from './taskDetailsContext.ts';
|
||||
import {
|
||||
TaskAttemptDataContext,
|
||||
TaskAttemptLoadingContext,
|
||||
TaskAttemptStoppingContext,
|
||||
TaskBackgroundRefreshContext,
|
||||
TaskDeletingFilesContext,
|
||||
TaskDetailsContext,
|
||||
TaskDiffContext,
|
||||
TaskExecutionStateContext,
|
||||
TaskSelectedAttemptContext,
|
||||
} from './taskDetailsContext.ts';
|
||||
|
||||
const TaskDetailsProvider: FC<{
|
||||
task: TaskWithAttemptStatus;
|
||||
@@ -33,6 +43,7 @@ const TaskDetailsProvider: FC<{
|
||||
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
|
||||
isOpen: boolean;
|
||||
userSelectedTab: boolean;
|
||||
projectHasDevScript?: boolean;
|
||||
}> = ({
|
||||
task,
|
||||
projectId,
|
||||
@@ -42,6 +53,7 @@ const TaskDetailsProvider: FC<{
|
||||
setShowEditorDialog,
|
||||
isOpen,
|
||||
userSelectedTab,
|
||||
projectHasDevScript,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
@@ -136,7 +148,11 @@ const TaskDetailsProvider: FC<{
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<TaskAttemptState> = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setExecutionState(result.data);
|
||||
setExecutionState((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(result.data))
|
||||
return prev;
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -256,10 +272,14 @@ const TaskDetailsProvider: FC<{
|
||||
}
|
||||
}
|
||||
|
||||
setAttemptData({
|
||||
activities: activitiesResult.data,
|
||||
processes: processesResult.data,
|
||||
runningProcessDetails,
|
||||
setAttemptData((prev) => {
|
||||
const newData = {
|
||||
activities: activitiesResult.data || [],
|
||||
processes: processesResult.data || [],
|
||||
runningProcessDetails,
|
||||
};
|
||||
if (JSON.stringify(prev) === JSON.stringify(newData)) return prev;
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -368,56 +388,98 @@ const TaskDetailsProvider: FC<{
|
||||
() => ({
|
||||
task,
|
||||
projectId,
|
||||
loading,
|
||||
setLoading,
|
||||
selectedAttempt,
|
||||
setSelectedAttempt,
|
||||
isStopping,
|
||||
setIsStopping,
|
||||
handleOpenInEditor,
|
||||
projectHasDevScript,
|
||||
}),
|
||||
[task, projectId, handleOpenInEditor, projectHasDevScript]
|
||||
);
|
||||
|
||||
const taskAttemptLoadingValue = useMemo(
|
||||
() => ({ loading, setLoading }),
|
||||
[loading]
|
||||
);
|
||||
|
||||
const selectedAttemptValue = useMemo(
|
||||
() => ({ selectedAttempt, setSelectedAttempt }),
|
||||
[selectedAttempt]
|
||||
);
|
||||
|
||||
const attemptStoppingValue = useMemo(
|
||||
() => ({ isStopping, setIsStopping }),
|
||||
[isStopping]
|
||||
);
|
||||
|
||||
const deletingFilesValue = useMemo(
|
||||
() => ({
|
||||
deletingFiles,
|
||||
fileToDelete,
|
||||
setFileToDelete,
|
||||
setDeletingFiles,
|
||||
fetchDiff,
|
||||
}),
|
||||
[deletingFiles, fileToDelete]
|
||||
);
|
||||
|
||||
const diffValue = useMemo(
|
||||
() => ({
|
||||
setDiffError,
|
||||
fetchDiff,
|
||||
diff,
|
||||
diffError,
|
||||
diffLoading,
|
||||
setDiffLoading,
|
||||
setDiff,
|
||||
setDiffLoading,
|
||||
}),
|
||||
[fetchDiff, diff, diffError, diffLoading]
|
||||
);
|
||||
|
||||
const backgroundRefreshingValue = useMemo(
|
||||
() => ({
|
||||
isBackgroundRefreshing,
|
||||
handleOpenInEditor,
|
||||
isAttemptRunning,
|
||||
fetchExecutionState,
|
||||
executionState,
|
||||
}),
|
||||
[isBackgroundRefreshing]
|
||||
);
|
||||
|
||||
const attemptDataValue = useMemo(
|
||||
() => ({
|
||||
attemptData,
|
||||
setAttemptData,
|
||||
fetchAttemptData,
|
||||
}),
|
||||
[
|
||||
task,
|
||||
projectId,
|
||||
loading,
|
||||
selectedAttempt,
|
||||
isStopping,
|
||||
deletingFiles,
|
||||
fileToDelete,
|
||||
fetchDiff,
|
||||
diff,
|
||||
diffError,
|
||||
diffLoading,
|
||||
isBackgroundRefreshing,
|
||||
handleOpenInEditor,
|
||||
isAttemptRunning,
|
||||
fetchExecutionState,
|
||||
executionState,
|
||||
attemptData,
|
||||
fetchAttemptData,
|
||||
]
|
||||
}),
|
||||
[attemptData, fetchAttemptData, isAttemptRunning]
|
||||
);
|
||||
|
||||
const executionStateValue = useMemo(
|
||||
() => ({
|
||||
executionState,
|
||||
fetchExecutionState,
|
||||
}),
|
||||
[executionState, fetchExecutionState]
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,36 +11,98 @@ import type {
|
||||
export interface TaskDetailsContextValue {
|
||||
task: TaskWithAttemptStatus;
|
||||
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>;
|
||||
isAttemptRunning: 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;
|
||||
projectHasDevScript?: boolean;
|
||||
}
|
||||
|
||||
export const TaskDetailsContext = createContext<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
|
||||
);
|
||||
|
||||
@@ -10,20 +10,20 @@ import { Button } from '@/components/ui/button.tsx';
|
||||
import { makeRequest } from '@/lib/api.ts';
|
||||
import { useContext } from 'react';
|
||||
import { ApiResponse } from 'shared/types.ts';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import {
|
||||
TaskDeletingFilesContext,
|
||||
TaskDetailsContext,
|
||||
TaskDiffContext,
|
||||
TaskSelectedAttemptContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
|
||||
function DeleteFileConfirmationDialog() {
|
||||
const {
|
||||
task,
|
||||
projectId,
|
||||
selectedAttempt,
|
||||
setDeletingFiles,
|
||||
fileToDelete,
|
||||
deletingFiles,
|
||||
setFileToDelete,
|
||||
fetchDiff,
|
||||
setDiffError,
|
||||
} = useContext(TaskDetailsContext);
|
||||
const { task, projectId } = useContext(TaskDetailsContext);
|
||||
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||
const { setDeletingFiles, fileToDelete, deletingFiles, setFileToDelete } =
|
||||
useContext(TaskDeletingFilesContext);
|
||||
const { fetchDiff, setDiffError } = useContext(TaskDiffContext);
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { TaskDetailsToolbar } from '@/components/tasks/TaskDetailsToolbar.tsx';
|
||||
import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx';
|
||||
|
||||
type Props = {
|
||||
projectHasDevScript?: boolean;
|
||||
};
|
||||
|
||||
function CollapsibleToolbar({ projectHasDevScript }: Props) {
|
||||
function CollapsibleToolbar() {
|
||||
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -29,11 +25,9 @@ function CollapsibleToolbar({ projectHasDevScript }: Props) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!isHeaderCollapsed && (
|
||||
<TaskDetailsToolbar projectHasDevScript={projectHasDevScript} />
|
||||
)}
|
||||
{!isHeaderCollapsed && <TaskDetailsToolbar />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollapsibleToolbar;
|
||||
export default memo(CollapsibleToolbar);
|
||||
|
||||
143
frontend/src/components/tasks/TaskDetails/Conversation.tsx
Normal file
143
frontend/src/components/tasks/TaskDetails/Conversation.tsx
Normal 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;
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useCallback, useContext, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react';
|
||||
import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
|
||||
interface ProcessedLine {
|
||||
content: string;
|
||||
chunkType: DiffChunkType;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
interface ProcessedSection {
|
||||
type: 'context' | 'change' | 'expanded';
|
||||
lines: ProcessedLine[];
|
||||
expandKey?: string;
|
||||
expandedAbove?: boolean;
|
||||
expandedBelow?: boolean;
|
||||
}
|
||||
import { GitCompare } from 'lucide-react';
|
||||
import type { WorktreeDiff } from 'shared/types.ts';
|
||||
import { TaskBackgroundRefreshContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import DiffFile from '@/components/tasks/TaskDetails/DiffFile.tsx';
|
||||
|
||||
interface DiffCardProps {
|
||||
diff: WorktreeDiff | null;
|
||||
@@ -32,212 +18,8 @@ export function DiffCard({
|
||||
compact = false,
|
||||
className = '',
|
||||
}: DiffCardProps) {
|
||||
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } =
|
||||
useContext(TaskDetailsContext);
|
||||
const { isBackgroundRefreshing } = useContext(TaskBackgroundRefreshContext);
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<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 = () => {
|
||||
if (diff) {
|
||||
@@ -312,168 +94,15 @@ export function DiffCard({
|
||||
>
|
||||
<div className="space-y-2 p-3">
|
||||
{diff.files.map((file, fileIndex) => (
|
||||
<div
|
||||
<DiffFile
|
||||
key={fileIndex}
|
||||
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">
|
||||
{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>
|
||||
collapsedFiles={collapsedFiles}
|
||||
compact={compact}
|
||||
deletable={deletable}
|
||||
file={file}
|
||||
fileIndex={fileIndex}
|
||||
setCollapsedFiles={setCollapsedFiles}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
134
frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx
Normal file
134
frontend/src/components/tasks/TaskDetails/DiffChunkSection.tsx
Normal 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;
|
||||
278
frontend/src/components/tasks/TaskDetails/DiffFile.tsx
Normal file
278
frontend/src/components/tasks/TaskDetails/DiffFile.tsx
Normal 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;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx';
|
||||
import { useContext } from 'react';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
|
||||
|
||||
function DiffTab() {
|
||||
const { diff, diffLoading, diffError } = useContext(TaskDetailsContext);
|
||||
const { diff, diffLoading, diffError } = useContext(TaskDiffContext);
|
||||
|
||||
if (diffLoading) {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
@@ -1,30 +1,24 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import {
|
||||
TaskAttemptDataContext,
|
||||
TaskAttemptLoadingContext,
|
||||
TaskExecutionStateContext,
|
||||
TaskSelectedAttemptContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
import Conversation from '@/components/tasks/TaskDetails/Conversation.tsx';
|
||||
|
||||
function LogsTab() {
|
||||
const { loading, selectedAttempt, executionState, attemptData } =
|
||||
useContext(TaskDetailsContext);
|
||||
const { loading } = useContext(TaskAttemptLoadingContext);
|
||||
const { executionState } = useContext(TaskExecutionStateContext);
|
||||
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||
const { attemptData } = useContext(TaskAttemptDataContext);
|
||||
|
||||
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
|
||||
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
|
||||
|
||||
const scrollContainerRef = useRef<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
|
||||
useEffect(() => {
|
||||
if (setupScrollRef.current) {
|
||||
@@ -32,20 +26,6 @@ function LogsTab() {
|
||||
}
|
||||
}, [attemptData.runningProcessDetails]);
|
||||
|
||||
const handleLogsScroll = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
scrollContainerRef.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
|
||||
if (isAtBottom && !shouldAutoScrollLogs) {
|
||||
setShouldAutoScrollLogs(true);
|
||||
} else if (!isAtBottom && shouldAutoScrollLogs) {
|
||||
setShouldAutoScrollLogs(false);
|
||||
}
|
||||
}
|
||||
}, [shouldAutoScrollLogs]);
|
||||
|
||||
// Callback to trigger auto-scroll when conversation updates
|
||||
const handleConversationUpdate = useCallback(() => {
|
||||
setConversationUpdateTrigger((prev) => prev + 1);
|
||||
@@ -220,101 +200,10 @@ function LogsTab() {
|
||||
// When coding agent is running or complete, show conversation
|
||||
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleLogsScroll}
|
||||
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>
|
||||
<Conversation
|
||||
conversationUpdateTrigger={conversationUpdateTrigger}
|
||||
handleConversationUpdate={handleConversationUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Brain,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Edit,
|
||||
Eye,
|
||||
Globe,
|
||||
Hammer,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Terminal,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Bot, Hammer, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
import { makeRequest } from '@/lib/api.ts';
|
||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
|
||||
import { DiffCard } from './DiffCard.tsx';
|
||||
import type {
|
||||
ApiResponse,
|
||||
ExecutionProcess,
|
||||
NormalizedConversation,
|
||||
NormalizedEntry,
|
||||
NormalizedEntryType,
|
||||
WorktreeDiff,
|
||||
} from 'shared/types.ts';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import DisplayConversationEntry from '@/components/tasks/TaskDetails/DisplayConversationEntry.tsx';
|
||||
|
||||
interface NormalizedConversationViewerProps {
|
||||
executionProcess: ExecutionProcess;
|
||||
@@ -39,88 +20,6 @@ interface NormalizedConversationViewerProps {
|
||||
diffDeletable?: boolean;
|
||||
}
|
||||
|
||||
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||
if (entryType.type === 'user_message') {
|
||||
return <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
|
||||
const GEMINI_CLUSTERING_CONFIG = {
|
||||
enabled: true,
|
||||
@@ -212,174 +111,20 @@ const clusterGeminiMessages = (
|
||||
return clustered;
|
||||
};
|
||||
|
||||
// Helper function to determine if a tool call modifies files
|
||||
const isFileModificationToolCall = (
|
||||
entryType: NormalizedEntryType
|
||||
): boolean => {
|
||||
if (entryType.type !== 'tool_use') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for direct file write action
|
||||
if (entryType.action_type.action === 'file_write') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "other" actions that are file modification tools
|
||||
if (entryType.action_type.action === 'other') {
|
||||
const fileModificationTools = [
|
||||
'edit',
|
||||
'write',
|
||||
'create_file',
|
||||
'multiedit',
|
||||
'edit_file',
|
||||
];
|
||||
return fileModificationTools.includes(
|
||||
entryType.tool_name?.toLowerCase() || ''
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Extract file path from tool call
|
||||
const extractFilePathFromToolCall = (entry: NormalizedEntry): string | null => {
|
||||
if (entry.entry_type.type !== 'tool_use') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { action_type, tool_name } = entry.entry_type;
|
||||
|
||||
// Direct path extraction from action_type
|
||||
if (action_type.action === 'file_write') {
|
||||
return action_type.path || null;
|
||||
}
|
||||
|
||||
// For "other" actions, check if it's a known file modification tool
|
||||
if (action_type.action === 'other') {
|
||||
const fileModificationTools = [
|
||||
'edit',
|
||||
'write',
|
||||
'create_file',
|
||||
'multiedit',
|
||||
'edit_file',
|
||||
];
|
||||
|
||||
if (fileModificationTools.includes(tool_name.toLowerCase())) {
|
||||
// Parse file path from content field
|
||||
return parseFilePathFromContent(entry.content);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Parse file path from content (handles various formats)
|
||||
const parseFilePathFromContent = (content: string): string | null => {
|
||||
// Try to extract path from backticks: `path/to/file.ext`
|
||||
const backtickMatch = content.match(/`([^`]+)`/);
|
||||
if (backtickMatch) {
|
||||
return backtickMatch[1];
|
||||
}
|
||||
|
||||
// Try to extract from common patterns like "Edit file: path" or "Write file: path"
|
||||
const actionMatch = content.match(
|
||||
/(?:Edit|Write|Create)\s+file:\s*([^\s\n]+)/i
|
||||
);
|
||||
if (actionMatch) {
|
||||
return actionMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create filtered diff showing only specific files
|
||||
const createIncrementalDiff = (
|
||||
fullDiff: WorktreeDiff | null,
|
||||
targetFilePaths: string[]
|
||||
): WorktreeDiff | null => {
|
||||
if (!fullDiff || targetFilePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter files to only include the target file paths
|
||||
const filteredFiles = fullDiff.files.filter((file) =>
|
||||
targetFilePaths.some(
|
||||
(targetPath) =>
|
||||
file.path === targetPath ||
|
||||
file.path.endsWith('/' + targetPath) ||
|
||||
targetPath.endsWith('/' + file.path)
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...fullDiff,
|
||||
files: filteredFiles,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to determine if content should be rendered as markdown
|
||||
const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
||||
// Render markdown for assistant messages and tool outputs that contain backticks
|
||||
return (
|
||||
entryType.type === 'assistant_message' ||
|
||||
(entryType.type === 'tool_use' &&
|
||||
entryType.tool_name &&
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
||||
entryType.tool_name.toLowerCase() === 'glob' ||
|
||||
entryType.tool_name.toLowerCase() === 'ls' ||
|
||||
entryType.tool_name.toLowerCase() === 'list_directory' ||
|
||||
entryType.tool_name.toLowerCase() === 'read' ||
|
||||
entryType.tool_name.toLowerCase() === 'read_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'write' ||
|
||||
entryType.tool_name.toLowerCase() === 'create_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'edit' ||
|
||||
entryType.tool_name.toLowerCase() === 'edit_file' ||
|
||||
entryType.tool_name.toLowerCase() === 'multiedit' ||
|
||||
entryType.tool_name.toLowerCase() === 'bash' ||
|
||||
entryType.tool_name.toLowerCase() === 'run_command' ||
|
||||
entryType.tool_name.toLowerCase() === 'grep' ||
|
||||
entryType.tool_name.toLowerCase() === 'search' ||
|
||||
entryType.tool_name.toLowerCase() === 'webfetch' ||
|
||||
entryType.tool_name.toLowerCase() === 'web_fetch' ||
|
||||
entryType.tool_name.toLowerCase() === 'task'))
|
||||
);
|
||||
};
|
||||
|
||||
export function NormalizedConversationViewer({
|
||||
executionProcess,
|
||||
diffDeletable,
|
||||
onConversationUpdate,
|
||||
}: NormalizedConversationViewerProps) {
|
||||
const { projectId, diff } = useContext(TaskDetailsContext);
|
||||
const { projectId } = useContext(TaskDetailsContext);
|
||||
const [conversation, setConversation] =
|
||||
useState<NormalizedConversation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
|
||||
const [clusteringEnabled, setClusteringEnabled] = useState(
|
||||
GEMINI_CLUSTERING_CONFIG.enabled
|
||||
);
|
||||
|
||||
const toggleErrorExpansion = (index: number) => {
|
||||
setExpandedErrors((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchNormalizedLogs = useCallback(
|
||||
async (isPolling = false) => {
|
||||
try {
|
||||
@@ -449,6 +194,26 @@ export function NormalizedConversationViewer({
|
||||
}
|
||||
}, [executionProcess.status, fetchNormalizedLogs]);
|
||||
|
||||
// Apply clustering for Gemini executor conversations
|
||||
const isGeminiExecutor = useMemo(
|
||||
() => conversation?.executor_type === 'gemini',
|
||||
[conversation?.executor_type]
|
||||
);
|
||||
const hasAssistantMessages = useMemo(
|
||||
() =>
|
||||
conversation?.entries.some(
|
||||
(entry) => entry.entry_type.type === 'assistant_message'
|
||||
),
|
||||
[conversation?.entries]
|
||||
);
|
||||
const displayEntries = useMemo(
|
||||
() =>
|
||||
isGeminiExecutor && conversation?.entries
|
||||
? clusterGeminiMessages(conversation.entries, clusteringEnabled)
|
||||
: conversation?.entries || [],
|
||||
[isGeminiExecutor, conversation?.entries, clusteringEnabled]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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 (
|
||||
<div>
|
||||
{/* Display clustering controls for Gemini */}
|
||||
@@ -541,111 +297,14 @@ export function NormalizedConversationViewer({
|
||||
|
||||
{/* Display conversation entries */}
|
||||
<div className="space-y-2">
|
||||
{displayEntries.map((entry, index) => {
|
||||
const isErrorMessage = entry.entry_type.type === 'error_message';
|
||||
const isExpanded = expandedErrors.has(index);
|
||||
const hasMultipleLines =
|
||||
isErrorMessage && entry.content.includes('\n');
|
||||
const isFileModification = isFileModificationToolCall(
|
||||
entry.entry_type
|
||||
);
|
||||
|
||||
// Extract file path from this specific tool call
|
||||
const modifiedFilePath = isFileModification
|
||||
? extractFilePathFromToolCall(entry)
|
||||
: null;
|
||||
|
||||
// Create incremental diff showing only the files modified by this specific tool call
|
||||
const incrementalDiff =
|
||||
modifiedFilePath && diff
|
||||
? createIncrementalDiff(diff, [modifiedFilePath])
|
||||
: null;
|
||||
|
||||
// Show incremental diff for this specific file modification
|
||||
const shouldShowDiff =
|
||||
isFileModification &&
|
||||
incrementalDiff &&
|
||||
incrementalDiff.files.length > 0;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{displayEntries.map((entry, index) => (
|
||||
<DisplayConversationEntry
|
||||
key={index}
|
||||
entry={entry}
|
||||
index={index}
|
||||
diffDeletable={diffDeletable}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GitCompare, MessageSquare } from 'lucide-react';
|
||||
import { useContext } from 'react';
|
||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
|
||||
|
||||
type Props = {
|
||||
activeTab: 'logs' | 'diffs';
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
|
||||
const { diff } = useContext(TaskDetailsContext);
|
||||
const { diff } = useContext(TaskDiffContext);
|
||||
return (
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="flex px-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { memo, useContext, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Chip } from '@/components/ui/chip';
|
||||
@@ -42,7 +42,7 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export function TaskDetailsHeader({
|
||||
function TaskDetailsHeader({
|
||||
onClose,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
@@ -165,3 +165,5 @@ export function TaskDetailsHeader({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TaskDetailsHeader);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TaskDetailsHeader } from './TaskDetailsHeader';
|
||||
import TaskDetailsHeader from './TaskDetailsHeader';
|
||||
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
||||
import { EditorSelectionDialog } from './EditorSelectionDialog';
|
||||
import {
|
||||
@@ -76,6 +76,7 @@ export function TaskDetailsPanel({
|
||||
setActiveTab={setActiveTab}
|
||||
isOpen={isOpen}
|
||||
userSelectedTab={userSelectedTab}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
<div className={getBackdropClasses()} onClick={onClose} />
|
||||
@@ -89,7 +90,7 @@ export function TaskDetailsPanel({
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
|
||||
<CollapsibleToolbar projectHasDevScript={projectHasDevScript} />
|
||||
<CollapsibleToolbar />
|
||||
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
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() {
|
||||
const {
|
||||
task,
|
||||
projectId,
|
||||
selectedAttempt,
|
||||
isAttemptRunning,
|
||||
attemptData,
|
||||
fetchAttemptData,
|
||||
} = useContext(TaskDetailsContext);
|
||||
const { task, projectId } = useContext(TaskDetailsContext);
|
||||
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||
const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
|
||||
TaskAttemptDataContext
|
||||
);
|
||||
|
||||
const [followUpMessage, setFollowUpMessage] = useState('');
|
||||
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
|
||||
const [followUpError, setFollowUpError] = useState<string | null>(null);
|
||||
|
||||
271
frontend/src/components/tasks/Toolbar/CreateAttempt.tsx
Normal file
271
frontend/src/components/tasks/Toolbar/CreateAttempt.tsx
Normal 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;
|
||||
235
frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx
Normal file
235
frontend/src/components/tasks/Toolbar/CreatePRDialog.tsx
Normal 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;
|
||||
634
frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
Normal file
634
frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx
Normal 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;
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { makeRequest } from '@/lib/api';
|
||||
import { ApiResponse } from 'shared/types.ts';
|
||||
|
||||
interface FileSearchResult {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
interface FileSearchTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
|
||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateTaskAndStart,
|
||||
ExecutorConfig,
|
||||
ProjectWithBranch,
|
||||
@@ -26,12 +27,6 @@ import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
||||
|
||||
type Task = TaskWithAttemptStatus;
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { projectId, taskId } = useParams<{
|
||||
projectId: string;
|
||||
@@ -52,12 +47,12 @@ export function ProjectTasks() {
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
||||
// Define task creation handler
|
||||
const handleCreateNewTask = () => {
|
||||
const handleCreateNewTask = useCallback(() => {
|
||||
setEditingTask(null);
|
||||
setIsTaskDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenInIDE = async () => {
|
||||
const handleOpenInIDE = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
@@ -79,7 +74,7 @@ export function ProjectTasks() {
|
||||
console.error('Failed to open project in IDE:', error);
|
||||
setError('Failed to open project in IDE');
|
||||
}
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
@@ -110,7 +105,10 @@ export function ProjectTasks() {
|
||||
if (taskId && tasks.length > 0) {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setSelectedTask(task);
|
||||
setSelectedTask((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(task)) return prev;
|
||||
return task;
|
||||
});
|
||||
setIsPanelOpen(true);
|
||||
}
|
||||
}
|
||||
@@ -154,19 +152,19 @@ export function ProjectTasks() {
|
||||
return prevTasks; // Return same reference to prevent re-render
|
||||
}
|
||||
|
||||
// Update selectedTask if it exists and has been modified
|
||||
if (selectedTask) {
|
||||
setSelectedTask((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const updatedSelectedTask = newTasks.find(
|
||||
(task) => task.id === selectedTask.id
|
||||
(task) => task.id === prev.id
|
||||
);
|
||||
|
||||
if (
|
||||
updatedSelectedTask &&
|
||||
JSON.stringify(selectedTask) !==
|
||||
JSON.stringify(updatedSelectedTask)
|
||||
) {
|
||||
setSelectedTask(updatedSelectedTask);
|
||||
}
|
||||
}
|
||||
JSON.stringify(prev) === JSON.stringify(updatedSelectedTask)
|
||||
)
|
||||
return prev;
|
||||
return updatedSelectedTask || prev;
|
||||
});
|
||||
|
||||
return newTasks;
|
||||
});
|
||||
@@ -182,173 +180,190 @@ export function ProjectTasks() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectId, selectedTask]
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleCreateTask = async (title: string, description: string) => {
|
||||
try {
|
||||
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
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`,
|
||||
{
|
||||
const handleCreateTask = useCallback(
|
||||
async (title: string, description: string) => {
|
||||
try {
|
||||
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
|
||||
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({
|
||||
project_id: projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
status,
|
||||
}),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks();
|
||||
setEditingTask(null);
|
||||
} else {
|
||||
if (response.ok) {
|
||||
await fetchTasks();
|
||||
} 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');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to update task');
|
||||
}
|
||||
};
|
||||
},
|
||||
[projectId, editingTask, fetchTasks]
|
||||
);
|
||||
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
const handleDeleteTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
try {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${taskId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
try {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${taskId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks();
|
||||
} else {
|
||||
setError('Failed to delete task');
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await fetchTasks();
|
||||
} else {
|
||||
} catch (err) {
|
||||
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);
|
||||
setIsTaskDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleViewTaskDetails = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setIsPanelOpen(true);
|
||||
// Update URL to include task ID
|
||||
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
|
||||
};
|
||||
const handleViewTaskDetails = useCallback(
|
||||
(task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setIsPanelOpen(true);
|
||||
// Update URL to include task ID
|
||||
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
|
||||
},
|
||||
[projectId, navigate]
|
||||
);
|
||||
|
||||
const handleClosePanel = () => {
|
||||
const handleClosePanel = useCallback(() => {
|
||||
setIsPanelOpen(false);
|
||||
setSelectedTask(null);
|
||||
// Remove task ID from URL when closing panel
|
||||
navigate(`/projects/${projectId}/tasks`, { replace: true });
|
||||
};
|
||||
}, [projectId, navigate]);
|
||||
|
||||
const handleProjectSettingsSuccess = () => {
|
||||
const handleProjectSettingsSuccess = useCallback(() => {
|
||||
setIsProjectSettingsOpen(false);
|
||||
fetchProject(); // Refresh project data after settings change
|
||||
};
|
||||
}, [fetchProject]);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const handleDragEnd = useCallback(
|
||||
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 newStatus = over.id as Task['status'];
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as Task['status'];
|
||||
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
|
||||
const previousStatus = task.status;
|
||||
setTasks((prev) =>
|
||||
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,
|
||||
}),
|
||||
}
|
||||
// Optimistically update the UI immediately
|
||||
const previousStatus = task.status;
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||
);
|
||||
|
||||
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
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -357,16 +372,9 @@ export function ProjectTasks() {
|
||||
);
|
||||
setError('Failed to update task status');
|
||||
}
|
||||
} catch (err) {
|
||||
// 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');
|
||||
}
|
||||
};
|
||||
},
|
||||
[projectId, tasks]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading tasks...</div>;
|
||||
@@ -375,6 +383,7 @@ export function ProjectTasks() {
|
||||
if (error) {
|
||||
return <div className="text-center py-8 text-destructive">{error}</div>;
|
||||
}
|
||||
console.log('selectedTask', selectedTask);
|
||||
|
||||
return (
|
||||
<div className={getMainContainerClasses(isPanelOpen)}>
|
||||
|
||||
@@ -198,6 +198,21 @@ export type DiffChunkType = "Equal" | "Insert" | "Delete";
|
||||
|
||||
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 WorktreeDiff = { files: Array<FileDiff>, };
|
||||
|
||||
Reference in New Issue
Block a user