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(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const checkToken = async () => {
|
const checkToken = async () => {
|
||||||
try {
|
const response = await fetch('/api/auth/github/check');
|
||||||
const response = await fetch('/api/auth/github/check');
|
const data: ApiResponse<null> = await response.json();
|
||||||
const data: ApiResponse<null> = await response.json();
|
if (!data.success && data.message === 'github_token_invalid') {
|
||||||
if (!data.success && data.message === 'github_token_invalid') {
|
|
||||||
setGithubTokenInvalid(true);
|
|
||||||
} else {
|
|
||||||
setGithubTokenInvalid(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// If the check fails, assume token is invalid
|
|
||||||
setGithubTokenInvalid(true);
|
setGithubTokenInvalid(true);
|
||||||
|
} else {
|
||||||
|
setGithubTokenInvalid(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkToken();
|
checkToken();
|
||||||
|
|||||||
@@ -22,7 +22,17 @@ import type {
|
|||||||
WorktreeDiff,
|
WorktreeDiff,
|
||||||
} from 'shared/types.ts';
|
} from 'shared/types.ts';
|
||||||
import { makeRequest } from '@/lib/api.ts';
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
import { TaskDetailsContext } from './taskDetailsContext.ts';
|
import {
|
||||||
|
TaskAttemptDataContext,
|
||||||
|
TaskAttemptLoadingContext,
|
||||||
|
TaskAttemptStoppingContext,
|
||||||
|
TaskBackgroundRefreshContext,
|
||||||
|
TaskDeletingFilesContext,
|
||||||
|
TaskDetailsContext,
|
||||||
|
TaskDiffContext,
|
||||||
|
TaskExecutionStateContext,
|
||||||
|
TaskSelectedAttemptContext,
|
||||||
|
} from './taskDetailsContext.ts';
|
||||||
|
|
||||||
const TaskDetailsProvider: FC<{
|
const TaskDetailsProvider: FC<{
|
||||||
task: TaskWithAttemptStatus;
|
task: TaskWithAttemptStatus;
|
||||||
@@ -33,6 +43,7 @@ const TaskDetailsProvider: FC<{
|
|||||||
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
|
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
userSelectedTab: boolean;
|
userSelectedTab: boolean;
|
||||||
|
projectHasDevScript?: boolean;
|
||||||
}> = ({
|
}> = ({
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -42,6 +53,7 @@ const TaskDetailsProvider: FC<{
|
|||||||
setShowEditorDialog,
|
setShowEditorDialog,
|
||||||
isOpen,
|
isOpen,
|
||||||
userSelectedTab,
|
userSelectedTab,
|
||||||
|
projectHasDevScript,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isStopping, setIsStopping] = useState(false);
|
const [isStopping, setIsStopping] = useState(false);
|
||||||
@@ -136,7 +148,11 @@ const TaskDetailsProvider: FC<{
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result: ApiResponse<TaskAttemptState> = await response.json();
|
const result: ApiResponse<TaskAttemptState> = await response.json();
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setExecutionState(result.data);
|
setExecutionState((prev) => {
|
||||||
|
if (JSON.stringify(prev) === JSON.stringify(result.data))
|
||||||
|
return prev;
|
||||||
|
return result.data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -256,10 +272,14 @@ const TaskDetailsProvider: FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAttemptData({
|
setAttemptData((prev) => {
|
||||||
activities: activitiesResult.data,
|
const newData = {
|
||||||
processes: processesResult.data,
|
activities: activitiesResult.data || [],
|
||||||
runningProcessDetails,
|
processes: processesResult.data || [],
|
||||||
|
runningProcessDetails,
|
||||||
|
};
|
||||||
|
if (JSON.stringify(prev) === JSON.stringify(newData)) return prev;
|
||||||
|
return newData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,56 +388,98 @@ const TaskDetailsProvider: FC<{
|
|||||||
() => ({
|
() => ({
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
loading,
|
handleOpenInEditor,
|
||||||
setLoading,
|
projectHasDevScript,
|
||||||
selectedAttempt,
|
}),
|
||||||
setSelectedAttempt,
|
[task, projectId, handleOpenInEditor, projectHasDevScript]
|
||||||
isStopping,
|
);
|
||||||
setIsStopping,
|
|
||||||
|
const taskAttemptLoadingValue = useMemo(
|
||||||
|
() => ({ loading, setLoading }),
|
||||||
|
[loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAttemptValue = useMemo(
|
||||||
|
() => ({ selectedAttempt, setSelectedAttempt }),
|
||||||
|
[selectedAttempt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const attemptStoppingValue = useMemo(
|
||||||
|
() => ({ isStopping, setIsStopping }),
|
||||||
|
[isStopping]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletingFilesValue = useMemo(
|
||||||
|
() => ({
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
fileToDelete,
|
fileToDelete,
|
||||||
setFileToDelete,
|
setFileToDelete,
|
||||||
setDeletingFiles,
|
setDeletingFiles,
|
||||||
fetchDiff,
|
}),
|
||||||
|
[deletingFiles, fileToDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const diffValue = useMemo(
|
||||||
|
() => ({
|
||||||
setDiffError,
|
setDiffError,
|
||||||
|
fetchDiff,
|
||||||
diff,
|
diff,
|
||||||
diffError,
|
diffError,
|
||||||
diffLoading,
|
diffLoading,
|
||||||
setDiffLoading,
|
|
||||||
setDiff,
|
setDiff,
|
||||||
|
setDiffLoading,
|
||||||
|
}),
|
||||||
|
[fetchDiff, diff, diffError, diffLoading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backgroundRefreshingValue = useMemo(
|
||||||
|
() => ({
|
||||||
isBackgroundRefreshing,
|
isBackgroundRefreshing,
|
||||||
handleOpenInEditor,
|
}),
|
||||||
isAttemptRunning,
|
[isBackgroundRefreshing]
|
||||||
fetchExecutionState,
|
);
|
||||||
executionState,
|
|
||||||
|
const attemptDataValue = useMemo(
|
||||||
|
() => ({
|
||||||
attemptData,
|
attemptData,
|
||||||
setAttemptData,
|
setAttemptData,
|
||||||
fetchAttemptData,
|
fetchAttemptData,
|
||||||
}),
|
|
||||||
[
|
|
||||||
task,
|
|
||||||
projectId,
|
|
||||||
loading,
|
|
||||||
selectedAttempt,
|
|
||||||
isStopping,
|
|
||||||
deletingFiles,
|
|
||||||
fileToDelete,
|
|
||||||
fetchDiff,
|
|
||||||
diff,
|
|
||||||
diffError,
|
|
||||||
diffLoading,
|
|
||||||
isBackgroundRefreshing,
|
|
||||||
handleOpenInEditor,
|
|
||||||
isAttemptRunning,
|
isAttemptRunning,
|
||||||
fetchExecutionState,
|
}),
|
||||||
executionState,
|
[attemptData, fetchAttemptData, isAttemptRunning]
|
||||||
attemptData,
|
|
||||||
fetchAttemptData,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const executionStateValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
executionState,
|
||||||
|
fetchExecutionState,
|
||||||
|
}),
|
||||||
|
[executionState, fetchExecutionState]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TaskDetailsContext.Provider value={value}>
|
<TaskDetailsContext.Provider value={value}>
|
||||||
{children}
|
<TaskAttemptLoadingContext.Provider value={taskAttemptLoadingValue}>
|
||||||
|
<TaskSelectedAttemptContext.Provider value={selectedAttemptValue}>
|
||||||
|
<TaskAttemptStoppingContext.Provider value={attemptStoppingValue}>
|
||||||
|
<TaskDeletingFilesContext.Provider value={deletingFilesValue}>
|
||||||
|
<TaskDiffContext.Provider value={diffValue}>
|
||||||
|
<TaskAttemptDataContext.Provider value={attemptDataValue}>
|
||||||
|
<TaskExecutionStateContext.Provider
|
||||||
|
value={executionStateValue}
|
||||||
|
>
|
||||||
|
<TaskBackgroundRefreshContext.Provider
|
||||||
|
value={backgroundRefreshingValue}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TaskBackgroundRefreshContext.Provider>
|
||||||
|
</TaskExecutionStateContext.Provider>
|
||||||
|
</TaskAttemptDataContext.Provider>
|
||||||
|
</TaskDiffContext.Provider>
|
||||||
|
</TaskDeletingFilesContext.Provider>
|
||||||
|
</TaskAttemptStoppingContext.Provider>
|
||||||
|
</TaskSelectedAttemptContext.Provider>
|
||||||
|
</TaskAttemptLoadingContext.Provider>
|
||||||
</TaskDetailsContext.Provider>
|
</TaskDetailsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,36 +11,98 @@ import type {
|
|||||||
export interface TaskDetailsContextValue {
|
export interface TaskDetailsContextValue {
|
||||||
task: TaskWithAttemptStatus;
|
task: TaskWithAttemptStatus;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
loading: boolean;
|
|
||||||
setLoading: Dispatch<SetStateAction<boolean>>;
|
|
||||||
selectedAttempt: TaskAttempt | null;
|
|
||||||
setSelectedAttempt: Dispatch<SetStateAction<TaskAttempt | null>>;
|
|
||||||
isStopping: boolean;
|
|
||||||
setIsStopping: Dispatch<SetStateAction<boolean>>;
|
|
||||||
deletingFiles: Set<string>;
|
|
||||||
setDeletingFiles: Dispatch<SetStateAction<Set<string>>>;
|
|
||||||
fileToDelete: string | null;
|
|
||||||
setFileToDelete: Dispatch<SetStateAction<string | null>>;
|
|
||||||
setDiffError: Dispatch<SetStateAction<string | null>>;
|
|
||||||
fetchDiff: (isBackgroundRefresh?: boolean) => Promise<void>;
|
|
||||||
diff: WorktreeDiff | null;
|
|
||||||
diffError: string | null;
|
|
||||||
diffLoading: boolean;
|
|
||||||
isBackgroundRefreshing: boolean;
|
|
||||||
setDiff: Dispatch<SetStateAction<WorktreeDiff | null>>;
|
|
||||||
setDiffLoading: Dispatch<SetStateAction<boolean>>;
|
|
||||||
handleOpenInEditor: (editorType?: EditorType) => Promise<void>;
|
handleOpenInEditor: (editorType?: EditorType) => Promise<void>;
|
||||||
isAttemptRunning: boolean;
|
projectHasDevScript?: boolean;
|
||||||
fetchExecutionState: (
|
|
||||||
attemptId: string,
|
|
||||||
taskId: string
|
|
||||||
) => Promise<void> | void;
|
|
||||||
executionState: TaskAttemptState | null;
|
|
||||||
attemptData: AttemptData;
|
|
||||||
setAttemptData: Dispatch<SetStateAction<AttemptData>>;
|
|
||||||
fetchAttemptData: (attemptId: string, taskId: string) => Promise<void> | void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailsContext = createContext<TaskDetailsContextValue>(
|
export const TaskDetailsContext = createContext<TaskDetailsContextValue>(
|
||||||
{} as TaskDetailsContextValue
|
{} as TaskDetailsContextValue
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface TaskAttemptLoadingContextValue {
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskAttemptLoadingContext =
|
||||||
|
createContext<TaskAttemptLoadingContextValue>(
|
||||||
|
{} as TaskAttemptLoadingContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskAttemptDataContextValue {
|
||||||
|
attemptData: AttemptData;
|
||||||
|
setAttemptData: Dispatch<SetStateAction<AttemptData>>;
|
||||||
|
fetchAttemptData: (attemptId: string, taskId: string) => Promise<void> | void;
|
||||||
|
isAttemptRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskAttemptDataContext =
|
||||||
|
createContext<TaskAttemptDataContextValue>({} as TaskAttemptDataContextValue);
|
||||||
|
|
||||||
|
interface TaskSelectedAttemptContextValue {
|
||||||
|
selectedAttempt: TaskAttempt | null;
|
||||||
|
setSelectedAttempt: Dispatch<SetStateAction<TaskAttempt | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskSelectedAttemptContext =
|
||||||
|
createContext<TaskSelectedAttemptContextValue>(
|
||||||
|
{} as TaskSelectedAttemptContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskAttemptStoppingContextValue {
|
||||||
|
isStopping: boolean;
|
||||||
|
setIsStopping: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskAttemptStoppingContext =
|
||||||
|
createContext<TaskAttemptStoppingContextValue>(
|
||||||
|
{} as TaskAttemptStoppingContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskDeletingFilesContextValue {
|
||||||
|
deletingFiles: Set<string>;
|
||||||
|
setDeletingFiles: Dispatch<SetStateAction<Set<string>>>;
|
||||||
|
fileToDelete: string | null;
|
||||||
|
setFileToDelete: Dispatch<SetStateAction<string | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskDeletingFilesContext =
|
||||||
|
createContext<TaskDeletingFilesContextValue>(
|
||||||
|
{} as TaskDeletingFilesContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskDiffContextValue {
|
||||||
|
setDiffError: Dispatch<SetStateAction<string | null>>;
|
||||||
|
fetchDiff: (isBackgroundRefresh?: boolean) => Promise<void>;
|
||||||
|
diff: WorktreeDiff | null;
|
||||||
|
diffError: string | null;
|
||||||
|
diffLoading: boolean;
|
||||||
|
setDiff: Dispatch<SetStateAction<WorktreeDiff | null>>;
|
||||||
|
setDiffLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskDiffContext = createContext<TaskDiffContextValue>(
|
||||||
|
{} as TaskDiffContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskBackgroundRefreshContextValue {
|
||||||
|
isBackgroundRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskBackgroundRefreshContext =
|
||||||
|
createContext<TaskBackgroundRefreshContextValue>(
|
||||||
|
{} as TaskBackgroundRefreshContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TaskExecutionStateContextValue {
|
||||||
|
executionState: TaskAttemptState | null;
|
||||||
|
fetchExecutionState: (
|
||||||
|
attemptId: string,
|
||||||
|
taskId: string
|
||||||
|
) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskExecutionStateContext =
|
||||||
|
createContext<TaskExecutionStateContextValue>(
|
||||||
|
{} as TaskExecutionStateContextValue
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ import { Button } from '@/components/ui/button.tsx';
|
|||||||
import { makeRequest } from '@/lib/api.ts';
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { ApiResponse } from 'shared/types.ts';
|
import { ApiResponse } from 'shared/types.ts';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import {
|
||||||
|
TaskDeletingFilesContext,
|
||||||
|
TaskDetailsContext,
|
||||||
|
TaskDiffContext,
|
||||||
|
TaskSelectedAttemptContext,
|
||||||
|
} from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
function DeleteFileConfirmationDialog() {
|
function DeleteFileConfirmationDialog() {
|
||||||
const {
|
const { task, projectId } = useContext(TaskDetailsContext);
|
||||||
task,
|
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||||
projectId,
|
const { setDeletingFiles, fileToDelete, deletingFiles, setFileToDelete } =
|
||||||
selectedAttempt,
|
useContext(TaskDeletingFilesContext);
|
||||||
setDeletingFiles,
|
const { fetchDiff, setDiffError } = useContext(TaskDiffContext);
|
||||||
fileToDelete,
|
|
||||||
deletingFiles,
|
|
||||||
setFileToDelete,
|
|
||||||
fetchDiff,
|
|
||||||
setDiffError,
|
|
||||||
} = useContext(TaskDetailsContext);
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
|
if (!fileToDelete || !projectId || !task?.id || !selectedAttempt?.id)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button.tsx';
|
import { Button } from '@/components/ui/button.tsx';
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { TaskDetailsToolbar } from '@/components/tasks/TaskDetailsToolbar.tsx';
|
import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx';
|
||||||
|
|
||||||
type Props = {
|
function CollapsibleToolbar() {
|
||||||
projectHasDevScript?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CollapsibleToolbar({ projectHasDevScript }: Props) {
|
|
||||||
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
|
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,11 +25,9 @@ function CollapsibleToolbar({ projectHasDevScript }: Props) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!isHeaderCollapsed && (
|
{!isHeaderCollapsed && <TaskDetailsToolbar />}
|
||||||
<TaskDetailsToolbar projectHasDevScript={projectHasDevScript} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollapsibleToolbar;
|
export default memo(CollapsibleToolbar);
|
||||||
|
|||||||
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 { Button } from '@/components/ui/button.tsx';
|
||||||
import { ChevronDown, ChevronUp, GitCompare, Trash2 } from 'lucide-react';
|
import { GitCompare } from 'lucide-react';
|
||||||
import type { DiffChunk, DiffChunkType, WorktreeDiff } from 'shared/types.ts';
|
import type { WorktreeDiff } from 'shared/types.ts';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import { TaskBackgroundRefreshContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
import DiffFile from '@/components/tasks/TaskDetails/DiffFile.tsx';
|
||||||
interface ProcessedLine {
|
|
||||||
content: string;
|
|
||||||
chunkType: DiffChunkType;
|
|
||||||
oldLineNumber?: number;
|
|
||||||
newLineNumber?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessedSection {
|
|
||||||
type: 'context' | 'change' | 'expanded';
|
|
||||||
lines: ProcessedLine[];
|
|
||||||
expandKey?: string;
|
|
||||||
expandedAbove?: boolean;
|
|
||||||
expandedBelow?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiffCardProps {
|
interface DiffCardProps {
|
||||||
diff: WorktreeDiff | null;
|
diff: WorktreeDiff | null;
|
||||||
@@ -32,212 +18,8 @@ export function DiffCard({
|
|||||||
compact = false,
|
compact = false,
|
||||||
className = '',
|
className = '',
|
||||||
}: DiffCardProps) {
|
}: DiffCardProps) {
|
||||||
const { deletingFiles, setFileToDelete, isBackgroundRefreshing } =
|
const { isBackgroundRefreshing } = useContext(TaskBackgroundRefreshContext);
|
||||||
useContext(TaskDetailsContext);
|
|
||||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDeleteFile = useCallback(
|
|
||||||
(filePath: string) => {
|
|
||||||
setFileToDelete(filePath);
|
|
||||||
},
|
|
||||||
[setFileToDelete]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Diff processing functions
|
|
||||||
const getChunkClassName = (chunkType: DiffChunkType) => {
|
|
||||||
const baseClass = 'font-mono text-sm whitespace-pre flex w-full';
|
|
||||||
|
|
||||||
switch (chunkType) {
|
|
||||||
case 'Insert':
|
|
||||||
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-900 dark:text-green-100`;
|
|
||||||
case 'Delete':
|
|
||||||
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100`;
|
|
||||||
case 'Equal':
|
|
||||||
default:
|
|
||||||
return `${baseClass} text-muted-foreground`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLineNumberClassName = (chunkType: DiffChunkType) => {
|
|
||||||
const baseClass =
|
|
||||||
'flex-shrink-0 w-12 px-1.5 text-xs border-r select-none min-h-[1.25rem] flex items-center';
|
|
||||||
|
|
||||||
switch (chunkType) {
|
|
||||||
case 'Insert':
|
|
||||||
return `${baseClass} text-green-800 dark:text-green-200 bg-green-100 dark:bg-green-900/40 border-green-300 dark:border-green-600`;
|
|
||||||
case 'Delete':
|
|
||||||
return `${baseClass} text-red-800 dark:text-red-200 bg-red-100 dark:bg-red-900/40 border-red-300 dark:border-red-600`;
|
|
||||||
case 'Equal':
|
|
||||||
default:
|
|
||||||
return `${baseClass} text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChunkPrefix = (chunkType: DiffChunkType) => {
|
|
||||||
switch (chunkType) {
|
|
||||||
case 'Insert':
|
|
||||||
return '+';
|
|
||||||
case 'Delete':
|
|
||||||
return '-';
|
|
||||||
case 'Equal':
|
|
||||||
default:
|
|
||||||
return ' ';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processFileChunks = (chunks: DiffChunk[], fileIndex: number) => {
|
|
||||||
const CONTEXT_LINES = compact ? 2 : 3;
|
|
||||||
const lines: ProcessedLine[] = [];
|
|
||||||
let oldLineNumber = 1;
|
|
||||||
let newLineNumber = 1;
|
|
||||||
|
|
||||||
// Convert chunks to lines with line numbers
|
|
||||||
chunks.forEach((chunk) => {
|
|
||||||
const chunkLines = chunk.content.split('\n');
|
|
||||||
chunkLines.forEach((line, index) => {
|
|
||||||
if (index < chunkLines.length - 1 || line !== '') {
|
|
||||||
const processedLine: ProcessedLine = {
|
|
||||||
content: line,
|
|
||||||
chunkType: chunk.chunk_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (chunk.chunk_type) {
|
|
||||||
case 'Equal':
|
|
||||||
processedLine.oldLineNumber = oldLineNumber++;
|
|
||||||
processedLine.newLineNumber = newLineNumber++;
|
|
||||||
break;
|
|
||||||
case 'Delete':
|
|
||||||
processedLine.oldLineNumber = oldLineNumber++;
|
|
||||||
break;
|
|
||||||
case 'Insert':
|
|
||||||
processedLine.newLineNumber = newLineNumber++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(processedLine);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const sections: ProcessedSection[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
if (line.chunkType === 'Equal') {
|
|
||||||
let nextChangeIndex = i + 1;
|
|
||||||
while (
|
|
||||||
nextChangeIndex < lines.length &&
|
|
||||||
lines[nextChangeIndex].chunkType === 'Equal'
|
|
||||||
) {
|
|
||||||
nextChangeIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextLength = nextChangeIndex - i;
|
|
||||||
const hasNextChange = nextChangeIndex < lines.length;
|
|
||||||
const hasPrevChange =
|
|
||||||
sections.length > 0 &&
|
|
||||||
sections[sections.length - 1].type === 'change';
|
|
||||||
|
|
||||||
if (
|
|
||||||
contextLength <= CONTEXT_LINES * 2 ||
|
|
||||||
(!hasPrevChange && !hasNextChange)
|
|
||||||
) {
|
|
||||||
sections.push({
|
|
||||||
type: 'context',
|
|
||||||
lines: lines.slice(i, nextChangeIndex),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (hasPrevChange) {
|
|
||||||
sections.push({
|
|
||||||
type: 'context',
|
|
||||||
lines: lines.slice(i, i + CONTEXT_LINES),
|
|
||||||
});
|
|
||||||
i += CONTEXT_LINES;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNextChange) {
|
|
||||||
const expandStart = hasPrevChange ? i : i + CONTEXT_LINES;
|
|
||||||
const expandEnd = nextChangeIndex - CONTEXT_LINES;
|
|
||||||
|
|
||||||
if (expandEnd > expandStart) {
|
|
||||||
const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`;
|
|
||||||
const isExpanded = expandedSections.has(expandKey);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
sections.push({
|
|
||||||
type: 'expanded',
|
|
||||||
lines: lines.slice(expandStart, expandEnd),
|
|
||||||
expandKey,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
sections.push({
|
|
||||||
type: 'context',
|
|
||||||
lines: [],
|
|
||||||
expandKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({
|
|
||||||
type: 'context',
|
|
||||||
lines: lines.slice(
|
|
||||||
nextChangeIndex - CONTEXT_LINES,
|
|
||||||
nextChangeIndex
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else if (!hasPrevChange) {
|
|
||||||
sections.push({
|
|
||||||
type: 'context',
|
|
||||||
lines: lines.slice(i, i + CONTEXT_LINES),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i = nextChangeIndex;
|
|
||||||
} else {
|
|
||||||
const changeStart = i;
|
|
||||||
while (i < lines.length && lines[i].chunkType !== 'Equal') {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({
|
|
||||||
type: 'change',
|
|
||||||
lines: lines.slice(changeStart, i),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpandSection = (expandKey: string) => {
|
|
||||||
setExpandedSections((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(expandKey)) {
|
|
||||||
newSet.delete(expandKey);
|
|
||||||
} else {
|
|
||||||
newSet.add(expandKey);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFileCollapse = (filePath: string) => {
|
|
||||||
setCollapsedFiles((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(filePath)) {
|
|
||||||
newSet.delete(filePath);
|
|
||||||
} else {
|
|
||||||
newSet.add(filePath);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAllFiles = () => {
|
const collapseAllFiles = () => {
|
||||||
if (diff) {
|
if (diff) {
|
||||||
@@ -312,168 +94,15 @@ export function DiffCard({
|
|||||||
>
|
>
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-2 p-3">
|
||||||
{diff.files.map((file, fileIndex) => (
|
{diff.files.map((file, fileIndex) => (
|
||||||
<div
|
<DiffFile
|
||||||
key={fileIndex}
|
key={fileIndex}
|
||||||
className={`border rounded-lg overflow-hidden ${
|
collapsedFiles={collapsedFiles}
|
||||||
collapsedFiles.has(file.path) ? 'border-muted' : 'border-border'
|
compact={compact}
|
||||||
}`}
|
deletable={deletable}
|
||||||
>
|
file={file}
|
||||||
<div
|
fileIndex={fileIndex}
|
||||||
className={`bg-muted px-3 py-1.5 flex items-center justify-between ${
|
setCollapsedFiles={setCollapsedFiles}
|
||||||
!collapsedFiles.has(file.path) ? 'border-b' : ''
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleFileCollapse(file.path)}
|
|
||||||
className="h-5 w-5 p-0 hover:bg-muted-foreground/10"
|
|
||||||
title={
|
|
||||||
collapsedFiles.has(file.path)
|
|
||||||
? 'Expand diff'
|
|
||||||
: 'Collapse diff'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{collapsedFiles.has(file.path) ? (
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground font-mono">
|
|
||||||
{file.path}
|
|
||||||
</p>
|
|
||||||
{collapsedFiles.has(file.path) && (
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-2">
|
|
||||||
<span className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 px-1 py-0.5 rounded text-xs">
|
|
||||||
+
|
|
||||||
{file.chunks
|
|
||||||
.filter((c) => c.chunk_type === 'Insert')
|
|
||||||
.reduce(
|
|
||||||
(acc, c) => acc + c.content.split('\n').length - 1,
|
|
||||||
0
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 px-1 py-0.5 rounded text-xs">
|
|
||||||
-
|
|
||||||
{file.chunks
|
|
||||||
.filter((c) => c.chunk_type === 'Delete')
|
|
||||||
.reduce(
|
|
||||||
(acc, c) => acc + c.content.split('\n').length - 1,
|
|
||||||
0
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{deletable && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onDeleteFile(file.path)}
|
|
||||||
disabled={deletingFiles.has(file.path)}
|
|
||||||
className="text-red-600 hover:text-red-800 hover:bg-red-50 h-6 px-2 gap-1"
|
|
||||||
title={`Delete ${file.path}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
{!compact && (
|
|
||||||
<span className="text-xs">
|
|
||||||
{deletingFiles.has(file.path)
|
|
||||||
? 'Deleting...'
|
|
||||||
: 'Delete'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!collapsedFiles.has(file.path) && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="inline-block min-w-full">
|
|
||||||
{processFileChunks(file.chunks, fileIndex).map(
|
|
||||||
(section, sectionIndex) => {
|
|
||||||
if (
|
|
||||||
section.type === 'context' &&
|
|
||||||
section.lines.length === 0 &&
|
|
||||||
section.expandKey
|
|
||||||
) {
|
|
||||||
const lineCount =
|
|
||||||
parseInt(section.expandKey.split('-')[2]) -
|
|
||||||
parseInt(section.expandKey.split('-')[1]);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`expand-${section.expandKey}`}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
toggleExpandSection(section.expandKey!)
|
|
||||||
}
|
|
||||||
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3 mr-1" />
|
|
||||||
Show {lineCount} more lines
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={`section-${sectionIndex}`}>
|
|
||||||
{section.type === 'expanded' &&
|
|
||||||
section.expandKey && (
|
|
||||||
<div className="w-full">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
toggleExpandSection(section.expandKey!)
|
|
||||||
}
|
|
||||||
className="w-full h-5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none justify-start"
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-3 w-3 mr-1" />
|
|
||||||
Hide expanded lines
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{section.lines.map((line, lineIndex) => (
|
|
||||||
<div
|
|
||||||
key={`${sectionIndex}-${lineIndex}`}
|
|
||||||
className={getChunkClassName(line.chunkType)}
|
|
||||||
style={{ minWidth: 'max-content' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={getLineNumberClassName(
|
|
||||||
line.chunkType
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="inline-block w-4 text-right text-xs">
|
|
||||||
{line.oldLineNumber || ''}
|
|
||||||
</span>
|
|
||||||
<span className="inline-block w-4 text-right ml-1 text-xs">
|
|
||||||
{line.newLineNumber || ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 px-2 min-h-[1rem] flex items-center">
|
|
||||||
<span className="inline-block w-3 text-xs">
|
|
||||||
{getChunkPrefix(line.chunkType)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">
|
|
||||||
{line.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { DiffCard } from '@/components/tasks/TaskDetails/DiffCard.tsx';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
function DiffTab() {
|
function DiffTab() {
|
||||||
const { diff, diffLoading, diffError } = useContext(TaskDetailsContext);
|
const { diff, diffLoading, diffError } = useContext(TaskDiffContext);
|
||||||
|
|
||||||
if (diffLoading) {
|
if (diffLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
|
import { NormalizedConversationViewer } from '@/components/tasks/TaskDetails/NormalizedConversationViewer.tsx';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import {
|
||||||
|
TaskAttemptDataContext,
|
||||||
|
TaskAttemptLoadingContext,
|
||||||
|
TaskExecutionStateContext,
|
||||||
|
TaskSelectedAttemptContext,
|
||||||
|
} from '@/components/context/taskDetailsContext.ts';
|
||||||
|
import Conversation from '@/components/tasks/TaskDetails/Conversation.tsx';
|
||||||
|
|
||||||
function LogsTab() {
|
function LogsTab() {
|
||||||
const { loading, selectedAttempt, executionState, attemptData } =
|
const { loading } = useContext(TaskAttemptLoadingContext);
|
||||||
useContext(TaskDetailsContext);
|
const { executionState } = useContext(TaskExecutionStateContext);
|
||||||
|
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||||
|
const { attemptData } = useContext(TaskAttemptDataContext);
|
||||||
|
|
||||||
const [shouldAutoScrollLogs, setShouldAutoScrollLogs] = useState(true);
|
|
||||||
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
|
const [conversationUpdateTrigger, setConversationUpdateTrigger] = useState(0);
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const setupScrollRef = useRef<HTMLDivElement>(null);
|
const setupScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldAutoScrollLogs && scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTop =
|
|
||||||
scrollContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
attemptData.activities,
|
|
||||||
attemptData.processes,
|
|
||||||
conversationUpdateTrigger,
|
|
||||||
shouldAutoScrollLogs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Auto-scroll setup script logs to bottom
|
// Auto-scroll setup script logs to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setupScrollRef.current) {
|
if (setupScrollRef.current) {
|
||||||
@@ -32,20 +26,6 @@ function LogsTab() {
|
|||||||
}
|
}
|
||||||
}, [attemptData.runningProcessDetails]);
|
}, [attemptData.runningProcessDetails]);
|
||||||
|
|
||||||
const handleLogsScroll = useCallback(() => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } =
|
|
||||||
scrollContainerRef.current;
|
|
||||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
|
||||||
|
|
||||||
if (isAtBottom && !shouldAutoScrollLogs) {
|
|
||||||
setShouldAutoScrollLogs(true);
|
|
||||||
} else if (!isAtBottom && shouldAutoScrollLogs) {
|
|
||||||
setShouldAutoScrollLogs(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [shouldAutoScrollLogs]);
|
|
||||||
|
|
||||||
// Callback to trigger auto-scroll when conversation updates
|
// Callback to trigger auto-scroll when conversation updates
|
||||||
const handleConversationUpdate = useCallback(() => {
|
const handleConversationUpdate = useCallback(() => {
|
||||||
setConversationUpdateTrigger((prev) => prev + 1);
|
setConversationUpdateTrigger((prev) => prev + 1);
|
||||||
@@ -220,101 +200,10 @@ function LogsTab() {
|
|||||||
// When coding agent is running or complete, show conversation
|
// When coding agent is running or complete, show conversation
|
||||||
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
|
if (isCodingAgentRunning || isCodingAgentComplete || hasChanges) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Conversation
|
||||||
ref={scrollContainerRef}
|
conversationUpdateTrigger={conversationUpdateTrigger}
|
||||||
onScroll={handleLogsScroll}
|
handleConversationUpdate={handleConversationUpdate}
|
||||||
className="h-full overflow-y-auto"
|
/>
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
// Find main coding agent process (command: "executor")
|
|
||||||
let mainCodingAgentProcess = Object.values(
|
|
||||||
attemptData.runningProcessDetails
|
|
||||||
).find(
|
|
||||||
(process) =>
|
|
||||||
process.process_type === 'codingagent' &&
|
|
||||||
process.command === 'executor'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mainCodingAgentProcess) {
|
|
||||||
const mainCodingAgentSummary = attemptData.processes.find(
|
|
||||||
(process) =>
|
|
||||||
process.process_type === 'codingagent' &&
|
|
||||||
process.command === 'executor'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mainCodingAgentSummary) {
|
|
||||||
mainCodingAgentProcess = Object.values(
|
|
||||||
attemptData.runningProcessDetails
|
|
||||||
).find((process) => process.id === mainCodingAgentSummary.id);
|
|
||||||
|
|
||||||
if (!mainCodingAgentProcess) {
|
|
||||||
mainCodingAgentProcess = {
|
|
||||||
...mainCodingAgentSummary,
|
|
||||||
stdout: null,
|
|
||||||
stderr: null,
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find follow up executor processes (command: "followup_executor")
|
|
||||||
const followUpProcesses = attemptData.processes
|
|
||||||
.filter(
|
|
||||||
(process) =>
|
|
||||||
process.process_type === 'codingagent' &&
|
|
||||||
process.command === 'followup_executor'
|
|
||||||
)
|
|
||||||
.map((summary) => {
|
|
||||||
const detailedProcess = Object.values(
|
|
||||||
attemptData.runningProcessDetails
|
|
||||||
).find((process) => process.id === summary.id);
|
|
||||||
return (
|
|
||||||
detailedProcess ||
|
|
||||||
({
|
|
||||||
...summary,
|
|
||||||
stdout: null,
|
|
||||||
stderr: null,
|
|
||||||
} as any)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mainCodingAgentProcess || followUpProcesses.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{mainCodingAgentProcess && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<NormalizedConversationViewer
|
|
||||||
executionProcess={mainCodingAgentProcess}
|
|
||||||
onConversationUpdate={handleConversationUpdate}
|
|
||||||
diffDeletable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{followUpProcesses.map((followUpProcess) => (
|
|
||||||
<div key={followUpProcess.id}>
|
|
||||||
<div className="border-t border-border mb-8"></div>
|
|
||||||
<NormalizedConversationViewer
|
|
||||||
executionProcess={followUpProcess}
|
|
||||||
onConversationUpdate={handleConversationUpdate}
|
|
||||||
diffDeletable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
|
||||||
<p className="text-lg font-semibold mb-2">
|
|
||||||
Coding Agent Starting
|
|
||||||
</p>
|
|
||||||
<p>Initializing conversation...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { Bot, Hammer, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
AlertCircle,
|
|
||||||
Bot,
|
|
||||||
Brain,
|
|
||||||
CheckSquare,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronUp,
|
|
||||||
Edit,
|
|
||||||
Eye,
|
|
||||||
Globe,
|
|
||||||
Hammer,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Terminal,
|
|
||||||
ToggleLeft,
|
|
||||||
ToggleRight,
|
|
||||||
User,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { makeRequest } from '@/lib/api.ts';
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
|
import { MarkdownRenderer } from '@/components/ui/markdown-renderer.tsx';
|
||||||
import { DiffCard } from './DiffCard.tsx';
|
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ExecutionProcess,
|
ExecutionProcess,
|
||||||
NormalizedConversation,
|
NormalizedConversation,
|
||||||
NormalizedEntry,
|
NormalizedEntry,
|
||||||
NormalizedEntryType,
|
|
||||||
WorktreeDiff,
|
WorktreeDiff,
|
||||||
} from 'shared/types.ts';
|
} from 'shared/types.ts';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
import DisplayConversationEntry from '@/components/tasks/TaskDetails/DisplayConversationEntry.tsx';
|
||||||
|
|
||||||
interface NormalizedConversationViewerProps {
|
interface NormalizedConversationViewerProps {
|
||||||
executionProcess: ExecutionProcess;
|
executionProcess: ExecutionProcess;
|
||||||
@@ -39,88 +20,6 @@ interface NormalizedConversationViewerProps {
|
|||||||
diffDeletable?: boolean;
|
diffDeletable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEntryIcon = (entryType: NormalizedEntryType) => {
|
|
||||||
if (entryType.type === 'user_message') {
|
|
||||||
return <User className="h-4 w-4 text-blue-600" />;
|
|
||||||
}
|
|
||||||
if (entryType.type === 'assistant_message') {
|
|
||||||
return <Bot className="h-4 w-4 text-green-600" />;
|
|
||||||
}
|
|
||||||
if (entryType.type === 'system_message') {
|
|
||||||
return <Settings className="h-4 w-4 text-gray-600" />;
|
|
||||||
}
|
|
||||||
if (entryType.type === 'thinking') {
|
|
||||||
return <Brain className="h-4 w-4 text-purple-600" />;
|
|
||||||
}
|
|
||||||
if (entryType.type === 'error_message') {
|
|
||||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
|
||||||
}
|
|
||||||
if (entryType.type === 'tool_use') {
|
|
||||||
const { action_type, tool_name } = entryType;
|
|
||||||
|
|
||||||
// Special handling for TODO tools
|
|
||||||
if (
|
|
||||||
tool_name &&
|
|
||||||
(tool_name.toLowerCase() === 'todowrite' ||
|
|
||||||
tool_name.toLowerCase() === 'todoread' ||
|
|
||||||
tool_name.toLowerCase() === 'todo_write' ||
|
|
||||||
tool_name.toLowerCase() === 'todo_read')
|
|
||||||
) {
|
|
||||||
return <CheckSquare className="h-4 w-4 text-purple-600" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action_type.action === 'file_read') {
|
|
||||||
return <Eye className="h-4 w-4 text-orange-600" />;
|
|
||||||
}
|
|
||||||
if (action_type.action === 'file_write') {
|
|
||||||
return <Edit className="h-4 w-4 text-red-600" />;
|
|
||||||
}
|
|
||||||
if (action_type.action === 'command_run') {
|
|
||||||
return <Terminal className="h-4 w-4 text-yellow-600" />;
|
|
||||||
}
|
|
||||||
if (action_type.action === 'search') {
|
|
||||||
return <Search className="h-4 w-4 text-indigo-600" />;
|
|
||||||
}
|
|
||||||
if (action_type.action === 'web_fetch') {
|
|
||||||
return <Globe className="h-4 w-4 text-cyan-600" />;
|
|
||||||
}
|
|
||||||
if (action_type.action === 'task_create') {
|
|
||||||
return <Plus className="h-4 w-4 text-teal-600" />;
|
|
||||||
}
|
|
||||||
return <Settings className="h-4 w-4 text-gray-600" />;
|
|
||||||
}
|
|
||||||
return <Settings className="h-4 w-4 text-gray-400" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getContentClassName = (entryType: NormalizedEntryType) => {
|
|
||||||
const baseClasses = 'text-sm whitespace-pre-wrap break-words';
|
|
||||||
|
|
||||||
if (
|
|
||||||
entryType.type === 'tool_use' &&
|
|
||||||
entryType.action_type.action === 'command_run'
|
|
||||||
) {
|
|
||||||
return `${baseClasses} font-mono`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entryType.type === 'error_message') {
|
|
||||||
return `${baseClasses} text-red-600 font-mono bg-red-50 dark:bg-red-950/20 px-2 py-1 rounded`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special styling for TODO lists
|
|
||||||
if (
|
|
||||||
entryType.type === 'tool_use' &&
|
|
||||||
entryType.tool_name &&
|
|
||||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todo_read')
|
|
||||||
) {
|
|
||||||
return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseClasses;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Configuration for Gemini message clustering
|
// Configuration for Gemini message clustering
|
||||||
const GEMINI_CLUSTERING_CONFIG = {
|
const GEMINI_CLUSTERING_CONFIG = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -212,174 +111,20 @@ const clusterGeminiMessages = (
|
|||||||
return clustered;
|
return clustered;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to determine if a tool call modifies files
|
|
||||||
const isFileModificationToolCall = (
|
|
||||||
entryType: NormalizedEntryType
|
|
||||||
): boolean => {
|
|
||||||
if (entryType.type !== 'tool_use') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for direct file write action
|
|
||||||
if (entryType.action_type.action === 'file_write') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for "other" actions that are file modification tools
|
|
||||||
if (entryType.action_type.action === 'other') {
|
|
||||||
const fileModificationTools = [
|
|
||||||
'edit',
|
|
||||||
'write',
|
|
||||||
'create_file',
|
|
||||||
'multiedit',
|
|
||||||
'edit_file',
|
|
||||||
];
|
|
||||||
return fileModificationTools.includes(
|
|
||||||
entryType.tool_name?.toLowerCase() || ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract file path from tool call
|
|
||||||
const extractFilePathFromToolCall = (entry: NormalizedEntry): string | null => {
|
|
||||||
if (entry.entry_type.type !== 'tool_use') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { action_type, tool_name } = entry.entry_type;
|
|
||||||
|
|
||||||
// Direct path extraction from action_type
|
|
||||||
if (action_type.action === 'file_write') {
|
|
||||||
return action_type.path || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For "other" actions, check if it's a known file modification tool
|
|
||||||
if (action_type.action === 'other') {
|
|
||||||
const fileModificationTools = [
|
|
||||||
'edit',
|
|
||||||
'write',
|
|
||||||
'create_file',
|
|
||||||
'multiedit',
|
|
||||||
'edit_file',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (fileModificationTools.includes(tool_name.toLowerCase())) {
|
|
||||||
// Parse file path from content field
|
|
||||||
return parseFilePathFromContent(entry.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse file path from content (handles various formats)
|
|
||||||
const parseFilePathFromContent = (content: string): string | null => {
|
|
||||||
// Try to extract path from backticks: `path/to/file.ext`
|
|
||||||
const backtickMatch = content.match(/`([^`]+)`/);
|
|
||||||
if (backtickMatch) {
|
|
||||||
return backtickMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract from common patterns like "Edit file: path" or "Write file: path"
|
|
||||||
const actionMatch = content.match(
|
|
||||||
/(?:Edit|Write|Create)\s+file:\s*([^\s\n]+)/i
|
|
||||||
);
|
|
||||||
if (actionMatch) {
|
|
||||||
return actionMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create filtered diff showing only specific files
|
|
||||||
const createIncrementalDiff = (
|
|
||||||
fullDiff: WorktreeDiff | null,
|
|
||||||
targetFilePaths: string[]
|
|
||||||
): WorktreeDiff | null => {
|
|
||||||
if (!fullDiff || targetFilePaths.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter files to only include the target file paths
|
|
||||||
const filteredFiles = fullDiff.files.filter((file) =>
|
|
||||||
targetFilePaths.some(
|
|
||||||
(targetPath) =>
|
|
||||||
file.path === targetPath ||
|
|
||||||
file.path.endsWith('/' + targetPath) ||
|
|
||||||
targetPath.endsWith('/' + file.path)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filteredFiles.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...fullDiff,
|
|
||||||
files: filteredFiles,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine if content should be rendered as markdown
|
|
||||||
const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
|
|
||||||
// Render markdown for assistant messages and tool outputs that contain backticks
|
|
||||||
return (
|
|
||||||
entryType.type === 'assistant_message' ||
|
|
||||||
(entryType.type === 'tool_use' &&
|
|
||||||
entryType.tool_name &&
|
|
||||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'todo_read' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'glob' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'ls' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'list_directory' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'read' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'read_file' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'write' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'create_file' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'edit' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'edit_file' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'multiedit' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'bash' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'run_command' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'grep' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'search' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'webfetch' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'web_fetch' ||
|
|
||||||
entryType.tool_name.toLowerCase() === 'task'))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NormalizedConversationViewer({
|
export function NormalizedConversationViewer({
|
||||||
executionProcess,
|
executionProcess,
|
||||||
diffDeletable,
|
diffDeletable,
|
||||||
onConversationUpdate,
|
onConversationUpdate,
|
||||||
}: NormalizedConversationViewerProps) {
|
}: NormalizedConversationViewerProps) {
|
||||||
const { projectId, diff } = useContext(TaskDetailsContext);
|
const { projectId } = useContext(TaskDetailsContext);
|
||||||
const [conversation, setConversation] =
|
const [conversation, setConversation] =
|
||||||
useState<NormalizedConversation | null>(null);
|
useState<NormalizedConversation | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedErrors, setExpandedErrors] = useState<Set<number>>(new Set());
|
|
||||||
const [clusteringEnabled, setClusteringEnabled] = useState(
|
const [clusteringEnabled, setClusteringEnabled] = useState(
|
||||||
GEMINI_CLUSTERING_CONFIG.enabled
|
GEMINI_CLUSTERING_CONFIG.enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleErrorExpansion = (index: number) => {
|
|
||||||
setExpandedErrors((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(index)) {
|
|
||||||
newSet.delete(index);
|
|
||||||
} else {
|
|
||||||
newSet.add(index);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchNormalizedLogs = useCallback(
|
const fetchNormalizedLogs = useCallback(
|
||||||
async (isPolling = false) => {
|
async (isPolling = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -449,6 +194,26 @@ export function NormalizedConversationViewer({
|
|||||||
}
|
}
|
||||||
}, [executionProcess.status, fetchNormalizedLogs]);
|
}, [executionProcess.status, fetchNormalizedLogs]);
|
||||||
|
|
||||||
|
// Apply clustering for Gemini executor conversations
|
||||||
|
const isGeminiExecutor = useMemo(
|
||||||
|
() => conversation?.executor_type === 'gemini',
|
||||||
|
[conversation?.executor_type]
|
||||||
|
);
|
||||||
|
const hasAssistantMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
conversation?.entries.some(
|
||||||
|
(entry) => entry.entry_type.type === 'assistant_message'
|
||||||
|
),
|
||||||
|
[conversation?.entries]
|
||||||
|
);
|
||||||
|
const displayEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
isGeminiExecutor && conversation?.entries
|
||||||
|
? clusterGeminiMessages(conversation.entries, clusteringEnabled)
|
||||||
|
: conversation?.entries || [],
|
||||||
|
[isGeminiExecutor, conversation?.entries, clusteringEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-muted-foreground italic text-center">
|
<div className="text-xs text-muted-foreground italic text-center">
|
||||||
@@ -478,15 +243,6 @@ export function NormalizedConversationViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply clustering for Gemini executor conversations
|
|
||||||
const isGeminiExecutor = conversation.executor_type === 'gemini';
|
|
||||||
const hasAssistantMessages = conversation.entries.some(
|
|
||||||
(entry) => entry.entry_type.type === 'assistant_message'
|
|
||||||
);
|
|
||||||
const displayEntries = isGeminiExecutor
|
|
||||||
? clusterGeminiMessages(conversation.entries, clusteringEnabled)
|
|
||||||
: conversation.entries;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Display clustering controls for Gemini */}
|
{/* Display clustering controls for Gemini */}
|
||||||
@@ -541,111 +297,14 @@ export function NormalizedConversationViewer({
|
|||||||
|
|
||||||
{/* Display conversation entries */}
|
{/* Display conversation entries */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayEntries.map((entry, index) => {
|
{displayEntries.map((entry, index) => (
|
||||||
const isErrorMessage = entry.entry_type.type === 'error_message';
|
<DisplayConversationEntry
|
||||||
const isExpanded = expandedErrors.has(index);
|
key={index}
|
||||||
const hasMultipleLines =
|
entry={entry}
|
||||||
isErrorMessage && entry.content.includes('\n');
|
index={index}
|
||||||
const isFileModification = isFileModificationToolCall(
|
diffDeletable={diffDeletable}
|
||||||
entry.entry_type
|
/>
|
||||||
);
|
))}
|
||||||
|
|
||||||
// Extract file path from this specific tool call
|
|
||||||
const modifiedFilePath = isFileModification
|
|
||||||
? extractFilePathFromToolCall(entry)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Create incremental diff showing only the files modified by this specific tool call
|
|
||||||
const incrementalDiff =
|
|
||||||
modifiedFilePath && diff
|
|
||||||
? createIncrementalDiff(diff, [modifiedFilePath])
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Show incremental diff for this specific file modification
|
|
||||||
const shouldShowDiff =
|
|
||||||
isFileModification &&
|
|
||||||
incrementalDiff &&
|
|
||||||
incrementalDiff.files.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 mt-1">
|
|
||||||
{isErrorMessage && hasMultipleLines ? (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
|
||||||
className="transition-colors hover:opacity-70"
|
|
||||||
>
|
|
||||||
{getEntryIcon(entry.entry_type)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
getEntryIcon(entry.entry_type)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{isErrorMessage && hasMultipleLines ? (
|
|
||||||
<div className={isExpanded ? 'space-y-2' : ''}>
|
|
||||||
<div className={getContentClassName(entry.entry_type)}>
|
|
||||||
{isExpanded ? (
|
|
||||||
shouldRenderMarkdown(entry.entry_type) ? (
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={entry.content}
|
|
||||||
className="whitespace-pre-wrap break-words"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
entry.content
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{entry.content.split('\n')[0]}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
|
||||||
className="ml-2 inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
Show more
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleErrorExpansion(index)}
|
|
||||||
className="flex items-center gap-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
Show less
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={getContentClassName(entry.entry_type)}>
|
|
||||||
{shouldRenderMarkdown(entry.entry_type) ? (
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={entry.content}
|
|
||||||
className="whitespace-pre-wrap break-words"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
entry.content
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Render incremental diff card inline after file modification entries */}
|
|
||||||
{shouldShowDiff && incrementalDiff && (
|
|
||||||
<div className="mt-4 mb-2">
|
|
||||||
<DiffCard
|
|
||||||
diff={incrementalDiff}
|
|
||||||
deletable={diffDeletable}
|
|
||||||
compact={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GitCompare, MessageSquare } from 'lucide-react';
|
import { GitCompare, MessageSquare } from 'lucide-react';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
activeTab: 'logs' | 'diffs';
|
activeTab: 'logs' | 'diffs';
|
||||||
@@ -9,7 +9,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
|
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
|
||||||
const { diff } = useContext(TaskDetailsContext);
|
const { diff } = useContext(TaskDiffContext);
|
||||||
return (
|
return (
|
||||||
<div className="border-b bg-muted/30">
|
<div className="border-b bg-muted/30">
|
||||||
<div className="flex px-4">
|
<div className="flex px-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { memo, useContext, useState } from 'react';
|
||||||
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Edit, Trash2, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Chip } from '@/components/ui/chip';
|
import { Chip } from '@/components/ui/chip';
|
||||||
@@ -42,7 +42,7 @@ const getTaskStatusDotColor = (status: TaskStatus): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskDetailsHeader({
|
function TaskDetailsHeader({
|
||||||
onClose,
|
onClose,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
@@ -165,3 +165,5 @@ export function TaskDetailsHeader({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(TaskDetailsHeader);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { TaskDetailsHeader } from './TaskDetailsHeader';
|
import TaskDetailsHeader from './TaskDetailsHeader';
|
||||||
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
||||||
import { EditorSelectionDialog } from './EditorSelectionDialog';
|
import { EditorSelectionDialog } from './EditorSelectionDialog';
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +76,7 @@ export function TaskDetailsPanel({
|
|||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
userSelectedTab={userSelectedTab}
|
userSelectedTab={userSelectedTab}
|
||||||
|
projectHasDevScript={projectHasDevScript}
|
||||||
>
|
>
|
||||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||||
<div className={getBackdropClasses()} onClick={onClose} />
|
<div className={getBackdropClasses()} onClick={onClose} />
|
||||||
@@ -89,7 +90,7 @@ export function TaskDetailsPanel({
|
|||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CollapsibleToolbar projectHasDevScript={projectHasDevScript} />
|
<CollapsibleToolbar />
|
||||||
|
|
||||||
<TabNavigation
|
<TabNavigation
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,19 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
|
||||||
import { useContext, useMemo, useState } from 'react';
|
import { useContext, useMemo, useState } from 'react';
|
||||||
import { makeRequest } from '@/lib/api.ts';
|
import { makeRequest } from '@/lib/api.ts';
|
||||||
import { TaskDetailsContext } from '@/components/context/taskDetailsContext.ts';
|
import {
|
||||||
|
TaskAttemptDataContext,
|
||||||
|
TaskDetailsContext,
|
||||||
|
TaskSelectedAttemptContext,
|
||||||
|
} from '@/components/context/taskDetailsContext.ts';
|
||||||
|
|
||||||
export function TaskFollowUpSection() {
|
export function TaskFollowUpSection() {
|
||||||
const {
|
const { task, projectId } = useContext(TaskDetailsContext);
|
||||||
task,
|
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||||
projectId,
|
const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
|
||||||
selectedAttempt,
|
TaskAttemptDataContext
|
||||||
isAttemptRunning,
|
);
|
||||||
attemptData,
|
|
||||||
fetchAttemptData,
|
|
||||||
} = useContext(TaskDetailsContext);
|
|
||||||
const [followUpMessage, setFollowUpMessage] = useState('');
|
const [followUpMessage, setFollowUpMessage] = useState('');
|
||||||
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
|
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
|
||||||
const [followUpError, setFollowUpError] = useState<string | null>(null);
|
const [followUpError, setFollowUpError] = useState<string | null>(null);
|
||||||
|
|||||||
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 { createPortal } from 'react-dom';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { makeRequest } from '@/lib/api';
|
import { makeRequest } from '@/lib/api';
|
||||||
|
import { ApiResponse } from 'shared/types.ts';
|
||||||
|
|
||||||
interface FileSearchResult {
|
interface FileSearchResult {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data: T | null;
|
|
||||||
message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileSearchTextareaProps {
|
interface FileSearchTextareaProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
|
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
|
||||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||||
import type {
|
import type {
|
||||||
|
ApiResponse,
|
||||||
CreateTaskAndStart,
|
CreateTaskAndStart,
|
||||||
ExecutorConfig,
|
ExecutorConfig,
|
||||||
ProjectWithBranch,
|
ProjectWithBranch,
|
||||||
@@ -26,12 +27,6 @@ import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
|||||||
|
|
||||||
type Task = TaskWithAttemptStatus;
|
type Task = TaskWithAttemptStatus;
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data: T | null;
|
|
||||||
message: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectTasks() {
|
export function ProjectTasks() {
|
||||||
const { projectId, taskId } = useParams<{
|
const { projectId, taskId } = useParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -52,12 +47,12 @@ export function ProjectTasks() {
|
|||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
|
|
||||||
// Define task creation handler
|
// Define task creation handler
|
||||||
const handleCreateNewTask = () => {
|
const handleCreateNewTask = useCallback(() => {
|
||||||
setEditingTask(null);
|
setEditingTask(null);
|
||||||
setIsTaskDialogOpen(true);
|
setIsTaskDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleOpenInIDE = async () => {
|
const handleOpenInIDE = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -79,7 +74,7 @@ export function ProjectTasks() {
|
|||||||
console.error('Failed to open project in IDE:', error);
|
console.error('Failed to open project in IDE:', error);
|
||||||
setError('Failed to open project in IDE');
|
setError('Failed to open project in IDE');
|
||||||
}
|
}
|
||||||
};
|
}, [projectId]);
|
||||||
|
|
||||||
// Setup keyboard shortcuts
|
// Setup keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
@@ -110,7 +105,10 @@ export function ProjectTasks() {
|
|||||||
if (taskId && tasks.length > 0) {
|
if (taskId && tasks.length > 0) {
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
setSelectedTask(task);
|
setSelectedTask((prev) => {
|
||||||
|
if (JSON.stringify(prev) === JSON.stringify(task)) return prev;
|
||||||
|
return task;
|
||||||
|
});
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,19 +152,19 @@ export function ProjectTasks() {
|
|||||||
return prevTasks; // Return same reference to prevent re-render
|
return prevTasks; // Return same reference to prevent re-render
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update selectedTask if it exists and has been modified
|
setSelectedTask((prev) => {
|
||||||
if (selectedTask) {
|
if (!prev) return prev;
|
||||||
|
|
||||||
const updatedSelectedTask = newTasks.find(
|
const updatedSelectedTask = newTasks.find(
|
||||||
(task) => task.id === selectedTask.id
|
(task) => task.id === prev.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updatedSelectedTask &&
|
JSON.stringify(prev) === JSON.stringify(updatedSelectedTask)
|
||||||
JSON.stringify(selectedTask) !==
|
)
|
||||||
JSON.stringify(updatedSelectedTask)
|
return prev;
|
||||||
) {
|
return updatedSelectedTask || prev;
|
||||||
setSelectedTask(updatedSelectedTask);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTasks;
|
return newTasks;
|
||||||
});
|
});
|
||||||
@@ -182,173 +180,190 @@ export function ProjectTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, selectedTask]
|
[projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateTask = async (title: string, description: string) => {
|
const handleCreateTask = useCallback(
|
||||||
try {
|
async (title: string, description: string) => {
|
||||||
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
|
try {
|
||||||
method: 'POST',
|
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
|
||||||
body: JSON.stringify({
|
|
||||||
project_id: projectId,
|
|
||||||
title,
|
|
||||||
description: description || null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await fetchTasks();
|
|
||||||
} else {
|
|
||||||
setError('Failed to create task');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to create task');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAndStartTask = async (
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
executor?: ExecutorConfig
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const payload: CreateTaskAndStart = {
|
|
||||||
project_id: projectId!,
|
|
||||||
title,
|
|
||||||
description: description || null,
|
|
||||||
executor: executor || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await makeRequest(
|
|
||||||
`/api/projects/${projectId}/tasks/create-and-start`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result: ApiResponse<Task> = await response.json();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
await fetchTasks();
|
|
||||||
// Open the newly created task in the details panel
|
|
||||||
handleViewTaskDetails(result.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('Failed to create and start task');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to create and start task');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateTask = async (
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
status: TaskStatus
|
|
||||||
) => {
|
|
||||||
if (!editingTask) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await makeRequest(
|
|
||||||
`/api/projects/${projectId}/tasks/${editingTask.id}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
project_id: projectId,
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
status,
|
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
setEditingTask(null);
|
} else {
|
||||||
} else {
|
setError('Failed to create task');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to create task');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, fetchTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateAndStartTask = useCallback(
|
||||||
|
async (title: string, description: string, executor?: ExecutorConfig) => {
|
||||||
|
try {
|
||||||
|
const payload: CreateTaskAndStart = {
|
||||||
|
project_id: projectId!,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
executor: executor || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/create-and-start`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result: ApiResponse<Task> = await response.json();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
await fetchTasks();
|
||||||
|
// Open the newly created task in the details panel
|
||||||
|
handleViewTaskDetails(result.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Failed to create and start task');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to create and start task');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, fetchTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdateTask = useCallback(
|
||||||
|
async (title: string, description: string, status: TaskStatus) => {
|
||||||
|
if (!editingTask) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${editingTask.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchTasks();
|
||||||
|
setEditingTask(null);
|
||||||
|
} else {
|
||||||
|
setError('Failed to update task');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
setError('Failed to update task');
|
setError('Failed to update task');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
setError('Failed to update task');
|
[projectId, editingTask, fetchTasks]
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTask = async (taskId: string) => {
|
const handleDeleteTask = useCallback(
|
||||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
async (taskId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await makeRequest(
|
const response = await makeRequest(
|
||||||
`/api/projects/${projectId}/tasks/${taskId}`,
|
`/api/projects/${projectId}/tasks/${taskId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchTasks();
|
||||||
|
} else {
|
||||||
|
setError('Failed to delete task');
|
||||||
}
|
}
|
||||||
);
|
} catch (err) {
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await fetchTasks();
|
|
||||||
} else {
|
|
||||||
setError('Failed to delete task');
|
setError('Failed to delete task');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
setError('Failed to delete task');
|
[projectId, fetchTasks]
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditTask = (task: Task) => {
|
const handleEditTask = useCallback((task: Task) => {
|
||||||
setEditingTask(task);
|
setEditingTask(task);
|
||||||
setIsTaskDialogOpen(true);
|
setIsTaskDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleViewTaskDetails = (task: Task) => {
|
const handleViewTaskDetails = useCallback(
|
||||||
setSelectedTask(task);
|
(task: Task) => {
|
||||||
setIsPanelOpen(true);
|
setSelectedTask(task);
|
||||||
// Update URL to include task ID
|
setIsPanelOpen(true);
|
||||||
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
|
// Update URL to include task ID
|
||||||
};
|
navigate(`/projects/${projectId}/tasks/${task.id}`, { replace: true });
|
||||||
|
},
|
||||||
|
[projectId, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClosePanel = () => {
|
const handleClosePanel = useCallback(() => {
|
||||||
setIsPanelOpen(false);
|
setIsPanelOpen(false);
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
// Remove task ID from URL when closing panel
|
// Remove task ID from URL when closing panel
|
||||||
navigate(`/projects/${projectId}/tasks`, { replace: true });
|
navigate(`/projects/${projectId}/tasks`, { replace: true });
|
||||||
};
|
}, [projectId, navigate]);
|
||||||
|
|
||||||
const handleProjectSettingsSuccess = () => {
|
const handleProjectSettingsSuccess = useCallback(() => {
|
||||||
setIsProjectSettingsOpen(false);
|
setIsProjectSettingsOpen(false);
|
||||||
fetchProject(); // Refresh project data after settings change
|
fetchProject(); // Refresh project data after settings change
|
||||||
};
|
}, [fetchProject]);
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = useCallback(
|
||||||
const { active, over } = event;
|
async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over || !active.data.current) return;
|
if (!over || !active.data.current) return;
|
||||||
|
|
||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
const newStatus = over.id as Task['status'];
|
const newStatus = over.id as Task['status'];
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
|
||||||
if (!task || task.status === newStatus) return;
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
// Optimistically update the UI immediately
|
// Optimistically update the UI immediately
|
||||||
const previousStatus = task.status;
|
const previousStatus = task.status;
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await makeRequest(
|
|
||||||
`/api/projects/${projectId}/tasks/${taskId}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: task.title,
|
|
||||||
description: task.description,
|
|
||||||
status: newStatus,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/projects/${projectId}/tasks/${taskId}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
status: newStatus,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Revert the optimistic update if the API call failed
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId ? { ...t, status: previousStatus } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setError('Failed to update task status');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
// Revert the optimistic update if the API call failed
|
// Revert the optimistic update if the API call failed
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) =>
|
prev.map((t) =>
|
||||||
@@ -357,16 +372,9 @@ export function ProjectTasks() {
|
|||||||
);
|
);
|
||||||
setError('Failed to update task status');
|
setError('Failed to update task status');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
// Revert the optimistic update if the API call failed
|
[projectId, tasks]
|
||||||
setTasks((prev) =>
|
);
|
||||||
prev.map((t) =>
|
|
||||||
t.id === taskId ? { ...t, status: previousStatus } : t
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setError('Failed to update task status');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8">Loading tasks...</div>;
|
return <div className="text-center py-8">Loading tasks...</div>;
|
||||||
@@ -375,6 +383,7 @@ export function ProjectTasks() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return <div className="text-center py-8 text-destructive">{error}</div>;
|
return <div className="text-center py-8 text-destructive">{error}</div>;
|
||||||
}
|
}
|
||||||
|
console.log('selectedTask', selectedTask);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={getMainContainerClasses(isPanelOpen)}>
|
<div className={getMainContainerClasses(isPanelOpen)}>
|
||||||
|
|||||||
@@ -198,6 +198,21 @@ export type DiffChunkType = "Equal" | "Insert" | "Delete";
|
|||||||
|
|
||||||
export type DiffChunk = { chunk_type: DiffChunkType, content: string, };
|
export type DiffChunk = { chunk_type: DiffChunkType, content: string, };
|
||||||
|
|
||||||
|
export interface ProcessedLine {
|
||||||
|
content: string;
|
||||||
|
chunkType: DiffChunkType;
|
||||||
|
oldLineNumber?: number;
|
||||||
|
newLineNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedSection {
|
||||||
|
type: 'context' | 'change' | 'expanded';
|
||||||
|
lines: ProcessedLine[];
|
||||||
|
expandKey?: string;
|
||||||
|
expandedAbove?: boolean;
|
||||||
|
expandedBelow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
|
export type FileDiff = { path: string, chunks: Array<DiffChunk>, };
|
||||||
|
|
||||||
export type WorktreeDiff = { files: Array<FileDiff>, };
|
export type WorktreeDiff = { files: Array<FileDiff>, };
|
||||||
|
|||||||
Reference in New Issue
Block a user