diff --git a/frontend/package.json b/frontend/package.json index 1f948e8b..17be5646 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,9 @@ "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-devtools": "^0.8.0", + "@tanstack/react-form": "^1.23.8", + "@tanstack/react-form-devtools": "^0.1.8", "@tanstack/react-query": "^5.85.5", "@types/react-window": "^1.8.8", "@uiw/react-codemirror": "^4.25.1", @@ -63,6 +66,7 @@ "posthog-js": "^1.276.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.8", "react-hotkeys-hook": "^5.1.0", "react-i18next": "^15.7.3", "react-resizable-panels": "^3.0.6", @@ -75,6 +79,7 @@ "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", "vibe-kanban-web-companion": "^0.0.4", + "zod": "^4.1.12", "zustand": "^4.5.4" }, "devDependencies": { @@ -102,4 +107,4 @@ "typescript": "^5.9.2", "vite": "^5.0.8" } -} +} \ No newline at end of file diff --git a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx index 088d71cd..3751b5a7 100644 --- a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx +++ b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx @@ -1,19 +1,21 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Settings2, ChevronRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - ImageUploadSection, - type ImageUploadSectionHandle, -} from '@/components/ui/ImageUploadSection'; +import { useEffect, useCallback, useRef, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useDropzone } from 'react-dropzone'; +import { useForm, useStore } from '@tanstack/react-form'; +import { Image as ImageIcon } from 'lucide-react'; import { Dialog, DialogContent, + DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, @@ -21,19 +23,28 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { imagesApi, projectsApi, attemptsApi } from '@/lib/api'; -import { useTaskMutations } from '@/hooks/useTaskMutations'; -import { useUserSystem } from '@/components/config-provider'; -import { ExecutorProfileSelector } from '@/components/settings'; +import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; +import { + ImageUploadSection, + type ImageUploadSectionHandle, +} from '@/components/ui/ImageUploadSection'; import BranchSelector from '@/components/tasks/BranchSelector'; +import { ExecutorProfileSelector } from '@/components/settings'; +import { useUserSystem } from '@/components/config-provider'; +import { + useProjectBranches, + useTaskImages, + useImageUpload, + useTaskMutations, +} from '@/hooks'; +import { useKeySubmitTask, useKeyExit, Scope } from '@/keyboard'; +import { useHotkeysContext } from 'react-hotkeys-hook'; +import { cn } from '@/lib/utils'; import type { TaskStatus, - ImageResponse, - GitBranch, ExecutorProfileId, + ImageResponse, } from 'shared/types'; -import NiceModal, { useModal } from '@ebay/nice-modal-react'; -import { useKeySubmitTask, useKeySubmitTaskAlt, Scope } from '@/keyboard'; interface Task { id: string; @@ -45,653 +56,569 @@ interface Task { updated_at: string; } -export interface TaskFormDialogProps { - task?: Task | null; // Optional for create mode - projectId?: string; // For file search and tag functionality - 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 type TaskFormDialogProps = + | { mode: 'create'; projectId: string } + | { mode: 'edit'; projectId: string; task: Task } + | { mode: 'duplicate'; projectId: string; initialTask: Task } + | { + mode: 'subtask'; + projectId: string; + parentTaskAttemptId: string; + initialBaseBranch: string; + }; -export const TaskFormDialog = NiceModal.create( - ({ - task, - projectId, - 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 [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 [isTextareaFocused, setIsTextareaFocused] = useState(false); +type TaskFormValues = { + title: string; + description: string; + status: TaskStatus; + executorProfileId: ExecutorProfileId | null; + branch: string; + autoStart: boolean; +}; - const isEditMode = Boolean(task); +export const TaskFormDialog = NiceModal.create((props) => { + const { mode, projectId } = props; + const editMode = mode === 'edit'; + const modal = useModal(); + const { t } = useTranslation(['tasks', 'common']); + const { createTask, createAndStart, updateTask } = + useTaskMutations(projectId); + const { system, profiles, loading: userSystemLoading } = useUserSystem(); + const { upload, deleteImage } = useImageUpload(); + const { enableScope, disableScope } = useHotkeysContext(); - // 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]); + // Local UI state + const [images, setImages] = useState([]); + const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState( + [] + ); + const [showDiscardWarning, setShowDiscardWarning] = useState(false); + const imageUploadRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState(null); - // Warn on browser/tab close if there are unsaved changes - useEffect(() => { - if (!modal.visible) return; // dialog closed → nothing to do + const { data: branches, isLoading: branchesLoading } = + useProjectBranches(projectId); + const { data: taskImages } = useTaskImages( + editMode ? props.task.id : undefined + ); - // 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 - }; + // Get default form values based on mode + const defaultValues = useMemo((): TaskFormValues => { + const baseProfile = system.config?.executor_profile || null; - 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' - setImages([]); - setNewlyUploadedImageIds([]); - } else { - // Create mode - reset to defaults - setTitle(''); - setDescription(''); - setStatus('todo'); - setImages([]); - setNewlyUploadedImageIds([]); - setSelectedBranch(''); - setSelectedExecutorProfile(system.config?.executor_profile || null); - setQuickstartExpanded(false); - } - }, [task, initialTask, modal.visible, system.config?.executor_profile]); - - // Fetch branches when dialog opens in create mode - useEffect(() => { - if (modal.visible && !isEditMode && projectId) { - projectsApi - .getBranches(projectId) - .then((projectBranches) => { - // 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(() => { + const defaultBranch = (() => { + if (!branches?.length) return ''; if ( - modal.visible && - !isEditMode && - parentTaskAttemptId && - !initialBaseBranch && - branches.length > 0 + mode === 'subtask' && + branches.some((b) => b.name === props.initialBaseBranch) ) { - 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 - }); + return props.initialBaseBranch; } - }, [ - modal.visible, - isEditMode, - parentTaskAttemptId, - initialBaseBranch, - branches, - ]); + // current branch or first branch + const currentBranch = branches.find((b) => b.is_current); + return currentBranch?.name || branches[0]?.name || ''; + })(); - // Set default executor from config (following TaskDetailsToolbar pattern) - useEffect(() => { - if (system.config?.executor_profile) { - setSelectedExecutorProfile(system.config.executor_profile); - } - }, [system.config?.executor_profile]); + switch (mode) { + case 'edit': + return { + title: props.task.title, + description: props.task.description || '', + status: props.task.status, + executorProfileId: baseProfile, + branch: defaultBranch || '', + autoStart: false, + }; - // Set default executor from config (following TaskDetailsToolbar pattern) - useEffect(() => { - if (system.config?.executor_profile) { - setSelectedExecutorProfile(system.config.executor_profile); - } - }, [system.config?.executor_profile]); + case 'duplicate': + return { + title: props.initialTask.title, + description: props.initialTask.description || '', + status: 'todo', + executorProfileId: baseProfile, + branch: defaultBranch || '', + autoStart: true, + }; - // 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; - } - }); + case 'subtask': + case 'create': + default: + return { + title: '', + description: '', + status: 'todo', + executorProfileId: baseProfile, + branch: defaultBranch || '', + autoStart: true, + }; + } + }, [mode, props, system.config?.executor_profile, branches]); - 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)) + // Form submission handler + const handleSubmit = async ({ value }: { value: TaskFormValues }) => { + if (editMode) { + await updateTask.mutateAsync( + { + taskId: props.task.id, + data: { + title: value.title, + description: value.description, + status: value.status, + parent_task_attempt: null, + image_ids: images.length > 0 ? images.map((img) => img.id) : null, + }, + }, + { onSuccess: () => modal.remove() } ); - }, []); - - const handlePasteImages = useCallback((files: File[]) => { - if (files.length === 0) return; - void imageUploadRef.current?.addFiles(files); - }, []); - - const handleSubmit = useCallback(async () => { - if (!title.trim() || !projectId || isSubmitting || isSubmittingAndStart) { - 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) { - await updateTask.mutateAsync( - { - taskId: task.id, - data: { - title, - description: description, - status, - parent_task_attempt: parentTaskAttemptId || null, - image_ids: imageIds || null, - }, - }, - { - onSuccess: () => { - modal.hide(); - }, - } - ); - } else { - await createTask.mutateAsync( - { - project_id: projectId, - title, - description: description, - status: null, - parent_task_attempt: parentTaskAttemptId || null, - image_ids: imageIds || null, - shared_task_id: null, - }, - { - onSuccess: () => { - modal.hide(); - }, - } - ); - } - } catch (error) { - // Error already handled by mutation onError - } finally { - setIsSubmitting(false); - } - }, [ - title, - description, - status, - isEditMode, - projectId, - task, - modal, - newlyUploadedImageIds, - images, - createTask, - updateTask, - isSubmitting, - isSubmittingAndStart, - parentTaskAttemptId, - ]); - - const handleCreateAndStart = useCallback(async () => { - if ( - !title.trim() || - !projectId || - isEditMode || - isSubmitting || - isSubmittingAndStart - ) { - return; - } - - setIsSubmittingAndStart(true); - try { - 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; - } - + } else { + const imageIds = + newlyUploadedImageIds.length > 0 ? newlyUploadedImageIds : null; + const task = { + project_id: projectId, + title: value.title, + description: value.description, + status: null, + parent_task_attempt: + mode === 'subtask' ? props.parentTaskAttemptId : null, + image_ids: imageIds, + shared_task_id: null, + }; + if (value.autoStart) { await createAndStart.mutateAsync( { - task: { - project_id: projectId, - title, - description: description, - status: null, - parent_task_attempt: parentTaskAttemptId || null, - image_ids: imageIds || null, - shared_task_id: null, - }, - executor_profile_id: finalExecutorProfile, - base_branch: selectedBranch, + task, + executor_profile_id: value.executorProfileId!, + base_branch: value.branch, }, - { - onSuccess: () => { - modal.hide(); - }, - } + { onSuccess: () => modal.remove() } ); - } catch (error) { - // Error already handled by mutation onError - } finally { - setIsSubmittingAndStart(false); - } - }, [ - title, - description, - isEditMode, - projectId, - modal, - newlyUploadedImageIds, - createAndStart, - selectedExecutorProfile, - selectedBranch, - system.config?.executor_profile, - isSubmitting, - isSubmittingAndStart, - parentTaskAttemptId, - ]); - - const handleCancel = useCallback(() => { - // Check for unsaved changes before closing - if (hasUnsavedChanges()) { - setShowDiscardWarning(true); } else { - modal.hide(); + await createTask.mutateAsync(task, { onSuccess: () => modal.remove() }); } - }, [modal, hasUnsavedChanges]); + } + }; - const handleDiscardChanges = useCallback(() => { - // Close both dialogs - setShowDiscardWarning(false); - modal.hide(); - }, [modal]); + const validator = (value: TaskFormValues): string | undefined => { + if (!value.title.trim().length) return 'need title'; + if (value.autoStart && (!value.executorProfileId || !value.branch)) { + return 'need executor profile or branch;'; + } + }; - // Keyboard shortcut handlers - const handlePrimarySubmit = useCallback( - (e?: KeyboardEvent) => { - e?.preventDefault(); - if (isEditMode) { - handleSubmit(); - } else { - handleCreateAndStart(); - } - }, - [isEditMode, handleSubmit, handleCreateAndStart] - ); + // Initialize TanStack Form + const form = useForm({ + defaultValues: defaultValues, + onSubmit: handleSubmit, + validators: { + // we use an onMount validator so that the primary action button can + // enable/disable itself based on `canSubmit` + onMount: ({ value }) => validator(value), + onChange: ({ value }) => validator(value), + }, + }); - const handleAlternativeSubmit = useCallback( - (e?: KeyboardEvent) => { - e?.preventDefault(); - handleSubmit(); - }, - [handleSubmit] - ); + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const isDirty = useStore(form.store, (state) => state.isDirty); + const canSubmit = useStore(form.store, (state) => state.canSubmit); - // Register keyboard shortcuts - const canSubmit = - title.trim() !== '' && !isSubmitting && !isSubmittingAndStart; + // Load images for edit mode + useEffect(() => { + if (!taskImages) return; + setImages(taskImages); + }, [taskImages]); - useKeySubmitTask(handlePrimarySubmit, { - scope: Scope.DIALOG, - enableOnFormTags: ['textarea', 'TEXTAREA'], - when: canSubmit && isTextareaFocused, - preventDefault: true, - }); + const onDrop = useCallback((files: File[]) => { + if (imageUploadRef.current) { + imageUploadRef.current.addFiles(files); + } else { + setPendingFiles(files); + } + }, []); - useKeySubmitTaskAlt(handleAlternativeSubmit, { - scope: Scope.DIALOG, - enableOnFormTags: ['textarea', 'TEXTAREA'], - when: canSubmit && isTextareaFocused, - preventDefault: true, - }); + const { + getRootProps, + getInputProps, + isDragActive, + open: dropzoneOpen, + } = useDropzone({ + onDrop: onDrop, + accept: { 'image/*': [] }, + disabled: isSubmitting, + noClick: true, + noKeyboard: true, + }); - // Handle dialog close attempt - const handleDialogOpenChange = (open: boolean) => { - if (!open && hasUnsavedChanges()) { - // Trying to close with unsaved changes - setShowDiscardWarning(true); - } else if (!open) { - modal.hide(); + // Apply pending files when ImageUploadSection becomes available + useEffect(() => { + if (pendingFiles && imageUploadRef.current) { + imageUploadRef.current.addFiles(pendingFiles); + setPendingFiles(null); + } + }, [pendingFiles]); + + // Image upload callback + const handleImageUploaded = useCallback( + (img: ImageResponse) => { + const markdownText = `![${img.original_name}](${img.file_path})`; + form.setFieldValue('description', (prev) => + prev.trim() === '' ? markdownText : `${prev} ${markdownText}` + ); + setImages((prev) => [...prev, img]); + setNewlyUploadedImageIds((prev) => [...prev, img.id]); + }, + [form] + ); + + // Unsaved changes detection + const hasUnsavedChanges = useCallback(() => { + if (isDirty) return true; + if (newlyUploadedImageIds.length > 0) return true; + if (images.length > 0 && !editMode) return true; + return false; + }, [isDirty, newlyUploadedImageIds, images, editMode]); + + // beforeunload listener + useEffect(() => { + if (!modal.visible || isSubmitting) return; + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges()) { + e.preventDefault(); + return ''; } }; - return ( - <> - - - - - {isEditMode ? 'Edit Task' : 'Create New Task'} - - -
-
- + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [modal.visible, isSubmitting, hasUnsavedChanges]); + + // Keyboard shortcuts + const primaryAction = useCallback(() => { + if (isSubmitting || !canSubmit) return; + void form.handleSubmit(); + }, [form, isSubmitting, canSubmit]); + + const shortcutsEnabled = + modal.visible && !isSubmitting && canSubmit && !showDiscardWarning; + + useKeySubmitTask(primaryAction, { + enabled: shortcutsEnabled, + scope: Scope.DIALOG, + enableOnFormTags: ['input', 'INPUT', 'textarea', 'TEXTAREA'], + preventDefault: true, + }); + + // Dialog close handling + const handleDialogClose = (open: boolean) => { + if (open) return; + if (hasUnsavedChanges()) { + setShowDiscardWarning(true); + } else { + modal.remove(); + } + }; + + const handleDiscardChanges = () => { + form.reset(); + setImages([]); + setNewlyUploadedImageIds([]); + setShowDiscardWarning(false); + modal.remove(); + }; + + const handleContinueEditing = () => { + setShowDiscardWarning(false); + }; + + // Manage CONFIRMATION scope when warning is shown + useEffect(() => { + if (showDiscardWarning) { + disableScope(Scope.DIALOG); + enableScope(Scope.CONFIRMATION); + } else { + disableScope(Scope.CONFIRMATION); + enableScope(Scope.DIALOG); + } + }, [showDiscardWarning, enableScope, disableScope]); + + useKeyExit(handleContinueEditing, { + scope: Scope.CONFIRMATION, + when: () => modal.visible && showDiscardWarning, + }); + + const loading = branchesLoading || userSystemLoading; + if (loading) return <>; + + return ( + <> + +
+ + {/* Drag overlay */} + {isDragActive && ( +
+
+ +

+ {t('taskFormDialog.dropImagesHere')} +

+
+
+ )} + + {/* Title */} +
+ + {(field) => ( setTitle(e.target.value)} - placeholder="What needs to be done?" - className="mt-1.5" - disabled={isSubmitting || isSubmittingAndStart} + value={field.state.value} + onChange={(e) => field.handleChange(e.target.value)} + placeholder={t('taskFormDialog.titlePlaceholder')} + className="text-lg font-medium border-none shadow-none px-0 placeholder:text-muted-foreground/60 focus-visible:ring-0" + disabled={isSubmitting} autoFocus - onCommandEnter={ - isEditMode ? handleSubmit : handleCreateAndStart - } - onCommandShiftEnter={handleSubmit} /> -
+ )} + +
-
-
+ + {/* Create mode dropdowns */} + {!editMode && ( + + {(autoStartField) => ( +
- Description - - setIsTextareaFocused(true)} - onBlur={() => setIsTextareaFocused(false)} - /> -
- - - - {isEditMode && ( -
- - + + {(field) => ( + + field.handleChange(profile) + } + disabled={isSubmitting || !autoStartField.state.value} + showLabel={false} + className="flex items-center gap-2 flex-row flex-[2] min-w-0" + itemClassName="flex-1 min-w-0" + /> + )} + + + {(field) => ( + field.handleChange(branch)} + placeholder="Branch" + className={cn( + 'h-9 flex-1 min-w-0 text-xs', + isSubmitting && 'opacity-50 cursor-not-allowed' + )} + /> + )} +
)} +
+ )} - {!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? -

-
-
+ {/* Actions */} +
+ {/* Attach Image*/} +
-
- -
- - ); - } -); + + {/* Autostart switch */} +
+ {!editMode && ( + + {(field) => ( +
+ + field.handleChange(checked) + } + disabled={isSubmitting} + className="data-[state=checked]:bg-gray-900 dark:data-[state=checked]:bg-gray-100" + aria-label={t('taskFormDialog.startLabel')} + /> + +
+ )} +
+ )} + + {/* Create/Start/Update button*/} + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + values: state.values, + })} + > + {({ canSubmit, isSubmitting, values }) => { + const buttonText = editMode + ? isSubmitting + ? t('taskFormDialog.updating') + : t('taskFormDialog.updateTask') + : isSubmitting + ? values.autoStart + ? t('taskFormDialog.starting') + : t('taskFormDialog.creating') + : t('taskFormDialog.create'); + + return ( + + ); + }} + +
+ + + + {showDiscardWarning && ( +
+
setShowDiscardWarning(false)} + /> +
+ + +
+ + {t('taskFormDialog.discardDialog.title')} + +
+ + {t('taskFormDialog.discardDialog.description')} + +
+ + + + +
+
+
+ )} + + ); +}); diff --git a/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx b/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx index 1b8bcf98..afe696d9 100644 --- a/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx +++ b/frontend/src/components/dialogs/tasks/ViewRelatedTasksDialog.tsx @@ -91,6 +91,7 @@ export const ViewRelatedTasksDialog = await Promise.resolve(); await openTaskForm({ + mode: 'subtask', projectId, parentTaskAttemptId: attempt.id, initialBaseBranch: attempt.branch || attempt.target_branch, diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index 2ad6a36f..c51264b3 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -108,7 +108,7 @@ export function Navbar() { const handleCreateTask = () => { if (projectId) { - openTaskForm({ projectId }); + openTaskForm({ mode: 'create', projectId }); } }; diff --git a/frontend/src/components/settings/ExecutorProfileSelector.tsx b/frontend/src/components/settings/ExecutorProfileSelector.tsx index b988cd99..b0306624 100644 --- a/frontend/src/components/settings/ExecutorProfileSelector.tsx +++ b/frontend/src/components/settings/ExecutorProfileSelector.tsx @@ -1,17 +1,7 @@ -import { Settings2, ArrowDown } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Label } from '@/components/ui/label'; -import type { - BaseCodingAgent, - ExecutorConfig, - ExecutorProfileId, -} from 'shared/types'; +import { AgentSelector } from '@/components/tasks/AgentSelector'; +import { ConfigSelector } from '@/components/tasks/ConfigSelector'; +import { cn } from '@/lib/utils'; +import type { ExecutorConfig, ExecutorProfileId } from 'shared/types'; type Props = { profiles: Record | null; @@ -19,7 +9,8 @@ type Props = { onProfileSelect: (profile: ExecutorProfileId) => void; disabled?: boolean; showLabel?: boolean; - showVariantSelector?: boolean; + className?: string; + itemClassName?: string; }; function ExecutorProfileSelector({ @@ -28,153 +19,31 @@ function ExecutorProfileSelector({ onProfileSelect, disabled = false, showLabel = true, - showVariantSelector = true, + className, + itemClassName, }: Props) { if (!profiles) { return null; } - const handleExecutorChange = (executor: string) => { - onProfileSelect({ - executor: executor as BaseCodingAgent, - variant: null, - }); - }; - - const handleVariantChange = (variant: string) => { - if (selectedProfile) { - onProfileSelect({ - ...selectedProfile, - variant: variant === 'DEFAULT' ? null : variant, - }); - } - }; - - const currentProfile = selectedProfile - ? profiles[selectedProfile.executor] - : null; - const hasVariants = currentProfile && Object.keys(currentProfile).length > 0; - return ( -
- {/* Executor Profile Selector */} -
- {showLabel && ( - - )} - - - - - - {Object.keys(profiles) - .sort((a, b) => a.localeCompare(b)) - .map((executorKey) => ( - handleExecutorChange(executorKey)} - className={ - selectedProfile?.executor === executorKey ? 'bg-accent' : '' - } - > - {executorKey} - - ))} - - -
- - {/* Variant Selector (conditional) */} - {showVariantSelector && - selectedProfile && - hasVariants && - currentProfile && ( -
- - - - - - - {Object.keys(currentProfile).map((variantKey) => ( - handleVariantChange(variantKey)} - className={ - selectedProfile.variant === variantKey ? 'bg-accent' : '' - } - > - {variantKey} - - ))} - - -
- )} - - {/* Show disabled variant selector for profiles without variants */} - {showVariantSelector && - selectedProfile && - !hasVariants && - currentProfile && ( -
- - -
- )} - - {/* Show placeholder for variant when no profile selected */} - {showVariantSelector && !selectedProfile && ( -
- - -
- )} +
+ +
); } diff --git a/frontend/src/components/tasks/AgentSelector.tsx b/frontend/src/components/tasks/AgentSelector.tsx new file mode 100644 index 00000000..470e7f25 --- /dev/null +++ b/frontend/src/components/tasks/AgentSelector.tsx @@ -0,0 +1,84 @@ +import { Bot, ArrowDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Label } from '@/components/ui/label'; +import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types'; + +interface AgentSelectorProps { + profiles: Record> | null; + selectedExecutorProfile: ExecutorProfileId | null; + onChange: (profile: ExecutorProfileId) => void; + disabled?: boolean; + className?: string; + showLabel?: boolean; +} + +export function AgentSelector({ + profiles, + selectedExecutorProfile, + onChange, + disabled, + className = '', + showLabel = false, +}: AgentSelectorProps) { + const agents = profiles + ? (Object.keys(profiles).sort() as BaseCodingAgent[]) + : []; + const selectedAgent = selectedExecutorProfile?.executor; + + if (!profiles) return null; + + return ( +
+ {showLabel && ( + + )} + + + + + + {agents.length === 0 ? ( +
+ No agents available +
+ ) : ( + agents.map((agent) => ( + { + onChange({ + executor: agent, + variant: null, + }); + }} + className={selectedAgent === agent ? 'bg-accent' : ''} + > + {agent} + + )) + )} +
+
+
+ ); +} diff --git a/frontend/src/components/tasks/BranchSelector.tsx b/frontend/src/components/tasks/BranchSelector.tsx index fd442f70..627ba38e 100644 --- a/frontend/src/components/tasks/BranchSelector.tsx +++ b/frontend/src/components/tasks/BranchSelector.tsx @@ -64,9 +64,11 @@ const BranchRow = memo(function BranchRow({ disabled={isDisabled} className={classes.trim()} > -
- {branch.name} -
+
+ + {branch.name} + +
{branch.is_current && ( {t('branchSelector.badges.current')} @@ -209,13 +211,13 @@ function BranchSelector({ size="sm" className={`w-full justify-between text-xs ${className}`} > -
- +
+ {selectedBranch || effectivePlaceholder}
- + diff --git a/frontend/src/components/tasks/ConfigSelector.tsx b/frontend/src/components/tasks/ConfigSelector.tsx new file mode 100644 index 00000000..e849f2b5 --- /dev/null +++ b/frontend/src/components/tasks/ConfigSelector.tsx @@ -0,0 +1,89 @@ +import { Settings2, ArrowDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Label } from '@/components/ui/label'; +import type { ExecutorProfileId } from 'shared/types'; + +interface ConfigSelectorProps { + profiles: Record> | null; + selectedExecutorProfile: ExecutorProfileId | null; + onChange: (profile: ExecutorProfileId) => void; + disabled?: boolean; + className?: string; + showLabel?: boolean; +} + +export function ConfigSelector({ + profiles, + selectedExecutorProfile, + onChange, + disabled, + className = '', + showLabel = false, +}: ConfigSelectorProps) { + const selectedAgent = selectedExecutorProfile?.executor; + const configs = selectedAgent && profiles ? profiles[selectedAgent] : null; + const configOptions = configs ? Object.keys(configs).sort() : []; + const selectedVariant = selectedExecutorProfile?.variant || 'DEFAULT'; + + if ( + !selectedAgent || + !profiles || + !configs || + Object.keys(configs).length === 0 + ) + return null; + + return ( +
+ {showLabel && ( + + )} + + + + + + {configOptions.map((variant) => ( + { + onChange({ + executor: selectedAgent, + variant: variant === 'DEFAULT' ? null : variant, + }); + }} + className={ + (variant === 'DEFAULT' ? null : variant) === + selectedExecutorProfile?.variant + ? 'bg-accent' + : '' + } + > + {variant} + + ))} + + +
+ ); +} diff --git a/frontend/src/components/ui/ActionsDropdown.tsx b/frontend/src/components/ui/ActionsDropdown.tsx index e654f929..78bf3a8e 100644 --- a/frontend/src/components/ui/ActionsDropdown.tsx +++ b/frontend/src/components/ui/ActionsDropdown.tsx @@ -43,13 +43,13 @@ export function ActionsDropdown({ const handleEdit = (e: React.MouseEvent) => { e.stopPropagation(); if (!projectId || !task) return; - openTaskForm({ projectId, task }); + openTaskForm({ mode: 'edit', projectId, task }); }; const handleDuplicate = (e: React.MouseEvent) => { e.stopPropagation(); if (!projectId || !task) return; - openTaskForm({ projectId, initialTask: task }); + openTaskForm({ mode: 'duplicate', projectId, initialTask: task }); }; const handleDelete = async (e: React.MouseEvent) => { @@ -103,10 +103,13 @@ export function ActionsDropdown({ const handleCreateSubtask = (e: React.MouseEvent) => { e.stopPropagation(); if (!projectId || !attempt) return; + const baseBranch = attempt.branch || attempt.target_branch; + if (!baseBranch) return; openTaskForm({ + mode: 'subtask', projectId, parentTaskAttemptId: attempt.id, - initialBaseBranch: attempt.branch || attempt.target_branch, + initialBaseBranch: baseBranch, }); }; diff --git a/frontend/src/components/ui/ImageUploadSection.tsx b/frontend/src/components/ui/ImageUploadSection.tsx index 897b5b80..5adcaa09 100644 --- a/frontend/src/components/ui/ImageUploadSection.tsx +++ b/frontend/src/components/ui/ImageUploadSection.tsx @@ -30,6 +30,7 @@ interface ImageUploadSectionProps { readOnly?: boolean; collapsible?: boolean; defaultExpanded?: boolean; + hideDropZone?: boolean; // Hide the drag and drop area className?: string; } @@ -64,6 +65,7 @@ export const ImageUploadSection = forwardRef< readOnly = false, collapsible = true, defaultExpanded = false, + hideDropZone = false, className, }, ref @@ -232,8 +234,8 @@ export const ImageUploadSection = forwardRef<

No images attached

)} - {/* Drop zone - only show when not read-only */} - {!readOnly && ( + {/* Drop zone - only show when not read-only and not hidden */} + {!readOnly && !hideDropZone && (
{ maxRows?: number; + disableInternalScroll?: boolean; } const AutoExpandingTextarea = React.forwardRef< HTMLTextAreaElement, AutoExpandingTextareaProps ->(({ className, maxRows = 10, ...props }, ref) => { - const internalRef = React.useRef(null); +>( + ( + { className, maxRows = 10, disableInternalScroll = false, ...props }, + ref + ) => { + const internalRef = React.useRef(null); - // Get the actual ref to use - const textareaRef = ref || internalRef; + // Get the actual ref to use + const textareaRef = ref || internalRef; - const adjustHeight = React.useCallback(() => { - const textarea = (textareaRef as React.RefObject) - .current; - if (!textarea) return; + const adjustHeight = React.useCallback(() => { + const textarea = (textareaRef as React.RefObject) + .current; + if (!textarea) return; - // Reset height to auto to get the natural height - textarea.style.height = 'auto'; + // Reset height to auto to get the natural height + textarea.style.height = 'auto'; - // Calculate line height - const style = window.getComputedStyle(textarea); - const lineHeight = parseInt(style.lineHeight) || 20; - const paddingTop = parseInt(style.paddingTop) || 0; - const paddingBottom = parseInt(style.paddingBottom) || 0; + if (disableInternalScroll) { + // When parent handles scroll, expand to full content height + textarea.style.height = `${textarea.scrollHeight}px`; + } else { + // Calculate line height + const style = window.getComputedStyle(textarea); + const lineHeight = parseInt(style.lineHeight) || 20; + const paddingTop = parseInt(style.paddingTop) || 0; + const paddingBottom = parseInt(style.paddingBottom) || 0; - // Calculate max height based on maxRows - const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom; + // Calculate max height based on maxRows + const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom; - // Set the height to scrollHeight, but cap at maxHeight - const newHeight = Math.min(textarea.scrollHeight, maxHeight); - textarea.style.height = `${newHeight}px`; - }, [maxRows]); - - // Adjust height on mount and when content changes - React.useEffect(() => { - adjustHeight(); - }, [adjustHeight, props.value]); - - // Adjust height on input - const handleInput = React.useCallback( - (e: React.FormEvent) => { - adjustHeight(); - if (props.onInput) { - props.onInput(e); + // Set the height to scrollHeight, but cap at maxHeight + const newHeight = Math.min(textarea.scrollHeight, maxHeight); + textarea.style.height = `${newHeight}px`; } - }, - [adjustHeight, props.onInput] - ); + }, [maxRows, disableInternalScroll]); - return ( -