Files
vibe-kanban/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
Alex Netsch adc362955a Add variant/config cycle keyboard shortcut (vibe-kanban) (#922)
* Perfect! I've successfully implemented the Shift+Tab keyboard shortcut to cycle between agent configurations. Here's a summary of the changes:

Added Shift+Tab keyboard shortcut to cycle through agent configuration variants when editing the follow-up textbox.

1. **TaskFollowUpSection.tsx** (frontend/src/components/tasks/TaskFollowUpSection.tsx:125-136, 400)
   - Added `cycleVariant` function that cycles to the next variant in the current profile
   - Passed `onShiftTab={cycleVariant}` to `FollowUpEditorCard`

2. **FollowUpEditorCard.tsx** (frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx:17, 31, 50)
   - Added `onShiftTab?: () => void` prop to component interface
   - Passed through to `FileSearchTextarea`

3. **file-search-textarea.tsx** (frontend/src/components/ui/file-search-textarea.tsx:25, 40, 294)
   - Added `onShiftTab?: () => void` prop to component interface
   - Passed through to `AutoExpandingTextarea`

4. **auto-expanding-textarea.tsx** (frontend/src/components/ui/auto-expanding-textarea.tsx:8, 21, 62-65, 77)
   - Added `onShiftTab?: () => void` prop to component interface
   - Implemented Shift+Tab handler in `handleKeyDown` that prevents default behavior and calls the callback
   - Added to useCallback dependencies

- When the user presses **Shift+Tab** while editing the follow-up textbox, it cycles to the next agent configuration variant
- The `VariantSelector` component already has bump animation implemented (TaskFollowUpSection.tsx line 23-30 in VariantSelector.tsx) which will trigger on variant change
- The shortcut only works when the textbox is editable and active

The fix ensures that when `selectedVariant` is `null`, the first press of Shift+Tab will explicitly set the variant to the first one in the list (index 0), making the cycling work correctly from the start.

Perfect! I've updated the `cycleVariant` function to treat "DEFAULT" as a regular variant in the cycle. Here's what the fix does:

**The fix ensures that:**
1. When `selectedVariant` is `null`, it's treated as `"DEFAULT"` for lookup purposes
2. All variants including "DEFAULT" are included in the cycling
3. The cycle works: DEFAULT → variant1 → variant2 → ... → DEFAULT
4. When cycling lands on "DEFAULT", it converts back to `null` (since that's what the backend/API expects)

**Why this works:**
- First press from `null` (DEFAULT): Cycles to the first non-DEFAULT variant ✓
- Provides keyboard access to cycle back to DEFAULT ✓
- Treats "DEFAULT" as what it truly is: a configured variant, not a special case ✓
- No hacky filters or special-casing needed ✓

The implementation is clean and treats "DEFAULT" as a first-class variant in the cycling logic, while maintaining the existing API contract where `null` represents DEFAULT.

Perfect! Now the Shift+Tab interception only happens when `onShiftTab` is actually provided. This ensures:

1. **Follow-up text box**: Shift+Tab cycles through variants (has `onShiftTab` handler)
2. **All other textareas**: Shift+Tab works normally for browser focus navigation (no `onShiftTab` handler)

The fix preserves the default browser behavior for all existing uses of `AutoExpandingTextarea` while enabling the variant cycling feature only where it's needed.

Refactor keyboard shortcuts (vibe-kanban 67d268cf)

We did this in the last 4 commits:
Add a shortcut to cycle between agent configurations (previously called profile variants) when typing a follow up. Should be active when the user is editing the follow-up text box.
User shift tab to trigger the shortcut.

However this doesnt use the current pattern we want to use for shortcuts. Figure out the correct way and migrate.

Refactor keyboard shortcuts (vibe-kanban 4d35f44f)

Your colleague already handled one:
Refactoring Summary
The Shift+Tab shortcut was refactored from a prop-drilling approach (passing onShiftTab through multiple components) to the centralized keyboard system using the semantic hook pattern. The new implementation:

Registered the shortcut in registry.ts as CYCLE_VARIANT action bound to shift+tab in the FOLLOW_UP scope
Created a semantic hook useKeyCycleVariant for type-safe usage
Used scope management with enableScope(Scope.FOLLOW_UP) to control when the shortcut is active (only when textarea is focused AND editable)
Removed prop drilling - deleted onShiftTab props from FollowUpEditorCard, FileSearchTextarea, and AutoExpandingTextarea
Pattern for other shortcuts: Add the action/keys to registry.ts, create a semantic hook in hooks.ts, use the hook in your component with appropriate scope, and manage scope activation with enableScope/disableScope based on when the feature should be available. The when clause is only for additional runtime conditions beyond what scope controls.

Can we mirgate the rest of the shortcuts in frontend/src/components/tasks/TaskFollowUpSection.tsx

Fix shortcut regressions (vibe-kanban c07e229f)

To‑Do For Shortcuts

Restore submit shortcut in task dialog (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:491): reintroduce Cmd+Enter / Cmd+Shift+Enter when focus is in the description FileSearchTextarea. Prefer the new keyboard system—add a semantic hook (likely useKeySubmitTask) scoped appropriately, or, if we don’t have one yet, bind via useKeySubmit while the dialog is active. The binding must only be enabled when the form can submit and the textarea is focused.
Re-enable Cmd+Enter save in inline review comments (frontend/src/components/diff/CommentWidgetLine.tsx:69): same story—bring back the shortcut using our keyboard hooks so reviewers can submit without leaving the textarea.
Fix FileSearchTextarea event propagation (frontend/src/components/ui/file-search-textarea.tsx:12, :235): the component still lists onKeyDown but never calls it. Destructure onKeyDown from props and invoke it at the end of handleKeyDown so parent components can layer their own key handling again.

Fix shortcut regressions (vibe-kanban c07e229f)

To‑Do For Shortcuts

Restore submit shortcut in task dialog (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:491): reintroduce Cmd+Enter / Cmd+Shift+Enter when focus is in the description FileSearchTextarea. Prefer the new keyboard system—add a semantic hook (likely useKeySubmitTask) scoped appropriately, or, if we don’t have one yet, bind via useKeySubmit while the dialog is active. The binding must only be enabled when the form can submit and the textarea is focused.
Re-enable Cmd+Enter save in inline review comments (frontend/src/components/diff/CommentWidgetLine.tsx:69): same story—bring back the shortcut using our keyboard hooks so reviewers can submit without leaving the textarea.
Fix FileSearchTextarea event propagation (frontend/src/components/ui/file-search-textarea.tsx:12, :235): the component still lists onKeyDown but never calls it. Destructure onKeyDown from props and invoke it at the end of handleKeyDown so parent components can layer their own key handling again.

Fmt

* Perfect! I've added the `isComposing` check to `useKeyboardShortcut.ts` at line 68. This fix ensures that:

## What Was Fixed

The `isComposing` check that was lost during the rebase has been restored, but in the new architecture's proper location:

**Before the rebase (old architecture):**
- Each component (`AutoExpandingTextarea`, `Input`) had its own keyboard handlers
- The `isComposing` check was added to these handlers: `if (e.key === 'Enter' && !e.nativeEvent.isComposing)`

**After the rebase (new architecture):**
- All keyboard shortcuts centralized through `useKeyboardShortcut.ts` → `react-hotkeys-hook`
- The check needed to move to the central handler

**The fix:**
- Added `if (event.isComposing) return;` at line 68 in `useKeyboardShortcut.ts`
- This protects **all** keyboard shortcuts (not just Enter) from firing during IME composition
- Covers all actions: `SUBMIT_FOLLOW_UP`, `SUBMIT_TASK`, `CYCLE_VARIANT`, etc.

## Impact

Now when users input text using IME (Japanese, Chinese, Korean, etc.):
- Pressing Enter to confirm text conversion won't accidentally trigger shortcuts
- Pressing Shift+Tab during composition won't cycle variants
- All keyboard shortcuts properly respect IME composition state

This is actually **better** than the old fix because it applies universally to all shortcuts, not just the ones that were manually checked before.
2025-10-06 14:11:33 +01:00

766 lines
27 KiB
TypeScript

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';
import { useKeySubmitTask, useKeySubmitTaskAlt, Scope } from '@/keyboard';
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<TaskFormDialogProps>(
({
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<TaskStatus>('todo');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmittingAndStart, setIsSubmittingAndStart] = useState(false);
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [showDiscardWarning, setShowDiscardWarning] = useState(false);
const [images, setImages] = useState<ImageResponse[]>([]);
const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState<
string[]
>([]);
const [branches, setBranches] = useState<GitBranch[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [selectedExecutorProfile, setSelectedExecutorProfile] =
useState<ExecutorProfileId | null>(null);
const [quickstartExpanded, setQuickstartExpanded] =
useState<boolean>(false);
const imageUploadRef = useRef<ImageUploadSectionHandle>(null);
const [isTextareaFocused, setIsTextareaFocused] = useState(false);
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]);
// Keyboard shortcut handlers
const handlePrimarySubmit = useCallback(
(e?: KeyboardEvent) => {
e?.preventDefault();
if (isEditMode) {
handleSubmit();
} else {
handleCreateAndStart();
}
},
[isEditMode, handleSubmit, handleCreateAndStart]
);
const handleAlternativeSubmit = useCallback(
(e?: KeyboardEvent) => {
e?.preventDefault();
handleSubmit();
},
[handleSubmit]
);
// Register keyboard shortcuts
const canSubmit =
title.trim() !== '' && !isSubmitting && !isSubmittingAndStart;
useKeySubmitTask(handlePrimarySubmit, {
scope: Scope.DIALOG,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: canSubmit && isTextareaFocused,
preventDefault: true,
});
useKeySubmitTaskAlt(handleAlternativeSubmit, {
scope: Scope.DIALOG,
enableOnFormTags: ['textarea', 'TEXTAREA'],
when: canSubmit && isTextareaFocused,
preventDefault: 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();
}
};
return (
<>
<Dialog open={modal.visible} onOpenChange={handleDialogOpenChange}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>
{isEditMode ? 'Edit Task' : 'Create New Task'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="task-title" className="text-sm font-medium">
Title
</Label>
<Input
id="task-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
className="mt-1.5"
disabled={isSubmitting || isSubmittingAndStart}
autoFocus
onCommandEnter={
isEditMode ? handleSubmit : handleCreateAndStart
}
onCommandShiftEnter={handleSubmit}
/>
</div>
<div>
<Label
htmlFor="task-description"
className="text-sm font-medium"
>
Description
</Label>
<FileSearchTextarea
value={description}
onChange={setDescription}
rows={3}
maxRows={8}
placeholder="Add more details (optional). Type @ to search files."
className="mt-1.5"
disabled={isSubmitting || isSubmittingAndStart}
projectId={projectId}
onPasteFiles={handlePasteImages}
onFocus={() => setIsTextareaFocused(true)}
onBlur={() => setIsTextareaFocused(false)}
/>
</div>
<ImageUploadSection
ref={imageUploadRef}
images={images}
onImagesChange={handleImagesChange}
onUpload={imagesApi.upload}
onDelete={imagesApi.delete}
onImageUploaded={handleImageUploaded}
disabled={isSubmitting || isSubmittingAndStart}
readOnly={isEditMode}
collapsible={true}
defaultExpanded={false}
/>
{!isEditMode && templates.length > 0 && (
<div className="pt-2">
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-2">
<svg
className="h-3 w-3 transition-transform group-open:rotate-90"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
Use a template
</summary>
<div className="mt-3 space-y-2">
<p className="text-xs text-muted-foreground">
Templates help you quickly create tasks with predefined
content.
</p>
<Select
value={selectedTemplate}
onValueChange={handleTemplateChange}
>
<SelectTrigger id="task-template" className="w-full">
<SelectValue placeholder="Choose a template to prefill this form" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No template</SelectItem>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
{template.project_id === null && (
<Globe2 className="h-3 w-3 text-muted-foreground" />
)}
<span>{template.template_name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</details>
</div>
)}
{isEditMode && (
<div className="pt-2">
<Label htmlFor="task-status" className="text-sm font-medium">
Status
</Label>
<Select
value={status}
onValueChange={(value) => setStatus(value as TaskStatus)}
disabled={isSubmitting || isSubmittingAndStart}
>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="inprogress">In Progress</SelectItem>
<SelectItem value="inreview">In Review</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
)}
{!isEditMode &&
(() => {
const quickstartSection = (
<div className="pt-2">
<details
className="group"
open={quickstartExpanded}
onToggle={(e) =>
setQuickstartExpanded(
(e.target as HTMLDetailsElement).open
)
}
>
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-2">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
<Settings2 className="h-3 w-3" />
Quickstart
</summary>
<div className="mt-3 space-y-3">
<p className="text-xs text-muted-foreground">
Configuration for "Create & Start" workflow
</p>
{/* Executor Profile Selector */}
{profiles && selectedExecutorProfile && (
<ExecutorProfileSelector
profiles={profiles}
selectedProfile={selectedExecutorProfile}
onProfileSelect={setSelectedExecutorProfile}
disabled={isSubmitting || isSubmittingAndStart}
/>
)}
{/* Branch Selector */}
{branches.length > 0 && (
<div>
<Label
htmlFor="base-branch"
className="text-sm font-medium"
>
Branch
</Label>
<div className="mt-1.5">
<BranchSelector
branches={branches}
selectedBranch={selectedBranch}
onBranchSelect={setSelectedBranch}
placeholder="Select branch"
className={
isSubmitting || isSubmittingAndStart
? 'opacity-50 cursor-not-allowed'
: ''
}
/>
</div>
</div>
)}
</div>
</details>
</div>
);
return quickstartSection;
})()}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSubmitting || isSubmittingAndStart}
>
Cancel
</Button>
{isEditMode ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim()}
>
{isSubmitting ? 'Updating...' : 'Update Task'}
</Button>
) : (
<>
<Button
variant="outline"
onClick={handleSubmit}
disabled={
isSubmitting || isSubmittingAndStart || !title.trim()
}
>
{isSubmitting ? 'Creating...' : 'Create Task'}
</Button>
<Button
onClick={handleCreateAndStart}
disabled={
isSubmitting || isSubmittingAndStart || !title.trim()
}
className={'font-medium'}
>
{isSubmittingAndStart
? 'Creating & Starting...'
: 'Create & Start'}
</Button>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Discard Warning Dialog */}
<Dialog open={showDiscardWarning} onOpenChange={setShowDiscardWarning}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Discard unsaved changes?</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
You have unsaved changes. Are you sure you want to discard them?
</p>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowDiscardWarning(false)}
>
Continue Editing
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
);