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:
gsxdsm
2026-02-22 20:48:09 -08:00
committed by GitHub
parent 9305ecc242
commit e7504b247f
70 changed files with 3141 additions and 560 deletions

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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';

View File

@@ -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>
);
}