Files
vibe-kanban/frontend/src/components/tasks/TaskDetailsPanel.tsx

1204 lines
43 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
2025-06-21 17:36:07 +01:00
import {
X,
History,
Clock,
FileText,
Code,
2025-06-21 19:13:42 +01:00
ChevronDown,
ChevronUp,
2025-06-21 20:04:15 +01:00
Settings2,
2025-06-21 22:41:35 +01:00
Edit,
Trash2,
2025-06-21 23:40:13 +01:00
StopCircle,
Send,
2025-06-24 01:16:39 +01:00
AlertCircle,
Play,
2025-06-21 17:36:07 +01:00
} from "lucide-react";
2025-06-21 16:49:16 +01:00
import { Button } from "@/components/ui/button";
2025-06-24 01:16:39 +01:00
import { Alert, AlertDescription } from "@/components/ui/alert";
2025-06-21 16:49:16 +01:00
import { Label } from "@/components/ui/label";
2025-06-21 20:44:36 +01:00
import { Chip } from "@/components/ui/chip";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
2025-06-21 22:12:32 +01:00
import { ExecutionOutputViewer } from "./ExecutionOutputViewer";
import { EditorSelectionDialog } from "./EditorSelectionDialog";
2025-06-21 19:13:42 +01:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
2025-06-21 16:49:16 +01:00
import { makeRequest } from "@/lib/api";
2025-06-21 19:13:42 +01:00
import {
getTaskPanelClasses,
getBackdropClasses,
} from "@/lib/responsive-config";
2025-06-21 22:19:25 +01:00
import { useConfig } from "@/components/config-provider";
2025-06-21 16:49:16 +01:00
import type {
TaskStatus,
TaskAttempt,
TaskAttemptActivity,
TaskAttemptStatus,
ApiResponse,
TaskWithAttemptStatus,
2025-06-21 20:44:36 +01:00
ExecutionProcess,
ExecutionProcessSummary,
EditorType,
Project,
2025-06-21 16:49:16 +01:00
} from "shared/types";
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
project: Project | null;
2025-06-21 16:49:16 +01:00
projectId: string;
isOpen: boolean;
onClose: () => void;
2025-06-21 22:41:35 +01:00
onEditTask?: (task: TaskWithAttemptStatus) => void;
onDeleteTask?: (taskId: string) => void;
2025-06-21 16:49:16 +01:00
}
const statusLabels: Record<TaskStatus, string> = {
todo: "To Do",
2025-06-21 17:36:07 +01:00
inprogress: "In Progress",
2025-06-21 16:49:16 +01:00
inreview: "In Review",
done: "Done",
cancelled: "Cancelled",
};
2025-06-21 20:44:36 +01:00
const getTaskStatusDotColor = (status: TaskStatus): string => {
switch (status) {
case "todo":
return "bg-gray-400";
case "inprogress":
return "bg-blue-500";
case "inreview":
return "bg-yellow-500";
case "done":
return "bg-green-500";
case "cancelled":
return "bg-red-500";
default:
return "bg-gray-400";
}
};
2025-06-21 17:36:07 +01:00
const getAttemptStatusDisplay = (
status: TaskAttemptStatus
2025-06-21 20:44:36 +01:00
): { label: string; dotColor: string } => {
2025-06-21 16:49:16 +01:00
switch (status) {
case "setuprunning":
2025-06-21 17:36:07 +01:00
return {
label: "Setup Running",
2025-06-21 20:44:36 +01:00
dotColor: "bg-blue-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
case "setupcomplete":
2025-06-21 17:36:07 +01:00
return {
label: "Setup Complete",
2025-06-21 20:44:36 +01:00
dotColor: "bg-green-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
case "setupfailed":
2025-06-21 17:36:07 +01:00
return {
label: "Setup Failed",
2025-06-21 20:44:36 +01:00
dotColor: "bg-red-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
case "executorrunning":
2025-06-21 17:36:07 +01:00
return {
label: "Executor Running",
2025-06-21 20:44:36 +01:00
dotColor: "bg-blue-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
case "executorcomplete":
2025-06-21 17:36:07 +01:00
return {
label: "Executor Complete",
2025-06-21 20:44:36 +01:00
dotColor: "bg-green-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
case "executorfailed":
2025-06-21 17:36:07 +01:00
return {
label: "Executor Failed",
2025-06-21 20:44:36 +01:00
dotColor: "bg-red-500",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
default:
2025-06-21 17:36:07 +01:00
return {
label: "Unknown",
2025-06-21 20:44:36 +01:00
dotColor: "bg-gray-400",
2025-06-21 17:36:07 +01:00
};
2025-06-21 16:49:16 +01:00
}
};
2025-06-21 17:36:07 +01:00
export function TaskDetailsPanel({
task,
project,
2025-06-21 17:36:07 +01:00
projectId,
isOpen,
onClose,
2025-06-21 22:41:35 +01:00
onEditTask,
onDeleteTask,
2025-06-21 17:36:07 +01:00
}: TaskDetailsPanelProps) {
2025-06-21 16:49:16 +01:00
const [taskAttempts, setTaskAttempts] = useState<TaskAttempt[]>([]);
2025-06-21 17:36:07 +01:00
const [selectedAttempt, setSelectedAttempt] = useState<TaskAttempt | null>(
null
);
// Combined attempt data state
const [attemptData, setAttemptData] = useState<{
activities: TaskAttemptActivity[];
processes: ExecutionProcessSummary[];
runningProcessDetails: Record<string, ExecutionProcess>;
}>({
activities: [],
processes: [],
runningProcessDetails: {},
});
2025-06-21 16:49:16 +01:00
const [loading, setLoading] = useState(false);
2025-06-21 19:13:42 +01:00
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
2025-06-21 22:19:25 +01:00
const [selectedExecutor, setSelectedExecutor] = useState<string>("claude");
2025-06-21 23:40:13 +01:00
const [isStopping, setIsStopping] = useState(false);
2025-06-22 21:41:02 +01:00
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
new Set()
);
const [showEditorDialog, setShowEditorDialog] = useState(false);
const [followUpMessage, setFollowUpMessage] = useState("");
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
2025-06-24 01:16:39 +01:00
const [followUpError, setFollowUpError] = useState<string | null>(null);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [devServerDetails, setDevServerDetails] =
useState<ExecutionProcess | null>(null);
const [isHoveringDevServer, setIsHoveringDevServer] = useState(false);
// Auto-scroll state
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
2025-06-21 22:19:25 +01:00
const { config } = useConfig();
2025-06-21 20:04:15 +01:00
// Find running dev server in current project (across all task attempts)
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
(process) =>
process.process_type === "devserver" && process.status === "running"
);
}, [attemptData.processes]);
// Handle ESC key locally to prevent global navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown, true); // Use capture phase
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, onClose]);
2025-06-21 20:04:15 +01:00
// Available executors
const availableExecutors = [
{ id: "echo", name: "Echo" },
{ id: "claude", name: "Claude" },
{ id: "amp", name: "Amp" },
];
2025-06-21 16:49:16 +01:00
2025-06-21 23:40:13 +01:00
// Check if any execution process is currently running
const isAttemptRunning = useMemo(() => {
if (!selectedAttempt || attemptData.activities.length === 0 || isStopping) {
2025-06-21 23:40:13 +01:00
return false;
}
// Group activities by execution_process_id and get the latest one for each
const latestActivitiesByProcess = new Map<string, TaskAttemptActivity>();
2025-06-22 21:41:02 +01:00
attemptData.activities.forEach((activity) => {
2025-06-22 21:41:02 +01:00
const existing = latestActivitiesByProcess.get(
activity.execution_process_id
);
if (
!existing ||
new Date(activity.created_at) > new Date(existing.created_at)
) {
2025-06-21 23:40:13 +01:00
latestActivitiesByProcess.set(activity.execution_process_id, activity);
}
});
// Check if any execution process has a running status as its latest activity
return Array.from(latestActivitiesByProcess.values()).some(
(activity) =>
activity.status === "setuprunning" ||
activity.status === "executorrunning"
);
}, [selectedAttempt, attemptData.activities, isStopping]);
2025-06-21 16:49:16 +01:00
// Check if follow-up should be enabled
const canSendFollowUp = useMemo(() => {
if (
!selectedAttempt ||
attemptData.activities.length === 0 ||
isAttemptRunning ||
isSendingFollowUp
) {
return false;
}
// Need at least one completed coding agent execution
const codingAgentActivities = attemptData.activities.filter(
(activity) => activity.status === "executorcomplete"
);
return codingAgentActivities.length > 0;
}, [
selectedAttempt,
attemptData.activities,
isAttemptRunning,
isSendingFollowUp,
]);
2025-06-21 16:49:16 +01:00
// Polling for updates when attempt is running
useEffect(() => {
if (!isAttemptRunning || !task) return;
const interval = setInterval(() => {
if (selectedAttempt) {
fetchAttemptData(selectedAttempt.id, true);
2025-06-21 16:49:16 +01:00
}
}, 2000);
return () => clearInterval(interval);
}, [isAttemptRunning, task?.id, selectedAttempt?.id]);
// Fetch dev server details when hovering
const fetchDevServerDetails = 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);
}
};
// Poll dev server details while hovering
useEffect(() => {
if (!isHoveringDevServer || !runningDevServer) {
setDevServerDetails(null);
return;
}
// Fetch immediately
fetchDevServerDetails();
// Then poll every 2 seconds
const interval = setInterval(fetchDevServerDetails, 2000);
return () => clearInterval(interval);
}, [
isHoveringDevServer,
runningDevServer?.id,
task?.id,
selectedAttempt?.id,
]);
2025-06-21 22:19:25 +01:00
// Set default executor from config
useEffect(() => {
if (config) {
setSelectedExecutor(config.executor.type);
}
}, [config]);
2025-06-21 16:49:16 +01:00
useEffect(() => {
if (task && isOpen) {
fetchTaskAttempts();
}
}, [task, isOpen]);
// Auto-scroll to bottom when activities or execution processes change
useEffect(() => {
if (shouldAutoScroll && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [attemptData.activities, attemptData.processes, shouldAutoScroll]);
// Handle scroll events to detect manual scrolling
const handleScroll = useCallback(() => {
if (scrollContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
scrollContainerRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px tolerance
if (isAtBottom && !shouldAutoScroll) {
setShouldAutoScroll(true);
} else if (!isAtBottom && shouldAutoScroll) {
setShouldAutoScroll(false);
}
}
}, [shouldAutoScroll]);
2025-06-21 16:49:16 +01:00
const fetchTaskAttempts = async () => {
if (!task) return;
try {
setLoading(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`
);
if (response.ok) {
const result: ApiResponse<TaskAttempt[]> = await response.json();
if (result.success && result.data) {
setTaskAttempts(result.data);
2025-06-21 17:36:07 +01:00
2025-06-21 16:49:16 +01:00
// Auto-select latest attempt
if (result.data.length > 0) {
const latestAttempt = result.data.reduce((latest, current) =>
new Date(current.created_at) > new Date(latest.created_at)
? current
: latest
);
setSelectedAttempt(latestAttempt);
fetchAttemptData(latestAttempt.id);
2025-06-21 22:55:12 +01:00
} else {
// Clear state when no attempts exist
setSelectedAttempt(null);
setAttemptData({
activities: [],
processes: [],
runningProcessDetails: {},
});
2025-06-21 16:49:16 +01:00
}
}
}
} catch (err) {
console.error("Failed to fetch task attempts:", err);
} finally {
setLoading(false);
}
};
const fetchAttemptData = async (
2025-06-21 17:36:07 +01:00
attemptId: string,
_isBackgroundUpdate = false
) => {
2025-06-21 16:49:16 +01:00
if (!task) return;
try {
const [activitiesResponse, processesResponse] = await Promise.all([
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/activities`
),
makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${attemptId}/execution-processes`
),
]);
2025-06-21 16:49:16 +01:00
if (activitiesResponse.ok && processesResponse.ok) {
const activitiesResult: ApiResponse<TaskAttemptActivity[]> =
await activitiesResponse.json();
const processesResult: ApiResponse<ExecutionProcessSummary[]> =
await processesResponse.json();
2025-06-21 20:44:36 +01:00
if (
activitiesResult.success &&
processesResult.success &&
activitiesResult.data &&
processesResult.data
) {
// Find running activities that need detailed execution info
const runningActivities = activitiesResult.data.filter(
2025-06-21 20:44:36 +01:00
(activity) =>
activity.status === "setuprunning" ||
activity.status === "executorrunning"
);
// Fetch detailed execution info for running processes
const runningProcessDetails: Record<string, ExecutionProcess> = {};
2025-06-21 20:44:36 +01:00
for (const activity of runningActivities) {
try {
const detailResponse = await makeRequest(
`/api/projects/${projectId}/execution-processes/${activity.execution_process_id}`
);
if (detailResponse.ok) {
const detailResult: ApiResponse<ExecutionProcess> =
await detailResponse.json();
if (detailResult.success && detailResult.data) {
runningProcessDetails[activity.execution_process_id] =
detailResult.data;
}
}
} catch (err) {
console.error(
`Failed to fetch execution process ${activity.execution_process_id}:`,
err
);
}
2025-06-21 20:44:36 +01:00
}
// Update all attempt data at once
setAttemptData({
activities: activitiesResult.data,
processes: processesResult.data,
runningProcessDetails,
});
2025-06-21 20:44:36 +01:00
}
}
} catch (err) {
console.error("Failed to fetch attempt data:", err);
2025-06-21 20:44:36 +01:00
}
};
2025-06-21 16:49:16 +01:00
const handleAttemptChange = (attemptId: string) => {
2025-06-21 17:36:07 +01:00
const attempt = taskAttempts.find((a) => a.id === attemptId);
2025-06-21 16:49:16 +01:00
if (attempt) {
setSelectedAttempt(attempt);
fetchAttemptData(attempt.id);
2025-06-21 16:49:16 +01:00
}
};
const openInEditor = async (editorType?: EditorType) => {
2025-06-21 16:49:16 +01:00
if (!task || !selectedAttempt) return;
try {
const response = await makeRequest(
2025-06-21 19:13:42 +01:00
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/open-editor`,
2025-06-21 16:49:16 +01:00
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(editorType ? { editor_type: editorType } : null),
2025-06-21 16:49:16 +01:00
}
);
if (!response.ok) {
throw new Error("Failed to open editor");
}
2025-06-21 16:49:16 +01:00
} catch (err) {
2025-06-21 19:13:42 +01:00
console.error("Failed to open editor:", err);
// Show editor selection dialog if editor failed to open
if (!editorType) {
setShowEditorDialog(true);
}
2025-06-21 16:49:16 +01:00
}
};
const startDevServer = async () => {
if (!task || !selectedAttempt || !project?.dev_script) return;
setIsStartingDevServer(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
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");
}
// Refresh activities to show the new dev server process
fetchAttemptData(selectedAttempt.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/${task.id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error("Failed to stop dev server");
}
// Refresh activities to show the stopped dev server
fetchAttemptData(selectedAttempt.id);
} catch (err) {
console.error("Failed to stop dev server:", err);
} finally {
setIsStartingDevServer(false);
}
};
2025-06-21 20:04:15 +01:00
const createNewAttempt = async (executor?: string) => {
2025-06-21 19:13:42 +01:00
if (!task) return;
2025-06-21 16:49:16 +01:00
try {
2025-06-21 19:13:42 +01:00
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
2025-06-21 16:49:16 +01:00
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
2025-06-21 20:04:15 +01:00
body: JSON.stringify({
executor: executor || selectedExecutor,
}),
2025-06-21 16:49:16 +01:00
}
);
2025-06-21 19:13:42 +01:00
if (response.ok) {
// Refresh the attempts list
fetchTaskAttempts();
}
2025-06-21 16:49:16 +01:00
} catch (err) {
2025-06-21 19:13:42 +01:00
console.error("Failed to create new attempt:", err);
2025-06-21 16:49:16 +01:00
}
};
2025-06-21 23:40:13 +01:00
const stopAllExecutions = async () => {
if (!task || !selectedAttempt) return;
try {
setIsStopping(true);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
if (response.ok) {
// Refresh activities to show updated status
await fetchAttemptData(selectedAttempt.id);
2025-06-21 23:40:13 +01:00
// Wait a bit for the backend to finish updating
setTimeout(() => {
fetchAttemptData(selectedAttempt.id);
2025-06-21 23:40:13 +01:00
}, 1000);
}
} catch (err) {
console.error("Failed to stop executions:", err);
} finally {
setIsStopping(false);
}
};
const toggleOutputExpansion = (processId: string) => {
2025-06-22 21:41:02 +01:00
setExpandedOutputs((prev) => {
const newSet = new Set(prev);
if (newSet.has(processId)) {
newSet.delete(processId);
} else {
newSet.add(processId);
}
return newSet;
});
};
const handleSendFollowUp = async () => {
if (!task || !selectedAttempt || !followUpMessage.trim()) return;
try {
setIsSendingFollowUp(true);
2025-06-24 01:16:39 +01:00
setFollowUpError(null);
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/follow-up`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: followUpMessage.trim(),
}),
}
);
if (response.ok) {
// Clear the message
setFollowUpMessage("");
// Refresh activities to show the new follow-up execution
fetchAttemptData(selectedAttempt.id);
} else {
2025-06-24 01:16:39 +01:00
const errorText = await response.text();
setFollowUpError(
`Failed to start follow-up execution: ${
errorText || response.statusText
}`
);
}
} catch (err) {
setFollowUpError(
`Failed to send follow-up: ${
err instanceof Error ? err.message : "Unknown error"
}`
);
} finally {
setIsSendingFollowUp(false);
}
};
2025-06-21 16:49:16 +01:00
if (!task) return null;
return (
<>
{isOpen && (
<>
2025-06-21 18:00:28 +01:00
{/* Backdrop - only on smaller screens (overlay mode) */}
2025-06-21 19:13:42 +01:00
<div className={getBackdropClasses()} onClick={onClose} />
2025-06-21 17:36:07 +01:00
2025-06-21 16:49:16 +01:00
{/* Panel */}
2025-06-21 19:13:42 +01:00
<div className={getTaskPanelClasses()}>
2025-06-21 16:49:16 +01:00
<div className="flex flex-col h-full">
2025-06-21 17:36:07 +01:00
{/* Header */}
<div className="p-6 border-b space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold mb-2 line-clamp-2">
{task.title}
</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
2025-06-21 20:44:36 +01:00
<Chip dotColor={getTaskStatusDotColor(task.status)}>
2025-06-21 17:36:07 +01:00
{statusLabels[task.status]}
2025-06-21 20:44:36 +01:00
</Chip>
2025-06-21 17:36:07 +01:00
</div>
</div>
<div className="flex items-center gap-1">
2025-06-21 22:41:35 +01:00
{onEditTask && (
<Button
variant="ghost"
size="icon"
onClick={() => onEditTask(task)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{onDeleteTask && (
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteTask(task.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
2025-06-21 17:36:07 +01:00
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
2025-06-21 16:49:16 +01:00
</Button>
</div>
</div>
2025-06-21 19:13:42 +01:00
{/* Description */}
<div>
2025-06-21 20:44:36 +01:00
<div className="p-3 bg-muted/30 rounded-md">
2025-06-21 19:13:42 +01:00
{task.description ? (
<div>
<p
className={`text-sm whitespace-pre-wrap ${
!isDescriptionExpanded &&
task.description.length > 200
? "line-clamp-6"
2025-06-21 19:13:42 +01:00
: ""
}`}
>
{task.description}
</p>
{task.description.length > 200 && (
<Button
variant="ghost"
size="sm"
onClick={() =>
setIsDescriptionExpanded(!isDescriptionExpanded)
}
className="mt-2 p-0 h-auto text-xs text-muted-foreground hover:text-foreground"
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
)}
</Button>
)}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No description provided
</p>
)}
</div>
</div>
2025-06-21 17:36:07 +01:00
{/* Attempt Selection */}
2025-06-21 19:13:42 +01:00
<div className="flex items-center gap-2 p-3 bg-muted/30 rounded-md">
<div className="flex items-center gap-2 flex-1">
{selectedAttempt && (
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
<span className="text-sm text-muted-foreground">
Current attempt:{" "}
</span>
{new Date(
selectedAttempt.created_at
).toLocaleDateString()}{" "}
{new Date(
selectedAttempt.created_at
).toLocaleTimeString()}
2025-06-21 22:48:51 +01:00
</span>
</div>
2025-06-21 19:13:42 +01:00
)}
<div className="flex gap-1">
2025-06-21 17:36:07 +01:00
{taskAttempts.length > 1 && (
2025-06-21 19:13:42 +01:00
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
2025-06-21 20:04:15 +01:00
<History className="h-4 w-4" />
2025-06-21 19:13:42 +01:00
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => handleAttemptChange(attempt.id)}
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>
2025-06-21 17:36:07 +01:00
)}
2025-06-21 20:04:15 +01:00
<div className="flex">
<Button
variant="outline"
size="sm"
onClick={() => createNewAttempt()}
className="rounded-r-none border-r-0"
>
2025-06-21 22:55:12 +01:00
{selectedAttempt ? "Retry " : "Attempt "}
with{" "}
2025-06-21 20:04:15 +01:00
{
availableExecutors.find(
(e) => e.id === selectedExecutor
)?.name
}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
2025-06-21 19:13:42 +01:00
<Button
variant="outline"
2025-06-21 20:04:15 +01:00
size="sm"
className="rounded-l-none px-2"
2025-06-21 19:13:42 +01:00
>
2025-06-21 20:04:15 +01:00
<Settings2 className="h-4 w-4" />
2025-06-21 19:13:42 +01:00
</Button>
2025-06-21 20:04:15 +01:00
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableExecutors.map((executor) => (
<DropdownMenuItem
key={executor.id}
onClick={() => setSelectedExecutor(executor.id)}
className={
selectedExecutor === executor.id
? "bg-accent"
: ""
}
>
{executor.name}
{selectedExecutor === executor.id &&
" (Default)"}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
2025-06-21 17:36:07 +01:00
</div>
2025-06-21 19:13:42 +01:00
</div>
2025-06-21 17:36:07 +01:00
{selectedAttempt && (
<div className="flex gap-1">
2025-06-21 23:40:13 +01:00
{(isAttemptRunning || isStopping) && (
<Button
variant="outline"
size="sm"
onClick={stopAllExecutions}
disabled={isStopping}
className="text-red-600 hover:text-red-700 hover:bg-red-50 disabled:opacity-50"
>
<StopCircle className="h-4 w-4 mr-1" />
{isStopping ? "Stopping..." : "Stop"}
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={
!project?.dev_script ? "cursor-not-allowed" : ""
}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<Button
variant={
runningDevServer ? "destructive" : "outline"
}
size="sm"
onClick={
runningDevServer
? stopDevServer
: startDevServer
}
disabled={
isStartingDevServer || !project?.dev_script
}
>
{runningDevServer ? (
<StopCircle className="h-4 w-4 mr-1" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
{isStartingDevServer
? runningDevServer
? "Stopping..."
: "Starting..."
: runningDevServer
? "Stop Dev Server"
: "Start Dev Server"}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className={runningDevServer ? "max-w-2xl p-4" : ""}
side="top"
align="center"
avoidCollisions={true}
>
{!project?.dev_script ? (
<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">
{(() => {
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...";
})()}
</pre>
</div>
) : null}
</TooltipContent>
</Tooltip>
</TooltipProvider>
2025-06-21 17:36:07 +01:00
<Button
variant="outline"
size="sm"
onClick={() => openInEditor()}
2025-06-21 17:36:07 +01:00
>
<Code className="h-4 w-4 mr-1" />
Editor
</Button>
<Button variant="outline" size="sm" asChild>
<Link
to={`/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/compare`}
>
<FileText className="h-4 w-4 mr-1" />
Changes
</Link>
2025-06-21 17:36:07 +01:00
</Button>
</div>
)}
</div>
</div>
{/* Content */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-6 space-y-6"
>
2025-06-21 17:36:07 +01:00
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
) : (
<>
{/* Activity History */}
{selectedAttempt && (
<div>
<Label className="text-sm font-medium mb-3 block">
Activity History
</Label>
{attemptData.activities.length === 0 ? (
2025-06-21 17:36:07 +01:00
<div className="text-center py-4 text-muted-foreground">
No activities found
</div>
) : (
2025-06-21 20:44:36 +01:00
<div className="space-y-2">
{/* Fake worktree created activity */}
{selectedAttempt && (
<div key="worktree-created">
<div className="flex items-center gap-3 my-4 rounded-md">
<Chip dotColor="bg-green-500">
2025-06-22 22:00:45 +01:00
New Worktree
</Chip>
<span className="text-sm text-muted-foreground flex-1">
2025-06-22 22:00:45 +01:00
{selectedAttempt.worktree_path}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(
selectedAttempt.created_at
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
</div>
</div>
)}
{attemptData.activities.slice().map((activity) => (
2025-06-21 20:44:36 +01:00
<div key={activity.id}>
{/* Compact activity message */}
<div className="flex items-center gap-3 my-4 rounded-md">
<Chip
dotColor={
getAttemptStatusDisplay(activity.status)
.dotColor
}
>
{
getAttemptStatusDisplay(activity.status)
.label
}
</Chip>
2025-06-21 17:36:07 +01:00
{activity.note && (
2025-06-21 20:44:36 +01:00
<span className="text-sm text-muted-foreground flex-1">
2025-06-21 17:36:07 +01:00
{activity.note}
2025-06-21 20:44:36 +01:00
</span>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{new Date(
activity.created_at
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
</div>
{/* Show stdio output for running processes */}
{(activity.status === "setuprunning" ||
activity.status === "executorrunning") &&
attemptData.runningProcessDetails[
2025-06-21 20:44:36 +01:00
activity.execution_process_id
] && (
2025-06-21 22:12:32 +01:00
<div className="mt-2">
2025-06-22 21:41:02 +01:00
<div
className={`transition-all duration-200 ${
expandedOutputs.has(
activity.execution_process_id
)
? ""
: "max-h-64 overflow-hidden flex flex-col justify-end"
}`}
>
<ExecutionOutputViewer
executionProcess={
attemptData.runningProcessDetails[
activity.execution_process_id
]
}
executor={
2025-06-22 21:41:02 +01:00
selectedAttempt?.executor ||
undefined
}
/>
</div>
<Button
variant="ghost"
size="sm"
2025-06-22 21:41:02 +01:00
onClick={() =>
toggleOutputExpansion(
activity.execution_process_id
)
}
className="mt-2 p-0 h-auto text-xs text-muted-foreground hover:text-foreground"
>
2025-06-22 21:41:02 +01:00
{expandedOutputs.has(
activity.execution_process_id
) ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
)}
</Button>
2025-06-21 22:12:32 +01:00
</div>
2025-06-21 17:36:07 +01:00
)}
2025-06-21 20:44:36 +01:00
</div>
2025-06-21 17:36:07 +01:00
))}
</div>
)}
2025-06-21 16:49:16 +01:00
</div>
)}
2025-06-21 17:36:07 +01:00
</>
2025-06-21 16:49:16 +01:00
)}
2025-06-21 17:36:07 +01:00
</div>
2025-06-21 16:49:16 +01:00
{/* Footer - Follow-up section */}
{selectedAttempt && (
<div className="border-t p-4">
<div className="space-y-2">
<Label className="text-sm font-medium">
Follow-up question
</Label>
2025-06-24 01:16:39 +01:00
{followUpError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{followUpError}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Textarea
placeholder="Ask a follow-up question about this task..."
value={followUpMessage}
2025-06-24 01:16:39 +01:00
onChange={(e) => {
setFollowUpMessage(e.target.value);
if (followUpError) setFollowUpError(null);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
if (
canSendFollowUp &&
followUpMessage.trim() &&
!isSendingFollowUp
) {
handleSendFollowUp();
}
}
}}
className="flex-1 min-h-[60px] resize-none"
disabled={!canSendFollowUp}
/>
<Button
onClick={handleSendFollowUp}
disabled={
!canSendFollowUp ||
!followUpMessage.trim() ||
isSendingFollowUp
}
className="self-end"
>
{isSendingFollowUp ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{!canSendFollowUp
? isAttemptRunning
? "Wait for current execution to complete before asking follow-up questions"
: "Complete at least one coding agent execution to enable follow-up questions"
: "Continue the conversation with the most recent executor session"}
</p>
2025-06-21 17:36:07 +01:00
</div>
</div>
)}
2025-06-21 16:49:16 +01:00
</div>
</div>
{/* Editor Selection Dialog */}
<EditorSelectionDialog
isOpen={showEditorDialog}
onClose={() => setShowEditorDialog(false)}
onSelectEditor={(editorType) => openInEditor(editorType)}
/>
2025-06-21 16:49:16 +01:00
</>
)}
</>
);
}