mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +00:00
Add quick-add feature with improved workflows (#802)
* Changes from feature/quick-add * feat: Clarify system prompt and improve error handling across services. Address PR Feedback * feat: Improve PR description parsing and refactor event handling * feat: Add context options to pipeline orchestrator initialization * fix: Deduplicate React and handle CJS interop for use-sync-external-store Resolve "Cannot read properties of null (reading 'useState')" errors by deduplicating React/react-dom and ensuring use-sync-external-store is bundled together with React to prevent CJS packages from resolving to different React instances.
This commit is contained in:
@@ -29,7 +29,10 @@ import { useAppStore } from '@/store/app-store';
|
||||
/**
|
||||
* Normalize PhaseModelEntry or string to PhaseModelEntry
|
||||
*/
|
||||
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
function normalizeEntry(entry: PhaseModelEntry | string | undefined | null): PhaseModelEntry {
|
||||
if (!entry) {
|
||||
return { model: 'claude-sonnet' as ModelAlias };
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
return { model: entry as ModelAlias | CursorModelId };
|
||||
}
|
||||
@@ -110,7 +113,12 @@ export function BacklogPlanDialog({
|
||||
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
|
||||
const result = await api.backlogPlan.generate(
|
||||
projectPath,
|
||||
prompt,
|
||||
effectiveModel,
|
||||
currentBranch
|
||||
);
|
||||
if (!result.success) {
|
||||
logger.error('Backlog plan generation failed to start', {
|
||||
error: result.error,
|
||||
@@ -131,7 +139,15 @@ export function BacklogPlanDialog({
|
||||
});
|
||||
setPrompt('');
|
||||
onClose();
|
||||
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]);
|
||||
}, [
|
||||
projectPath,
|
||||
prompt,
|
||||
modelOverride,
|
||||
phaseModels,
|
||||
setIsGeneratingPlan,
|
||||
onClose,
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!pendingPlanResult) return;
|
||||
|
||||
@@ -10,12 +10,27 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
RefreshCw,
|
||||
Cloud,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* Parse git/worktree error messages and return user-friendly versions
|
||||
@@ -113,10 +128,19 @@ export function CreateWorktreeDialog({
|
||||
// allow free-form branch entry via allowCreate as a fallback.
|
||||
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
|
||||
|
||||
// Remote selection state
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('local');
|
||||
const [availableRemotes, setAvailableRemotes] = useState<Array<{ name: string; url: string }>>(
|
||||
[]
|
||||
);
|
||||
const [remoteBranches, setRemoteBranches] = useState<
|
||||
Map<string, Array<{ name: string; fullRef: string }>>
|
||||
>(new Map());
|
||||
|
||||
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
|
||||
const branchFetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch available branches (local + remote) when the base branch section is expanded
|
||||
// Fetch available branches and remotes when the base branch section is expanded
|
||||
const fetchBranches = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (!projectPath) return;
|
||||
@@ -125,13 +149,16 @@ export function CreateWorktreeDialog({
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Fetch branches using the project path (use listBranches on the project root).
|
||||
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
|
||||
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
|
||||
// Fetch both branches and remotes in parallel
|
||||
const [branchResult, remotesResult] = await Promise.all([
|
||||
api.worktree.listBranches(projectPath, true, signal),
|
||||
api.worktree.listRemotes(projectPath),
|
||||
]);
|
||||
|
||||
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
|
||||
if (signal?.aborted) return;
|
||||
|
||||
// Process branches
|
||||
if (branchResult.success && branchResult.result) {
|
||||
setBranchFetchError(null);
|
||||
setAvailableBranches(
|
||||
@@ -147,6 +174,30 @@ export function CreateWorktreeDialog({
|
||||
setBranchFetchError(message);
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
}
|
||||
|
||||
// Process remotes
|
||||
if (remotesResult.success && remotesResult.result) {
|
||||
const remotes = remotesResult.result.remotes;
|
||||
setAvailableRemotes(
|
||||
remotes.map((r: { name: string; url: string; branches: unknown[] }) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}))
|
||||
);
|
||||
|
||||
// Build remote branches map for filtering
|
||||
const branchesMap = new Map<string, Array<{ name: string; fullRef: string }>>();
|
||||
remotes.forEach(
|
||||
(r: {
|
||||
name: string;
|
||||
url: string;
|
||||
branches: Array<{ name: string; fullRef: string }>;
|
||||
}) => {
|
||||
branchesMap.set(r.name, r.branches || []);
|
||||
}
|
||||
);
|
||||
setRemoteBranches(branchesMap);
|
||||
}
|
||||
} catch (err) {
|
||||
// If aborted, don't update state
|
||||
if (signal?.aborted) return;
|
||||
@@ -160,6 +211,8 @@ export function CreateWorktreeDialog({
|
||||
// and enable free-form entry (allowCreate) so the user can still type
|
||||
// any branch name when the remote list is unavailable.
|
||||
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||
setAvailableRemotes([]);
|
||||
setRemoteBranches(new Map());
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setIsLoadingBranches(false);
|
||||
@@ -198,27 +251,30 @@ export function CreateWorktreeDialog({
|
||||
setAvailableBranches([]);
|
||||
setBranchFetchError(null);
|
||||
setIsLoadingBranches(false);
|
||||
setSelectedRemote('local');
|
||||
setAvailableRemotes([]);
|
||||
setRemoteBranches(new Map());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Build branch name list for the autocomplete, with local branches first then remote
|
||||
// Build branch name list for the autocomplete, filtered by selected remote
|
||||
const branchNames = useMemo(() => {
|
||||
const local: string[] = [];
|
||||
const remote: string[] = [];
|
||||
|
||||
for (const b of availableBranches) {
|
||||
if (b.isRemote) {
|
||||
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||
if (!b.name.includes('/')) continue;
|
||||
remote.push(b.name);
|
||||
} else {
|
||||
local.push(b.name);
|
||||
}
|
||||
// If "local" is selected, show only local branches
|
||||
if (selectedRemote === 'local') {
|
||||
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
|
||||
}
|
||||
|
||||
// Local branches first, then remote branches
|
||||
return [...local, ...remote];
|
||||
}, [availableBranches]);
|
||||
// If a specific remote is selected, show only branches from that remote
|
||||
const remoteBranchList = remoteBranches.get(selectedRemote);
|
||||
if (remoteBranchList) {
|
||||
return remoteBranchList.map((b) => b.fullRef);
|
||||
}
|
||||
|
||||
// Fallback: filter from available branches by remote prefix
|
||||
return availableBranches
|
||||
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
|
||||
.map((b) => b.name);
|
||||
}, [availableBranches, selectedRemote, remoteBranches]);
|
||||
|
||||
// Determine if the selected base branch is a remote branch.
|
||||
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||
@@ -418,6 +474,47 @@ export function CreateWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Selector */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="remote-select" className="text-xs text-muted-foreground">
|
||||
Source
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRemote}
|
||||
onValueChange={(value) => {
|
||||
setSelectedRemote(value);
|
||||
// Clear base branch when switching remotes
|
||||
setBaseBranch('');
|
||||
}}
|
||||
disabled={isLoadingBranches}
|
||||
>
|
||||
<SelectTrigger id="remote-select" className="h-8">
|
||||
<SelectValue placeholder="Select source..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
<span>Local Branches</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{availableRemotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
<span>{remote.name}</span>
|
||||
{remote.url && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
({remote.url})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<BranchAutocomplete
|
||||
value={baseBranch}
|
||||
onChange={(value) => {
|
||||
@@ -425,9 +522,13 @@ export function CreateWorktreeDialog({
|
||||
setError(null);
|
||||
}}
|
||||
branches={branchNames}
|
||||
placeholder="Select base branch (default: HEAD)..."
|
||||
placeholder={
|
||||
selectedRemote === 'local'
|
||||
? 'Select local branch (default: HEAD)...'
|
||||
: `Select branch from ${selectedRemote}...`
|
||||
}
|
||||
disabled={isLoadingBranches}
|
||||
allowCreate={!!branchFetchError}
|
||||
allowCreate={!!branchFetchError || selectedRemote === 'local'}
|
||||
/>
|
||||
|
||||
{isRemoteBaseBranch && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AddFeatureDialog } from './add-feature-dialog';
|
||||
export { QuickAddDialog } from './quick-add-dialog';
|
||||
export { AgentOutputModal } from './agent-output-modal';
|
||||
export { BacklogPlanDialog } from './backlog-plan-dialog';
|
||||
export { CompletedFeaturesModal } from './completed-features-modal';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Play, Plus } from 'lucide-react';
|
||||
import type { PhaseModelEntry } from '@automaker/types';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface QuickAddDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (description: string, modelEntry: PhaseModelEntry) => void;
|
||||
onAddAndStart: (description: string, modelEntry: PhaseModelEntry) => void;
|
||||
}
|
||||
|
||||
export function QuickAddDialog({ open, onOpenChange, onAdd, onAddAndStart }: QuickAddDialogProps) {
|
||||
const [description, setDescription] = useState('');
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Get default feature model from store
|
||||
const defaultFeatureModel = useAppStore((s) => s.defaultFeatureModel);
|
||||
const currentProject = useAppStore((s) => s.currentProject);
|
||||
|
||||
// Use project-level default feature model if set, otherwise fall back to global
|
||||
const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel;
|
||||
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(
|
||||
effectiveDefaultFeatureModel || { model: 'claude-opus' }
|
||||
);
|
||||
|
||||
// Reset form when dialog opens (in useEffect to avoid state mutation during render)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDescription('');
|
||||
setDescriptionError(false);
|
||||
setModelEntry(effectiveDefaultFeatureModel || { model: 'claude-opus' });
|
||||
}
|
||||
}, [open, effectiveDefaultFeatureModel]);
|
||||
|
||||
const handleSubmit = (actionFn: (description: string, modelEntry: PhaseModelEntry) => void) => {
|
||||
if (!description.trim()) {
|
||||
setDescriptionError(true);
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
actionFn(description.trim(), modelEntry);
|
||||
setDescription('');
|
||||
setDescriptionError(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleAdd = () => handleSubmit(onAdd);
|
||||
const handleAddAndStart = () => handleSubmit(onAddAndStart);
|
||||
|
||||
const handleDescriptionChange = (value: string) => {
|
||||
setDescription(value);
|
||||
if (value.trim()) {
|
||||
setDescriptionError(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
compact
|
||||
className="sm:max-w-md"
|
||||
data-testid="quick-add-dialog"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quick Add Feature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new feature with minimal configuration. All other settings use defaults.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Description Input */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="quick-add-description" className="text-sm font-medium">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="quick-add-description"
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Describe what you want to build..."
|
||||
className={
|
||||
descriptionError ? 'border-destructive focus-visible:ring-destructive' : ''
|
||||
}
|
||||
rows={3}
|
||||
data-testid="quick-add-description-input"
|
||||
/>
|
||||
{descriptionError && (
|
||||
<p className="text-xs text-destructive">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<PhaseModelSelector value={modelEntry} onChange={setModelEntry} compact align="end" />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleAdd} data-testid="quick-add-button">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleAddAndStart}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="quick-add-and-start-button"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Make
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user