mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
feat: enhance feature dialogs with OpenCode model support
- Added OpenCode model selection to AddFeatureDialog and EditFeatureDialog. - Introduced ProfileTypeahead component for improved profile selection. - Updated model constants to include OpenCode models and integrated them into the PhaseModelSelector. - Enhanced planning mode options with new UI elements for OpenCode. - Refactored existing components to streamline model handling and improve user experience. This commit expands the functionality of the feature dialogs, allowing users to select and manage OpenCode models effectively.
This commit is contained in:
@@ -9,11 +9,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
@@ -21,15 +21,10 @@ import {
|
||||
FeatureTextFilePath as DescriptionTextFilePath,
|
||||
ImagePreviewMap,
|
||||
} from '@/components/ui/description-image-dropzone';
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import {
|
||||
@@ -41,19 +36,22 @@ import {
|
||||
PlanningMode,
|
||||
Feature,
|
||||
} from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { codexModelHasThinking, supportsReasoningEffort } from '@automaker/types';
|
||||
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
|
||||
import {
|
||||
supportsReasoningEffort,
|
||||
PROVIDER_PREFIXES,
|
||||
isCursorModel,
|
||||
isClaudeModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
ReasoningEffortSelector,
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
PlanningModeSelect,
|
||||
AncestorContextSection,
|
||||
ProfileTypeahead,
|
||||
} from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -67,7 +65,6 @@ import {
|
||||
formatAncestorContextForPrompt,
|
||||
type AncestorContext,
|
||||
} from '@automaker/dependency-resolver';
|
||||
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AddFeatureDialog');
|
||||
|
||||
@@ -82,7 +79,7 @@ type FeatureData = {
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
@@ -96,14 +93,13 @@ interface AddFeatureDialogProps {
|
||||
onAddAndStart?: (feature: FeatureData) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
branchCardCounts?: Record<string, number>;
|
||||
defaultSkipTests: boolean;
|
||||
defaultBranch?: string;
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
// Spawn task mode props
|
||||
parentFeature?: Feature | null;
|
||||
allFeatures?: Feature[];
|
||||
}
|
||||
@@ -128,37 +124,43 @@ export function AddFeatureDialog({
|
||||
const isSpawnMode = !!parentFeature;
|
||||
const navigate = useNavigate();
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
textFilePaths: [] as DescriptionTextFilePath[],
|
||||
skipTests: false,
|
||||
model: 'opus' as ModelAlias,
|
||||
thinkingLevel: 'none' as ThinkingLevel,
|
||||
reasoningEffort: 'none' as ReasoningEffort,
|
||||
branchName: '',
|
||||
priority: 2 as number, // Default to medium priority
|
||||
});
|
||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||
() => new Map()
|
||||
);
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [images, setImages] = useState<FeatureImage[]>([]);
|
||||
const [imagePaths, setImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||
const [textFilePaths, setTextFilePaths] = useState<DescriptionTextFilePath[]>([]);
|
||||
const [skipTests, setSkipTests] = useState(false);
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
// Planning mode state
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
>('improve');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Spawn mode state
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Get planning mode defaults and worktrees setting from store
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } =
|
||||
useAppStore();
|
||||
|
||||
@@ -168,28 +170,29 @@ export function AddFeatureDialog({
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Find the default profile if one is set
|
||||
const defaultProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch || '',
|
||||
// Use default profile's model/thinkingLevel if set, else fallback to defaults
|
||||
model: defaultProfile?.model ?? 'opus',
|
||||
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
|
||||
}));
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName(defaultBranch || '');
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
|
||||
// Set model from default profile or fallback
|
||||
if (defaultProfile) {
|
||||
setSelectedProfileId(defaultProfile.id);
|
||||
applyProfileToModel(defaultProfile);
|
||||
} else {
|
||||
setSelectedProfileId(undefined);
|
||||
setModelEntry({ model: 'opus' });
|
||||
}
|
||||
|
||||
// Initialize ancestors for spawn mode
|
||||
if (parentFeature) {
|
||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||
setAncestors(ancestorList);
|
||||
// Only select parent by default - ancestors are optional context
|
||||
setSelectedAncestorIds(new Set([parentFeature.id]));
|
||||
} else {
|
||||
setAncestors([]);
|
||||
@@ -208,36 +211,62 @@ export function AddFeatureDialog({
|
||||
allFeatures,
|
||||
]);
|
||||
|
||||
const applyProfileToModel = (profile: AIProfile) => {
|
||||
if (profile.provider === 'cursor') {
|
||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
setModelEntry({ model: cursorModel as ModelAlias });
|
||||
} else if (profile.provider === 'codex') {
|
||||
setModelEntry({
|
||||
model: profile.codexModel || 'codex-gpt-5.2-codex',
|
||||
reasoningEffort: 'none',
|
||||
});
|
||||
} else if (profile.provider === 'opencode') {
|
||||
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
|
||||
} else {
|
||||
// Claude
|
||||
setModelEntry({
|
||||
model: profile.model || 'sonnet',
|
||||
thinkingLevel: profile.thinkingLevel || 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSelect = (profile: AIProfile) => {
|
||||
setSelectedProfileId(profile.id);
|
||||
applyProfileToModel(profile);
|
||||
};
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
// Clear profile selection when manually changing model
|
||||
setSelectedProfileId(undefined);
|
||||
};
|
||||
|
||||
const buildFeatureData = (): FeatureData | null => {
|
||||
if (!newFeature.description.trim()) {
|
||||
if (!description.trim()) {
|
||||
setDescriptionError(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate branch selection when "other branch" is selected
|
||||
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
||||
if (useWorktrees && !useCurrentBranch && !branchName.trim()) {
|
||||
toast.error('Please select a branch name');
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = newFeature.category || 'Uncategorized';
|
||||
const selectedModel = newFeature.model;
|
||||
const finalCategory = category || 'Uncategorized';
|
||||
const selectedModel = modelEntry.model;
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
? newFeature.thinkingLevel
|
||||
? modelEntry.thinkingLevel || 'none'
|
||||
: 'none';
|
||||
const normalizedReasoning = supportsReasoningEffort(selectedModel)
|
||||
? newFeature.reasoningEffort
|
||||
? modelEntry.reasoningEffort || 'none'
|
||||
: 'none';
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
|
||||
const finalBranchName = useCurrentBranch ? currentBranch || '' : branchName || '';
|
||||
|
||||
// Build final description - prepend ancestor context in spawn mode
|
||||
let finalDescription = newFeature.description;
|
||||
// Build final description with ancestor context in spawn mode
|
||||
let finalDescription = description;
|
||||
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
|
||||
// Create parent context as an AncestorContext
|
||||
const parentContext: AncestorContext = {
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
@@ -254,93 +283,84 @@ export function AddFeatureDialog({
|
||||
);
|
||||
|
||||
if (contextText) {
|
||||
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
|
||||
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: newFeature.title,
|
||||
category,
|
||||
title,
|
||||
category: finalCategory,
|
||||
description: finalDescription,
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
textFilePaths: newFeature.textFilePaths,
|
||||
skipTests: newFeature.skipTests,
|
||||
images,
|
||||
imagePaths,
|
||||
textFilePaths,
|
||||
skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
reasoningEffort: normalizedReasoning,
|
||||
branchName: finalBranchName,
|
||||
priority: newFeature.priority,
|
||||
priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
// In spawn mode, automatically add parent as dependency
|
||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewFeature({
|
||||
title: '',
|
||||
category: '',
|
||||
description: '',
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
textFilePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus',
|
||||
priority: 2,
|
||||
thinkingLevel: 'none',
|
||||
reasoningEffort: 'none',
|
||||
branchName: '',
|
||||
});
|
||||
setTitle('');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setImages([]);
|
||||
setImagePaths([]);
|
||||
setTextFilePaths([]);
|
||||
setSkipTests(defaultSkipTests);
|
||||
setBranchName('');
|
||||
setPriority(2);
|
||||
setSelectedProfileId(undefined);
|
||||
setModelEntry({ model: 'opus' });
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setEnhanceOpen(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleAction = (actionFn?: (data: FeatureData) => void) => {
|
||||
if (!actionFn) return;
|
||||
|
||||
const featureData = buildFeatureData();
|
||||
if (!featureData) return;
|
||||
|
||||
actionFn(featureData);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleAdd = () => handleAction(onAdd);
|
||||
|
||||
const handleAddAndStart = () => handleAction(onAddAndStart);
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnhanceDescription = async () => {
|
||||
if (!newFeature.description.trim() || isEnhancing) return;
|
||||
if (!description.trim() || isEnhancing) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.enhancePrompt?.enhance(
|
||||
newFeature.description,
|
||||
description,
|
||||
enhancementMode,
|
||||
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
|
||||
enhancementOverride.effectiveModel,
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
||||
setDescription(result.enhancedText);
|
||||
toast.success('Description enhanced!');
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to enhance description');
|
||||
@@ -353,59 +373,9 @@ export function AddFeatureDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: string) => {
|
||||
// For Cursor models, thinking is handled by the model itself
|
||||
// For Claude models, check if it supports extended thinking
|
||||
const isCursor = isCursorModel(model);
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model: model as ModelAlias,
|
||||
thinkingLevel: isCursor
|
||||
? 'none'
|
||||
: modelSupportsThinking(model)
|
||||
? newFeature.thinkingLevel
|
||||
: 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (profile: AIProfile) => {
|
||||
if (profile.provider === 'cursor') {
|
||||
// Cursor profile - set cursor model
|
||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model: cursorModel as ModelAlias,
|
||||
thinkingLevel: 'none', // Cursor handles thinking internally
|
||||
});
|
||||
} else {
|
||||
// Claude profile - ensure model is always set from profile
|
||||
const profileModel = profile.model;
|
||||
if (!profileModel || !['haiku', 'sonnet', 'opus'].includes(profileModel)) {
|
||||
console.warn(
|
||||
`[ProfileSelect] Invalid or missing model "${profileModel}" for profile "${profile.name}", defaulting to sonnet`
|
||||
);
|
||||
}
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model:
|
||||
profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel)
|
||||
? profileModel
|
||||
: 'sonnet',
|
||||
thinkingLevel:
|
||||
profile.thinkingLevel && profile.thinkingLevel !== 'none'
|
||||
? profile.thinkingLevel
|
||||
: 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cursor models handle thinking internally, so only show thinking selector for Claude models
|
||||
const isCurrentModelCursor = isCursorModel(newFeature.model);
|
||||
const newModelAllowsThinking =
|
||||
!isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet');
|
||||
|
||||
// Codex models that support reasoning effort - show reasoning selector
|
||||
const newModelAllowsReasoning = supportsReasoningEffort(newFeature.model || '');
|
||||
// Shared card styling
|
||||
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
@@ -433,239 +403,264 @@ export function AddFeatureDialog({
|
||||
: 'Create a new feature card for the Kanban board.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model" data-testid="tab-model">
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" data-testid="tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Ancestor Context Section - only in spawn mode */}
|
||||
{isSpawnMode && parentFeature && (
|
||||
<AncestorContextSection
|
||||
parentFeature={{
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
description: parentFeature.description,
|
||||
spec: parentFeature.spec,
|
||||
summary: parentFeature.summary,
|
||||
}}
|
||||
ancestors={ancestors}
|
||||
selectedAncestorIds={selectedAncestorIds}
|
||||
onSelectionChange={setSelectedAncestorIds}
|
||||
/>
|
||||
)}
|
||||
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Ancestor Context Section - only in spawn mode */}
|
||||
{isSpawnMode && parentFeature && (
|
||||
<AncestorContextSection
|
||||
parentFeature={{
|
||||
id: parentFeature.id,
|
||||
title: parentFeature.title,
|
||||
description: parentFeature.description,
|
||||
spec: parentFeature.spec,
|
||||
summary: parentFeature.summary,
|
||||
}}
|
||||
ancestors={ancestors}
|
||||
selectedAncestorIds={selectedAncestorIds}
|
||||
onSelectionChange={setSelectedAncestorIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Details Section */}
|
||||
<div className={cardClass}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
value={newFeature.description}
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
setNewFeature({ ...newFeature, description: value });
|
||||
if (value.trim()) {
|
||||
setDescriptionError(false);
|
||||
}
|
||||
setDescription(value);
|
||||
if (value.trim()) setDescriptionError(false);
|
||||
}}
|
||||
images={newFeature.imagePaths}
|
||||
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
||||
textFiles={newFeature.textFilePaths}
|
||||
onTextFilesChange={(textFiles) =>
|
||||
setNewFeature({ ...newFeature, textFilePaths: textFiles })
|
||||
}
|
||||
images={imagePaths}
|
||||
onImagesChange={setImagePaths}
|
||||
textFiles={textFilePaths}
|
||||
onTextFilesChange={setTextFilePaths}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={newFeaturePreviewMap}
|
||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||
previewMap={previewMap}
|
||||
onPreviewMapChange={setPreviewMap}
|
||||
autoFocus
|
||||
error={descriptionError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newFeature.title}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[200px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
|
||||
{/* Collapsible Enhancement Section */}
|
||||
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
||||
{enhanceOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Enhance with AI</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2 pl-6">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Enhance
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!newFeature.description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Enhance with AI
|
||||
</Button>
|
||||
|
||||
<ModelOverrideTrigger
|
||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
||||
onModelChange={enhancementOverride.setOverride}
|
||||
phase="enhancementModel"
|
||||
isOverridden={enhancementOverride.isOverridden}
|
||||
size="sm"
|
||||
variant="icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category (optional)</Label>
|
||||
<CategoryAutocomplete
|
||||
value={newFeature.category}
|
||||
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={newFeature.branchName}
|
||||
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
testIdPrefix="feature"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
selectedPriority={newFeature.priority}
|
||||
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
|
||||
testIdPrefix="priority"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||
</p>
|
||||
<ModelOverrideTrigger
|
||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
||||
onModelChange={enhancementOverride.setOverride}
|
||||
phase="enhancementModel"
|
||||
isOverridden={enhancementOverride.isOverridden}
|
||||
size="sm"
|
||||
variant="icon"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
data-testid="show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
||||
</Button>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* AI & Execution Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Profile</Label>
|
||||
<ProfileTypeahead
|
||||
profiles={aiProfiles}
|
||||
selectedProfileId={selectedProfileId}
|
||||
onSelect={handleProfileSelect}
|
||||
placeholder="Select profile..."
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/profiles' });
|
||||
}}
|
||||
testIdPrefix="add-feature-profile"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<PhaseModelSelector
|
||||
value={modelEntry}
|
||||
onChange={handleModelChange}
|
||||
compact
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
|
||||
)}
|
||||
>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-skip-tests"
|
||||
checked={!skipTests}
|
||||
onCheckedChange={(checked) => setSkipTests(!checked)}
|
||||
data-testid="add-feature-skip-tests-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-skip-tests"
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
Run tests
|
||||
</Label>
|
||||
</div>
|
||||
{modelSupportsPlanningMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
data-testid="add-feature-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
Require approval
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Section */}
|
||||
<div className={cardClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<FolderKanban className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||
<CategoryAutocomplete
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Priority</Label>
|
||||
<PrioritySelector
|
||||
selectedPriority={priority}
|
||||
onPrioritySelect={setPriority}
|
||||
testIdPrefix="priority"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch Selector */}
|
||||
{useWorktrees && (
|
||||
<div className="pt-2">
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={branchName}
|
||||
onBranchNameChange={setBranchName}
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
testIdPrefix="feature"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={newFeature.model}
|
||||
selectedThinkingLevel={newFeature.thinkingLevel}
|
||||
selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined}
|
||||
onSelect={handleProfileSelect}
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/profiles' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
|
||||
{newModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={newFeature.thinkingLevel}
|
||||
onLevelSelect={(level) =>
|
||||
setNewFeature({ ...newFeature, thinkingLevel: level })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{newModelAllowsReasoning && (
|
||||
<ReasoningEffortSelector
|
||||
selectedEffort={newFeature.reasoningEffort}
|
||||
onEffortSelect={(effort) =>
|
||||
setNewFeature({ ...newFeature, reasoningEffort: effort })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={newFeature.description}
|
||||
testIdPrefix="add-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={newFeature.skipTests}
|
||||
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
@@ -675,7 +670,7 @@ export function AddFeatureDialog({
|
||||
onClick={handleAddAndStart}
|
||||
variant="secondary"
|
||||
data-testid="confirm-add-and-start-feature"
|
||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
||||
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Make
|
||||
@@ -686,7 +681,7 @@ export function AddFeatureDialog({
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-add-feature"
|
||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
||||
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
|
||||
>
|
||||
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
||||
</HotkeyButton>
|
||||
|
||||
Reference in New Issue
Block a user