import { useState, useEffect, useCallback, useRef } from 'react'; import { Globe2, Settings2, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ImageUploadSection, type ImageUploadSectionHandle, } from '@/components/ui/ImageUploadSection'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { templatesApi, imagesApi, projectsApi, attemptsApi } from '@/lib/api'; import { useTaskMutations } from '@/hooks/useTaskMutations'; import { useUserSystem } from '@/components/config-provider'; import { ExecutorProfileSelector } from '@/components/settings'; import BranchSelector from '@/components/tasks/BranchSelector'; import type { TaskStatus, TaskTemplate, ImageResponse, GitBranch, ExecutorProfileId, } from 'shared/types'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; interface Task { id: string; project_id: string; title: string; description: string | null; status: TaskStatus; created_at: string; updated_at: string; } export interface TaskFormDialogProps { task?: Task | null; // Optional for create mode projectId?: string; // For file search functionality initialTemplate?: TaskTemplate | null; // For pre-filling from template initialTask?: Task | null; // For duplicating an existing task initialBaseBranch?: string; // For pre-selecting base branch in spinoff parentTaskAttemptId?: string; // For linking to parent task attempt } export const TaskFormDialog = NiceModal.create( ({ task, projectId, initialTemplate, initialTask, initialBaseBranch, parentTaskAttemptId, }) => { const modal = useModal(); const { createTask, createAndStart, updateTask } = useTaskMutations(projectId); const { system, profiles } = useUserSystem(); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [status, setStatus] = useState('todo'); const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmittingAndStart, setIsSubmittingAndStart] = useState(false); const [templates, setTemplates] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(''); const [showDiscardWarning, setShowDiscardWarning] = useState(false); const [images, setImages] = useState([]); const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState< string[] >([]); const [branches, setBranches] = useState([]); const [selectedBranch, setSelectedBranch] = useState(''); const [selectedExecutorProfile, setSelectedExecutorProfile] = useState(null); const [quickstartExpanded, setQuickstartExpanded] = useState(false); const imageUploadRef = useRef(null); const isEditMode = Boolean(task); // Check if there's any content that would be lost const hasUnsavedChanges = useCallback(() => { if (!isEditMode) { // Create mode - warn when there's content return title.trim() !== '' || description.trim() !== ''; } else if (task) { // Edit mode - warn when current values differ from original task const titleChanged = title.trim() !== task.title.trim(); const descriptionChanged = (description || '').trim() !== (task.description || '').trim(); const statusChanged = status !== task.status; return titleChanged || descriptionChanged || statusChanged; } return false; }, [title, description, status, isEditMode, task]); // Warn on browser/tab close if there are unsaved changes useEffect(() => { if (!modal.visible) return; // dialog closed → nothing to do // always re-evaluate latest fields via hasUnsavedChanges() const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges()) { e.preventDefault(); // Chrome / Edge still require returnValue to be set e.returnValue = ''; return ''; } // nothing returned → no prompt }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [modal.visible, hasUnsavedChanges]); // hasUnsavedChanges is memoised with title/descr deps useEffect(() => { if (task) { // Edit mode - populate with existing task data setTitle(task.title); setDescription(task.description || ''); setStatus(task.status); // Load existing images for the task if (modal.visible) { imagesApi .getTaskImages(task.id) .then((taskImages) => setImages(taskImages)) .catch((err) => { console.error('Failed to load task images:', err); setImages([]); }); } } else if (initialTask) { // Duplicate mode - pre-fill from existing task but reset status to 'todo' and no images setTitle(initialTask.title); setDescription(initialTask.description || ''); setStatus('todo'); // Always start duplicated tasks as 'todo' setSelectedTemplate(''); setImages([]); setNewlyUploadedImageIds([]); } else if (initialTemplate) { // Create mode with template - pre-fill from template setTitle(initialTemplate.title); setDescription(initialTemplate.description || ''); setStatus('todo'); setSelectedTemplate(''); } else { // Create mode - reset to defaults setTitle(''); setDescription(''); setStatus('todo'); setSelectedTemplate(''); setImages([]); setNewlyUploadedImageIds([]); setSelectedBranch(''); setSelectedExecutorProfile(system.config?.executor_profile || null); setQuickstartExpanded(false); } }, [ task, initialTask, initialTemplate, modal.visible, system.config?.executor_profile, ]); // Fetch templates and branches when dialog opens in create mode useEffect(() => { if (modal.visible && !isEditMode && projectId) { // Fetch templates and branches Promise.all([ templatesApi.listByProject(projectId), templatesApi.listGlobal(), projectsApi.getBranches(projectId), ]) .then(([projectTemplates, globalTemplates, projectBranches]) => { // Combine templates with project templates first setTemplates([...projectTemplates, ...globalTemplates]); // Set branches and default to initialBaseBranch if provided, otherwise current branch setBranches(projectBranches); if ( initialBaseBranch && projectBranches.some((b) => b.name === initialBaseBranch) ) { // Use initialBaseBranch if it exists in the project branches (for spinoff) setSelectedBranch(initialBaseBranch); } else { // Default behavior: use current branch or first available const currentBranch = projectBranches.find((b) => b.is_current); const defaultBranch = currentBranch || projectBranches[0]; if (defaultBranch) { setSelectedBranch(defaultBranch.name); } } }) .catch(console.error); } }, [modal.visible, isEditMode, projectId, initialBaseBranch]); // Fetch parent base branch when parentTaskAttemptId is provided useEffect(() => { if ( modal.visible && !isEditMode && parentTaskAttemptId && !initialBaseBranch && branches.length > 0 ) { attemptsApi .get(parentTaskAttemptId) .then((attempt) => { const parentBranch = attempt.branch || attempt.target_branch; if (parentBranch && branches.some((b) => b.name === parentBranch)) { setSelectedBranch(parentBranch); } }) .catch(() => { // Silently fail, will use current branch fallback }); } }, [ modal.visible, isEditMode, parentTaskAttemptId, initialBaseBranch, branches, ]); // Set default executor from config (following TaskDetailsToolbar pattern) useEffect(() => { if (system.config?.executor_profile) { setSelectedExecutorProfile(system.config.executor_profile); } }, [system.config?.executor_profile]); // Set default executor from config (following TaskDetailsToolbar pattern) useEffect(() => { if (system.config?.executor_profile) { setSelectedExecutorProfile(system.config.executor_profile); } }, [system.config?.executor_profile]); // Handle template selection const handleTemplateChange = (templateId: string) => { setSelectedTemplate(templateId); if (templateId === 'none') { // Clear the form when "No template" is selected setTitle(''); setDescription(''); } else if (templateId) { const template = templates.find((t) => t.id === templateId); if (template) { setTitle(template.title); setDescription(template.description || ''); } } }; // Handle image upload success by inserting markdown into description const handleImageUploaded = useCallback((image: ImageResponse) => { const markdownText = `![${image.original_name}](${image.file_path})`; setDescription((prev) => { if (prev.trim() === '') { return markdownText; } else { return prev + ' ' + markdownText; } }); setImages((prev) => [...prev, image]); // Track as newly uploaded for backend association setNewlyUploadedImageIds((prev) => [...prev, image.id]); }, []); const handleImagesChange = useCallback((updatedImages: ImageResponse[]) => { setImages(updatedImages); // Also update newlyUploadedImageIds to remove any deleted image IDs setNewlyUploadedImageIds((prev) => prev.filter((id) => updatedImages.some((img) => img.id === id)) ); }, []); const handlePasteImages = useCallback((files: File[]) => { if (files.length === 0) return; void imageUploadRef.current?.addFiles(files); }, []); const handleSubmit = useCallback(async () => { if (!title.trim() || !projectId) return; setIsSubmitting(true); try { let imageIds: string[] | undefined; if (isEditMode) { // In edit mode, send all current image IDs (existing + newly uploaded) imageIds = images.length > 0 ? images.map((img) => img.id) : undefined; } else { // In create mode, only send newly uploaded image IDs imageIds = newlyUploadedImageIds.length > 0 ? newlyUploadedImageIds : undefined; } if (isEditMode && task) { updateTask.mutate( { taskId: task.id, data: { title, description: description, status, parent_task_attempt: parentTaskAttemptId || null, image_ids: imageIds || null, }, }, { onSuccess: () => { modal.hide(); }, } ); } else { createTask.mutate( { project_id: projectId, title, description: description, parent_task_attempt: parentTaskAttemptId || null, image_ids: imageIds || null, }, { onSuccess: () => { modal.hide(); }, } ); } } finally { setIsSubmitting(false); } }, [ title, description, status, isEditMode, projectId, task, modal, newlyUploadedImageIds, images, createTask, updateTask, ]); const handleCreateAndStart = useCallback(async () => { if (!title.trim() || !projectId) return; setIsSubmittingAndStart(true); try { if (!isEditMode) { const imageIds = newlyUploadedImageIds.length > 0 ? newlyUploadedImageIds : undefined; // Use selected executor profile or fallback to config default const finalExecutorProfile = selectedExecutorProfile || system.config?.executor_profile; if (!finalExecutorProfile || !selectedBranch) { console.warn( `Missing ${!finalExecutorProfile ? 'executor profile' : 'branch'} for Create & Start` ); return; } createAndStart.mutate( { task: { project_id: projectId, title, description: description, parent_task_attempt: parentTaskAttemptId || null, image_ids: imageIds || null, }, executor_profile_id: finalExecutorProfile, base_branch: selectedBranch, }, { onSuccess: () => { modal.hide(); }, } ); } } finally { setIsSubmittingAndStart(false); } }, [ title, description, isEditMode, projectId, modal, newlyUploadedImageIds, createAndStart, selectedExecutorProfile, selectedBranch, system.config?.executor_profile, ]); const handleCancel = useCallback(() => { // Check for unsaved changes before closing if (hasUnsavedChanges()) { setShowDiscardWarning(true); } else { modal.hide(); } }, [modal, hasUnsavedChanges]); const handleDiscardChanges = useCallback(() => { // Close both dialogs setShowDiscardWarning(false); modal.hide(); }, [modal]); // Handle keyboard shortcuts // Handle dialog close attempt const handleDialogOpenChange = (open: boolean) => { if (!open && hasUnsavedChanges()) { // Trying to close with unsaved changes setShowDiscardWarning(true); } else if (!open) { modal.hide(); } }; return ( <> {isEditMode ? 'Edit Task' : 'Create New Task'}
setTitle(e.target.value)} placeholder="What needs to be done?" className="mt-1.5" disabled={isSubmitting || isSubmittingAndStart} autoFocus onCommandEnter={ isEditMode ? handleSubmit : handleCreateAndStart } onCommandShiftEnter={handleSubmit} />
{!isEditMode && templates.length > 0 && (
Use a template

Templates help you quickly create tasks with predefined content.

)} {isEditMode && (
)} {!isEditMode && (() => { const quickstartSection = (
setQuickstartExpanded( (e.target as HTMLDetailsElement).open ) } > Quickstart

Configuration for "Create & Start" workflow

{/* Executor Profile Selector */} {profiles && selectedExecutorProfile && ( )} {/* Branch Selector */} {branches.length > 0 && (
)}
); return quickstartSection; })()}
{isEditMode ? ( ) : ( <> )}
{/* Discard Warning Dialog */} Discard unsaved changes?

You have unsaved changes. Are you sure you want to discard them?

); } );