Add plan mode (#174)
* feat: add related tasks functionality to task details panel - Introduced a new context for managing related tasks, including fetching and state management. - Added a new RelatedTasksTab component to display related tasks and their statuses. - Updated TaskDetailsProvider to fetch related tasks based on the selected attempt. - Enhanced TaskDetailsContext to include related tasks state and methods. - Modified TabNavigation to include a new tab for related tasks with a count indicator. - Updated TaskDetailsPanel to render the RelatedTasksTab when selected. - Adjusted API calls to support fetching related tasks and task details. - Updated types to include parent_task_attempt in task-related data structures. - Enhanced UI components to reflect changes in task statuses and interactions. Padding (vibe-kanban 97abacaa) frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx Add some padding to make tasks in the list look nice Move get children; Search for latest plan across all processes Jump to task created from plan feat: add latest attempt executor to task status and update TaskCard UI * Use correct naming convention * feat: enhance plan presentation handling in Claude executor and UI * format * Always show create task for planning tasks * Add claude hook to stop after plan creation * Lint --------- Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
@@ -12,12 +12,14 @@ import {
|
||||
import type {
|
||||
EditorType,
|
||||
ExecutionProcess,
|
||||
ExecutionProcessSummary,
|
||||
Task,
|
||||
TaskAttempt,
|
||||
TaskAttemptState,
|
||||
TaskWithAttemptStatus,
|
||||
WorktreeDiff,
|
||||
} from 'shared/types.ts';
|
||||
import { attemptsApi, executionProcessesApi } from '@/lib/api.ts';
|
||||
import { attemptsApi, executionProcessesApi, tasksApi } from '@/lib/api.ts';
|
||||
import {
|
||||
TaskAttemptDataContext,
|
||||
TaskAttemptLoadingContext,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
TaskDetailsContext,
|
||||
TaskDiffContext,
|
||||
TaskExecutionStateContext,
|
||||
TaskRelatedTasksContext,
|
||||
TaskSelectedAttemptContext,
|
||||
} from './taskDetailsContext.ts';
|
||||
import { AttemptData } from '@/lib/types.ts';
|
||||
@@ -35,8 +38,8 @@ const TaskDetailsProvider: FC<{
|
||||
task: TaskWithAttemptStatus;
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
activeTab: 'logs' | 'diffs';
|
||||
setActiveTab: Dispatch<SetStateAction<'logs' | 'diffs'>>;
|
||||
activeTab: 'logs' | 'diffs' | 'related';
|
||||
setActiveTab: Dispatch<SetStateAction<'logs' | 'diffs' | 'related'>>;
|
||||
setShowEditorDialog: Dispatch<SetStateAction<boolean>>;
|
||||
userSelectedTab: boolean;
|
||||
projectHasDevScript?: boolean;
|
||||
@@ -64,6 +67,13 @@ const TaskDetailsProvider: FC<{
|
||||
const [diffError, setDiffError] = useState<string | null>(null);
|
||||
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
|
||||
|
||||
// Related tasks state
|
||||
const [relatedTasks, setRelatedTasks] = useState<Task[] | null>(null);
|
||||
const [relatedTasksLoading, setRelatedTasksLoading] = useState(true);
|
||||
const [relatedTasksError, setRelatedTasksError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [executionState, setExecutionState] = useState<TaskAttemptState | null>(
|
||||
null
|
||||
);
|
||||
@@ -75,6 +85,39 @@ const TaskDetailsProvider: FC<{
|
||||
});
|
||||
|
||||
const diffLoadingRef = useRef(false);
|
||||
const relatedTasksLoadingRef = useRef(false);
|
||||
|
||||
const fetchRelatedTasks = useCallback(async () => {
|
||||
if (!projectId || !task?.id || !selectedAttempt?.id) {
|
||||
setRelatedTasks(null);
|
||||
setRelatedTasksLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent multiple concurrent requests
|
||||
if (relatedTasksLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
relatedTasksLoadingRef.current = true;
|
||||
setRelatedTasksLoading(true);
|
||||
setRelatedTasksError(null);
|
||||
|
||||
try {
|
||||
const children = await tasksApi.getChildren(
|
||||
projectId,
|
||||
task.id,
|
||||
selectedAttempt.id
|
||||
);
|
||||
setRelatedTasks(children);
|
||||
} catch (err) {
|
||||
console.error('Failed to load related tasks:', err);
|
||||
setRelatedTasksError('Failed to load related tasks');
|
||||
} finally {
|
||||
relatedTasksLoadingRef.current = false;
|
||||
setRelatedTasksLoading(false);
|
||||
}
|
||||
}, [projectId, task?.id, selectedAttempt?.id]);
|
||||
|
||||
const fetchDiff = useCallback(
|
||||
async (isBackgroundRefresh = false) => {
|
||||
@@ -126,6 +169,21 @@ const TaskDetailsProvider: FC<{
|
||||
fetchDiff();
|
||||
}, [fetchDiff]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAttempt && task) {
|
||||
fetchRelatedTasks();
|
||||
} else if (task && !selectedAttempt) {
|
||||
// If we have a task but no selectedAttempt, wait a bit then clear loading state
|
||||
// This happens when a task has no attempts yet
|
||||
const timeout = setTimeout(() => {
|
||||
setRelatedTasks(null);
|
||||
setRelatedTasksLoading(false);
|
||||
}, 1000); // Wait 1 second for attempts to load
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [selectedAttempt, task, fetchRelatedTasks]);
|
||||
|
||||
const fetchExecutionState = useCallback(
|
||||
async (attemptId: string, taskId: string) => {
|
||||
if (!task) return;
|
||||
@@ -217,7 +275,7 @@ const TaskDetailsProvider: FC<{
|
||||
}
|
||||
}
|
||||
|
||||
setAttemptData((prev) => {
|
||||
setAttemptData((prev: AttemptData) => {
|
||||
const newData = {
|
||||
activities: activitiesResult,
|
||||
processes: processesResult,
|
||||
@@ -247,7 +305,7 @@ const TaskDetailsProvider: FC<{
|
||||
}
|
||||
|
||||
return attemptData.processes.some(
|
||||
(process) =>
|
||||
(process: ExecutionProcessSummary) =>
|
||||
(process.process_type === 'codingagent' ||
|
||||
process.process_type === 'setupscript') &&
|
||||
process.status === 'running'
|
||||
@@ -400,6 +458,27 @@ const TaskDetailsProvider: FC<{
|
||||
[executionState, fetchExecutionState]
|
||||
);
|
||||
|
||||
const relatedTasksValue = useMemo(
|
||||
() => ({
|
||||
relatedTasks,
|
||||
setRelatedTasks,
|
||||
relatedTasksLoading,
|
||||
setRelatedTasksLoading,
|
||||
relatedTasksError,
|
||||
setRelatedTasksError,
|
||||
fetchRelatedTasks,
|
||||
totalRelatedCount:
|
||||
(task?.parent_task_attempt ? 1 : 0) + (relatedTasks?.length || 0),
|
||||
}),
|
||||
[
|
||||
relatedTasks,
|
||||
relatedTasksLoading,
|
||||
relatedTasksError,
|
||||
fetchRelatedTasks,
|
||||
task?.parent_task_attempt,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<TaskDetailsContext.Provider value={value}>
|
||||
<TaskAttemptLoadingContext.Provider value={taskAttemptLoadingValue}>
|
||||
@@ -414,7 +493,11 @@ const TaskDetailsProvider: FC<{
|
||||
<TaskBackgroundRefreshContext.Provider
|
||||
value={backgroundRefreshingValue}
|
||||
>
|
||||
{children}
|
||||
<TaskRelatedTasksContext.Provider
|
||||
value={relatedTasksValue}
|
||||
>
|
||||
{children}
|
||||
</TaskRelatedTasksContext.Provider>
|
||||
</TaskBackgroundRefreshContext.Provider>
|
||||
</TaskExecutionStateContext.Provider>
|
||||
</TaskAttemptDataContext.Provider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, Dispatch, SetStateAction } from 'react';
|
||||
import type {
|
||||
EditorType,
|
||||
Task,
|
||||
TaskAttempt,
|
||||
TaskAttemptState,
|
||||
TaskWithAttemptStatus,
|
||||
@@ -106,3 +107,19 @@ export const TaskExecutionStateContext =
|
||||
createContext<TaskExecutionStateContextValue>(
|
||||
{} as TaskExecutionStateContextValue
|
||||
);
|
||||
|
||||
interface TaskRelatedTasksContextValue {
|
||||
relatedTasks: Task[] | null;
|
||||
setRelatedTasks: Dispatch<SetStateAction<Task[] | null>>;
|
||||
relatedTasksLoading: boolean;
|
||||
setRelatedTasksLoading: Dispatch<SetStateAction<boolean>>;
|
||||
relatedTasksError: string | null;
|
||||
setRelatedTasksError: Dispatch<SetStateAction<string | null>>;
|
||||
fetchRelatedTasks: () => Promise<void>;
|
||||
totalRelatedCount: number;
|
||||
}
|
||||
|
||||
export const TaskRelatedTasksContext =
|
||||
createContext<TaskRelatedTasksContextValue>(
|
||||
{} as TaskRelatedTasksContextValue
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||
import { is_planning_executor_type } from '@/lib/utils';
|
||||
|
||||
type Task = TaskWithAttemptStatus;
|
||||
|
||||
@@ -78,7 +80,17 @@ export function TaskCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 pr-2">
|
||||
<h4 className="font-medium text-sm break-words">{task.title}</h4>
|
||||
<div className="mb-1">
|
||||
<h4 className="font-medium text-sm break-words">
|
||||
{task.latest_attempt_executor &&
|
||||
is_planning_executor_type(task.latest_attempt_executor) && (
|
||||
<Badge className="bg-blue-600 text-white hover:bg-blue-700 text-xs font-medium px-1.5 py-0.5 h-4 text-[10px] mr-1">
|
||||
PLAN
|
||||
</Badge>
|
||||
)}
|
||||
{task.title}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* In Progress Spinner */}
|
||||
|
||||
@@ -78,6 +78,9 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
|
||||
if (action_type.action === 'task_create') {
|
||||
return <Plus className="h-4 w-4 text-teal-600" />;
|
||||
}
|
||||
if (action_type.action === 'plan_presentation') {
|
||||
return <CheckSquare className="h-4 w-4 text-blue-600" />;
|
||||
}
|
||||
return <Settings className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
return <Settings className="h-4 w-4 text-gray-400" />;
|
||||
@@ -109,6 +112,14 @@ const getContentClassName = (entryType: NormalizedEntryType) => {
|
||||
return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`;
|
||||
}
|
||||
|
||||
// Special styling for plan presentations
|
||||
if (
|
||||
entryType.type === 'tool_use' &&
|
||||
entryType.action_type.action === 'plan_presentation'
|
||||
) {
|
||||
return `${baseClasses} text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 rounded-md border-l-4 border-blue-400`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
};
|
||||
|
||||
@@ -224,9 +235,11 @@ const createIncrementalDiff = (
|
||||
|
||||
// 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
|
||||
// Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks
|
||||
return (
|
||||
entryType.type === 'assistant_message' ||
|
||||
(entryType.type === 'tool_use' &&
|
||||
entryType.action_type.action === 'plan_presentation') ||
|
||||
(entryType.type === 'tool_use' &&
|
||||
entryType.tool_name &&
|
||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||
|
||||
216
frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx
Normal file
216
frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
TaskDetailsContext,
|
||||
TaskRelatedTasksContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
import { attemptsApi, tasksApi } from '@/lib/api.ts';
|
||||
import type { Task, TaskAttempt } from 'shared/types.ts';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
function RelatedTasksTab() {
|
||||
const { task, projectId } = useContext(TaskDetailsContext);
|
||||
const { relatedTasks, relatedTasksLoading, relatedTasksError } = useContext(
|
||||
TaskRelatedTasksContext
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// State for parent task details
|
||||
const [parentTaskDetails, setParentTaskDetails] = useState<{
|
||||
task: Task;
|
||||
attempt: TaskAttempt;
|
||||
} | null>(null);
|
||||
const [parentTaskLoading, setParentTaskLoading] = useState(false);
|
||||
|
||||
const handleTaskClick = (relatedTask: any) => {
|
||||
navigate(`/projects/${projectId}/tasks/${relatedTask.id}`);
|
||||
};
|
||||
|
||||
const hasParent = task?.parent_task_attempt;
|
||||
const children = relatedTasks || [];
|
||||
|
||||
// Fetch parent task details when component mounts
|
||||
useEffect(() => {
|
||||
const fetchParentTaskDetails = async () => {
|
||||
if (!task?.parent_task_attempt) {
|
||||
setParentTaskDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setParentTaskLoading(true);
|
||||
try {
|
||||
const attemptData = await attemptsApi.getDetails(
|
||||
task.parent_task_attempt
|
||||
);
|
||||
const parentTask = await tasksApi.getById(
|
||||
projectId,
|
||||
attemptData.task_id
|
||||
);
|
||||
setParentTaskDetails({
|
||||
task: parentTask,
|
||||
attempt: attemptData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching parent task details:', error);
|
||||
setParentTaskDetails(null);
|
||||
} finally {
|
||||
setParentTaskLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchParentTaskDetails();
|
||||
}, [task?.parent_task_attempt, projectId]);
|
||||
|
||||
const handleParentClick = async () => {
|
||||
if (task?.parent_task_attempt) {
|
||||
try {
|
||||
const attemptData = await attemptsApi.getDetails(
|
||||
task.parent_task_attempt
|
||||
);
|
||||
navigate(
|
||||
`/projects/${projectId}/tasks/${attemptData.task_id}?attempt=${task.parent_task_attempt}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error navigating to parent task:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'inprogress':
|
||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
||||
case 'cancelled':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'inreview':
|
||||
return <AlertCircle className="h-4 w-4 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (relatedTasksLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (relatedTasksError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600">{relatedTasksError}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalRelatedTasks = (hasParent ? 1 : 0) + children.length;
|
||||
|
||||
if (totalRelatedTasks === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<p>No related tasks found.</p>
|
||||
<p className="text-sm mt-2">
|
||||
This task doesn't have any parent task or subtasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Parent Task */}
|
||||
{hasParent && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Parent Task
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleParentClick}
|
||||
className="w-full bg-card border border-border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
{parentTaskLoading ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<div className="text-muted-foreground">
|
||||
Loading parent task...
|
||||
</div>
|
||||
</div>
|
||||
) : parentTaskDetails ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-foreground">
|
||||
{parentTaskDetails.task.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(
|
||||
parentTaskDetails.attempt.created_at
|
||||
).toLocaleDateString()}{' '}
|
||||
{new Date(
|
||||
parentTaskDetails.attempt.created_at
|
||||
).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-muted-foreground">
|
||||
Parent task (failed to load details)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Child Tasks */}
|
||||
{children.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
Child Tasks ({children.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{children.map((childTask) => (
|
||||
<button
|
||||
key={childTask.id}
|
||||
onClick={() => handleTaskClick(childTask)}
|
||||
className="w-full bg-card border border-border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(childTask.status)}
|
||||
<span className="font-medium text-foreground">
|
||||
{childTask.title}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RelatedTasksTab;
|
||||
@@ -1,15 +1,19 @@
|
||||
import { GitCompare, MessageSquare } from 'lucide-react';
|
||||
import { GitCompare, MessageSquare, Network } from 'lucide-react';
|
||||
import { useContext } from 'react';
|
||||
import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts';
|
||||
import {
|
||||
TaskDiffContext,
|
||||
TaskRelatedTasksContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
|
||||
type Props = {
|
||||
activeTab: 'logs' | 'diffs';
|
||||
setActiveTab: (tab: 'logs' | 'diffs') => void;
|
||||
activeTab: 'logs' | 'diffs' | 'related';
|
||||
setActiveTab: (tab: 'logs' | 'diffs' | 'related') => void;
|
||||
setUserSelectedTab: (tab: boolean) => void;
|
||||
};
|
||||
|
||||
function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
|
||||
const { diff } = useContext(TaskDiffContext);
|
||||
const { totalRelatedCount } = useContext(TaskRelatedTasksContext);
|
||||
return (
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="flex px-4">
|
||||
@@ -48,6 +52,28 @@ function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'Related Tasks tab clicked - setting activeTab to related'
|
||||
);
|
||||
setActiveTab('related');
|
||||
setUserSelectedTab(true);
|
||||
}}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'related'
|
||||
? 'border-primary text-primary bg-background'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
Related Tasks
|
||||
{totalRelatedCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary/10 text-primary rounded-full">
|
||||
{totalRelatedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||
import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
|
||||
import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
|
||||
import RelatedTasksTab from '@/components/tasks/TaskDetails/RelatedTasksTab.tsx';
|
||||
import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
|
||||
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
|
||||
import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx';
|
||||
@@ -36,7 +37,9 @@ export function TaskDetailsPanel({
|
||||
const [showEditorDialog, setShowEditorDialog] = useState(false);
|
||||
|
||||
// Tab and collapsible state
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs');
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'diffs' | 'related'>(
|
||||
'logs'
|
||||
);
|
||||
const [userSelectedTab, setUserSelectedTab] = useState<boolean>(false);
|
||||
|
||||
// Reset to logs tab when task changes
|
||||
@@ -67,6 +70,7 @@ export function TaskDetailsPanel({
|
||||
<>
|
||||
{!task ? null : (
|
||||
<TaskDetailsProvider
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
setShowEditorDialog={setShowEditorDialog}
|
||||
@@ -99,7 +103,13 @@ export function TaskDetailsPanel({
|
||||
<div
|
||||
className={`flex-1 flex flex-col min-h-0 ${activeTab === 'logs' ? 'p-4' : 'pt-4'}`}
|
||||
>
|
||||
{activeTab === 'diffs' ? <DiffTab /> : <LogsTab />}
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab />
|
||||
) : activeTab === 'related' ? (
|
||||
<RelatedTasksTab />
|
||||
) : (
|
||||
<LogsTab />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfig } from '@/components/config-provider';
|
||||
@@ -28,6 +29,7 @@ function TaskDetailsToolbar() {
|
||||
const { selectedAttempt, setSelectedAttempt } = useContext(
|
||||
TaskSelectedAttemptContext
|
||||
);
|
||||
|
||||
const { isStopping } = useContext(TaskAttemptStoppingContext);
|
||||
const { fetchAttemptData, setAttemptData, isAttemptRunning } = useContext(
|
||||
TaskAttemptDataContext
|
||||
@@ -35,6 +37,7 @@ function TaskDetailsToolbar() {
|
||||
const { fetchExecutionState } = useContext(TaskExecutionStateContext);
|
||||
|
||||
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([]);
|
||||
const location = useLocation();
|
||||
|
||||
const { config } = useConfig();
|
||||
|
||||
@@ -125,18 +128,46 @@ function TaskDetailsToolbar() {
|
||||
});
|
||||
|
||||
if (result.length > 0) {
|
||||
const latestAttempt = result.reduce((latest, current) =>
|
||||
new Date(current.created_at) > new Date(latest.created_at)
|
||||
? current
|
||||
: latest
|
||||
);
|
||||
// Check if there's an attempt query parameter
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const attemptParam = urlParams.get('attempt');
|
||||
|
||||
let selectedAttemptToUse: TaskAttempt;
|
||||
|
||||
if (attemptParam) {
|
||||
// Try to find the specific attempt
|
||||
const specificAttempt = result.find(
|
||||
(attempt) => attempt.id === attemptParam
|
||||
);
|
||||
if (specificAttempt) {
|
||||
selectedAttemptToUse = specificAttempt;
|
||||
} else {
|
||||
// Fall back to latest if specific attempt not found
|
||||
selectedAttemptToUse = result.reduce((latest, current) =>
|
||||
new Date(current.created_at) > new Date(latest.created_at)
|
||||
? current
|
||||
: latest
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use latest attempt if no specific attempt requested
|
||||
selectedAttemptToUse = result.reduce((latest, current) =>
|
||||
new Date(current.created_at) > new Date(latest.created_at)
|
||||
? current
|
||||
: latest
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedAttempt((prev) => {
|
||||
if (JSON.stringify(prev) === JSON.stringify(latestAttempt))
|
||||
if (JSON.stringify(prev) === JSON.stringify(selectedAttemptToUse))
|
||||
return prev;
|
||||
return latestAttempt;
|
||||
return selectedAttemptToUse;
|
||||
});
|
||||
fetchAttemptData(latestAttempt.id, latestAttempt.task_id);
|
||||
fetchExecutionState(latestAttempt.id, latestAttempt.task_id);
|
||||
fetchAttemptData(selectedAttemptToUse.id, selectedAttemptToUse.task_id);
|
||||
fetchExecutionState(
|
||||
selectedAttemptToUse.id,
|
||||
selectedAttemptToUse.task_id
|
||||
);
|
||||
} else {
|
||||
setSelectedAttempt(null);
|
||||
setAttemptData({
|
||||
@@ -150,7 +181,7 @@ function TaskDetailsToolbar() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [task, projectId, fetchAttemptData, fetchExecutionState]);
|
||||
}, [task, projectId, fetchAttemptData, fetchExecutionState, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskAttempts();
|
||||
@@ -235,7 +266,7 @@ function TaskDetailsToolbar() {
|
||||
branches={branches}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 flex-1">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-muted-foreground">
|
||||
No attempts yet
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Settings,
|
||||
StopCircle,
|
||||
} from 'lucide-react';
|
||||
import { is_planning_executor_type } from '@/lib/utils';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -31,7 +32,13 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog.tsx';
|
||||
import BranchSelector from '@/components/tasks/BranchSelector.tsx';
|
||||
import { attemptsApi, executionProcessesApi } from '@/lib/api.ts';
|
||||
import {
|
||||
attemptsApi,
|
||||
executionProcessesApi,
|
||||
makeRequest,
|
||||
FollowUpResponse,
|
||||
ApiResponse,
|
||||
} from '@/lib/api.ts';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
@@ -52,10 +59,12 @@ import {
|
||||
TaskAttemptStoppingContext,
|
||||
TaskDetailsContext,
|
||||
TaskExecutionStateContext,
|
||||
TaskRelatedTasksContext,
|
||||
TaskSelectedAttemptContext,
|
||||
} from '@/components/context/taskDetailsContext.ts';
|
||||
import { useConfig } from '@/components/config-provider.tsx';
|
||||
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Helper function to get the display name for different editor types
|
||||
function getEditorDisplayName(editorType: string): string {
|
||||
@@ -107,11 +116,15 @@ function CurrentAttempt({
|
||||
useContext(TaskDetailsContext);
|
||||
const { config } = useConfig();
|
||||
const { setSelectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||
const navigate = useNavigate();
|
||||
const { isStopping, setIsStopping } = useContext(TaskAttemptStoppingContext);
|
||||
const { attemptData, fetchAttemptData, isAttemptRunning } = useContext(
|
||||
TaskAttemptDataContext
|
||||
);
|
||||
const { fetchExecutionState } = useContext(TaskExecutionStateContext);
|
||||
const { relatedTasks } = useContext(TaskRelatedTasksContext);
|
||||
const { executionState, fetchExecutionState } = useContext(
|
||||
TaskExecutionStateContext
|
||||
);
|
||||
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
@@ -124,6 +137,7 @@ function CurrentAttempt({
|
||||
const [showRebaseDialog, setShowRebaseDialog] = useState(false);
|
||||
const [selectedRebaseBranch, setSelectedRebaseBranch] = useState<string>('');
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
const [isApprovingPlan, setIsApprovingPlan] = useState(false);
|
||||
|
||||
const processedDevServerLogs = useMemo(() => {
|
||||
if (!devServerDetails) return 'No output yet...';
|
||||
@@ -144,6 +158,14 @@ function CurrentAttempt({
|
||||
);
|
||||
}, [attemptData.processes]);
|
||||
|
||||
// Check if plan approval is needed
|
||||
const isPlanTask = useMemo(() => {
|
||||
return !!(
|
||||
selectedAttempt.executor &&
|
||||
is_planning_executor_type(selectedAttempt.executor)
|
||||
);
|
||||
}, [selectedAttempt.executor]);
|
||||
|
||||
const fetchDevServerDetails = useCallback(async () => {
|
||||
if (!runningDevServer || !task || !selectedAttempt) return;
|
||||
|
||||
@@ -375,6 +397,48 @@ function CurrentAttempt({
|
||||
setShowCreatePRDialog(true);
|
||||
};
|
||||
|
||||
const handlePlanApproval = async () => {
|
||||
if (!task || !selectedAttempt || !isPlanTask) return;
|
||||
|
||||
setIsApprovingPlan(true);
|
||||
try {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/approve-plan`,
|
||||
{
|
||||
method: 'POST',
|
||||
// No body needed - endpoint only handles approval now
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse<FollowUpResponse> = await response.json();
|
||||
if (result.success && result.data) {
|
||||
console.log('Plan approved successfully:', result.message);
|
||||
|
||||
// If a new task was created, navigate to it
|
||||
if (result.data.created_new_attempt) {
|
||||
const newTaskId = result.data.actual_attempt_id;
|
||||
console.log('Navigating to new task:', newTaskId);
|
||||
navigate(`/projects/${projectId}/tasks/${newTaskId}`);
|
||||
} else {
|
||||
// Otherwise, just refresh the current task data
|
||||
fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id);
|
||||
}
|
||||
} else {
|
||||
setError(`Failed to approve plan: ${result.message}`);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to approve plan');
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Error approving plan: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
setIsApprovingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display name for selected branch
|
||||
const selectedBranchDisplayName = useMemo(() => {
|
||||
if (!selectedBranch) return 'current';
|
||||
@@ -432,7 +496,10 @@ function CurrentAttempt({
|
||||
size="sm"
|
||||
onClick={handleRebaseDialogOpen}
|
||||
disabled={
|
||||
rebasing || branchStatusLoading || isAttemptRunning
|
||||
rebasing ||
|
||||
branchStatusLoading ||
|
||||
isAttemptRunning ||
|
||||
isPlanTask
|
||||
}
|
||||
className="h-4 w-4 p-0 hover:bg-muted"
|
||||
>
|
||||
@@ -455,10 +522,28 @@ function CurrentAttempt({
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
Merge Status
|
||||
{isPlanTask ? 'Plan Status' : 'Merge Status'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectedAttempt.merge_commit ? (
|
||||
{isPlanTask ? (
|
||||
// Plan status for planning tasks
|
||||
relatedTasks && relatedTasks.length > 0 ? (
|
||||
<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">
|
||||
Task Created
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2 w-2 bg-gray-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Draft
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : // Merge status for regular tasks
|
||||
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">
|
||||
@@ -482,7 +567,7 @@ function CurrentAttempt({
|
||||
|
||||
<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">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
Worktree Path
|
||||
</div>
|
||||
<Button
|
||||
@@ -603,54 +688,76 @@ function CurrentAttempt({
|
||||
{/* 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 && (
|
||||
<>
|
||||
{branchStatus.is_behind &&
|
||||
!branchStatus.merged &&
|
||||
!isPlanTask && (
|
||||
<Button
|
||||
onClick={handleCreatePRClick}
|
||||
onClick={handleRebaseClick}
|
||||
disabled={
|
||||
creatingPR ||
|
||||
Boolean(branchStatus.is_behind) ||
|
||||
isAttemptRunning
|
||||
rebasing || branchStatusLoading || isAttemptRunning
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-50 gap-1"
|
||||
className="border-orange-300 text-orange-700 hover:bg-orange-50 gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{selectedAttempt.pr_url
|
||||
? 'Open PR'
|
||||
: creatingPR
|
||||
? 'Creating...'
|
||||
: 'Create PR'}
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${rebasing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{rebasing ? 'Rebasing...' : `Rebase`}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{isPlanTask ? (
|
||||
// Plan tasks: show approval button
|
||||
<Button
|
||||
onClick={handlePlanApproval}
|
||||
disabled={
|
||||
isAttemptRunning ||
|
||||
executionState?.execution_state === 'CodingAgentFailed' ||
|
||||
executionState?.execution_state === 'SetupFailed'
|
||||
}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 gap-1"
|
||||
>
|
||||
<GitBranchIcon className="h-3 w-3" />
|
||||
{isApprovingPlan ? 'Approving...' : 'Create Task'}
|
||||
</Button>
|
||||
) : (
|
||||
// Normal merge and PR buttons for regular tasks
|
||||
!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>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,46 +28,37 @@ function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
</em>
|
||||
),
|
||||
p: ({ children, ...props }) => (
|
||||
<p {...props} className="mb-4 last:mb-0 leading-loose">
|
||||
<p {...props} className="leading-tight">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
{...props}
|
||||
className="text-lg font-bold mb-4 mt-6 first:mt-0 leading-relaxed"
|
||||
>
|
||||
<h1 {...props} className="text-lg font-bold leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2
|
||||
{...props}
|
||||
className="text-base font-bold mb-3 mt-5 first:mt-0 leading-relaxed"
|
||||
>
|
||||
<h2 {...props} className="text-base font-bold leading-tight">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3
|
||||
{...props}
|
||||
className="text-sm font-bold mb-3 mt-4 first:mt-0 leading-relaxed"
|
||||
>
|
||||
<h3 {...props} className="text-sm font-bold leading-tight">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul {...props} className="list-disc ml-4 mb-2 space-y-1">
|
||||
<ul {...props} className="list-disc ml-2">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol {...props} className="list-decimal ml-4 mb-2 space-y-1">
|
||||
<ol {...props} className="list-decimal ml-2">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children, ...props }) => (
|
||||
<li {...props} className="mb-1 leading-relaxed">
|
||||
<li {...props} className="leading-tight">
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface ApiResponse<T> {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpResponse {
|
||||
message: string;
|
||||
actual_attempt_id: string;
|
||||
created_new_attempt: boolean;
|
||||
}
|
||||
|
||||
// Additional interface for file search results
|
||||
export interface FileSearchResult {
|
||||
path: string;
|
||||
@@ -181,6 +187,13 @@ export const tasksApi = {
|
||||
return handleApiResponse<TaskWithAttemptStatus[]>(response);
|
||||
},
|
||||
|
||||
getById: async (projectId: string, taskId: string): Promise<Task> => {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${taskId}`
|
||||
);
|
||||
return handleApiResponse<Task>(response);
|
||||
},
|
||||
|
||||
create: async (projectId: string, data: CreateTask): Promise<Task> => {
|
||||
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
|
||||
method: 'POST',
|
||||
@@ -227,6 +240,17 @@ export const tasksApi = {
|
||||
);
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
getChildren: async (
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
attemptId: string
|
||||
): Promise<Task[]> => {
|
||||
const response = await makeRequest(
|
||||
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/children`
|
||||
);
|
||||
return handleApiResponse<Task[]>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Task Attempts APIs
|
||||
@@ -454,6 +478,11 @@ export const attemptsApi = {
|
||||
);
|
||||
return handleApiResponse<void>(response);
|
||||
},
|
||||
|
||||
getDetails: async (attemptId: string): Promise<TaskAttempt> => {
|
||||
const response = await makeRequest(`/api/attempts/${attemptId}/details`);
|
||||
return handleApiResponse<TaskAttempt>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Execution Process APIs
|
||||
|
||||
@@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function is_planning_executor_type(executorType: string): boolean {
|
||||
return executorType === 'claude-plan';
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ export function ProjectTasks() {
|
||||
project_id: projectId!,
|
||||
title,
|
||||
description: description || null,
|
||||
parent_task_attempt: null,
|
||||
});
|
||||
await fetchTasks();
|
||||
// Open the newly created task in the details panel
|
||||
@@ -196,6 +197,7 @@ export function ProjectTasks() {
|
||||
project_id: projectId!,
|
||||
title,
|
||||
description: description || null,
|
||||
parent_task_attempt: null,
|
||||
executor: executor || null,
|
||||
};
|
||||
const result = await tasksApi.createAndStart(projectId!, payload);
|
||||
@@ -218,6 +220,7 @@ export function ProjectTasks() {
|
||||
title,
|
||||
description: description || null,
|
||||
status,
|
||||
parent_task_attempt: null,
|
||||
});
|
||||
await fetchTasks();
|
||||
setEditingTask(null);
|
||||
@@ -292,6 +295,7 @@ export function ProjectTasks() {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: newStatus,
|
||||
parent_task_attempt: task.parent_task_attempt,
|
||||
});
|
||||
} catch (err) {
|
||||
// Revert the optimistic update if the API call failed
|
||||
|
||||
Reference in New Issue
Block a user