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:
Alex Netsch
2025-07-17 14:35:44 +01:00
committed by GitHub
parent a234bd4658
commit ad38c8af53
32 changed files with 1227 additions and 259 deletions

View File

@@ -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>

View File

@@ -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
);

View File

@@ -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 */}

View File

@@ -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' ||

View 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;

View File

@@ -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>
);

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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>
</>
)
)}
</>
)}

View File

@@ -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>
),

View File

@@ -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

View File

@@ -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';
}

View File

@@ -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