Merge branch 'main' into feature-dependency-improvements

This commit is contained in:
Cody Seibert
2025-12-17 00:23:59 -05:00
98 changed files with 13081 additions and 767 deletions

View File

@@ -0,0 +1,223 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AutocompleteOption {
value: string;
label?: string;
badge?: string;
isDefault?: boolean;
}
interface AutocompleteProps {
value: string;
onChange: (value: string) => void;
options: (string | AutocompleteOption)[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
disabled?: boolean;
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
"data-testid"?: string;
itemTestIdPrefix?: string;
}
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
if (typeof opt === "string") {
return { value: opt, label: opt };
}
return { ...opt, label: opt.label ?? opt.value };
}
export function Autocomplete({
value,
onChange,
options,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyMessage = "No results found.",
className,
disabled = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
"data-testid": testId,
itemTestIdPrefix = "option",
}: AutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const normalizedOptions = React.useMemo(
() => options.map(normalizeOption),
[options]
);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
// Filter options based on input
const filteredOptions = React.useMemo(() => {
if (!inputValue) return normalizedOptions;
const lower = inputValue.toLowerCase();
return normalizedOptions.filter(
(opt) =>
opt.value.toLowerCase().includes(lower) ||
opt.label?.toLowerCase().includes(lower)
);
}, [normalizedOptions, inputValue]);
// Check if user typed a new value that doesn't exist
const isNewValue =
allowCreate &&
inputValue.trim() &&
!normalizedOptions.some(
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
);
// Get display value
const displayValue = React.useMemo(() => {
if (!value) return null;
const found = normalizedOptions.find((opt) => opt.value === value);
return found?.label ?? value;
}, [value, normalizedOptions]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
className
)}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate">
{Icon && (
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{displayValue || placeholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 200),
}}
data-testid={testId ? `${testId}-list` : undefined}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={searchPlaceholder}
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{isNewValue ? (
<div className="py-2 px-3 text-sm">
Press enter to create{" "}
<code className="bg-muted px-1 rounded">{inputValue}</code>
</div>
) : (
emptyMessage
)}
</CommandEmpty>
<CommandGroup>
{/* Show "Create new" option if typing a new value */}
{isNewValue && (
<CommandItem
value={inputValue}
onSelect={() => {
onChange(inputValue);
setInputValue("");
setOpen(false);
}}
className="text-[var(--status-success)]"
data-testid={`${itemTestIdPrefix}-create-new`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{createLabel(inputValue)}
<span className="ml-auto text-xs text-muted-foreground">
(new)
</span>
</CommandItem>
)}
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setInputValue("");
setOpen(false);
}}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{option.label}
<Check
className={cn(
"ml-auto",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.badge && (
<span className="ml-2 text-xs text-muted-foreground">
({option.badge})
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import { GitBranch } from "lucide-react";
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
interface BranchAutocompleteProps {
value: string;
onChange: (value: string) => void;
branches: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function BranchAutocomplete({
value,
onChange,
branches,
placeholder = "Select a branch...",
className,
disabled = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
return Array.from(branchSet).map((branch) => ({
value: branch,
label: branch,
badge: branch === "main" ? "default" : undefined,
}));
}, [branches]);
return (
<Autocomplete
value={value}
onChange={onChange}
options={branchOptions}
placeholder={placeholder}
searchPlaceholder="Search or type new branch..."
emptyMessage="No branches found."
className={className}
disabled={disabled}
icon={GitBranch}
allowCreate
createLabel={(v) => `Create "${v}"`}
data-testid={testId}
itemTestIdPrefix="branch-option"
/>
);
}

View File

@@ -1,23 +1,7 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Autocomplete } from "@/components/ui/autocomplete";
interface CategoryAutocompleteProps {
value: string;
@@ -38,81 +22,18 @@ export function CategoryAutocomplete({
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
// Listen for resize events to handle responsive behavior
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
data-testid={testId}
>
{value
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 200),
}}
>
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Autocomplete
value={value}
onChange={onChange}
options={suggestions}
placeholder={placeholder}
searchPlaceholder="Search category..."
emptyMessage="No category found."
className={className}
disabled={disabled}
data-testid={testId}
itemTestIdPrefix="category-option"
/>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { useAppStore, type AgentModel } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
@@ -18,6 +18,7 @@ import {
Paperclip,
X,
ImageIcon,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
@@ -29,6 +30,13 @@ import {
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
@@ -41,6 +49,7 @@ export function AgentView() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -66,6 +75,7 @@ export function AgentView() {
} = useElectronAgent({
sessionId: currentSessionId || "",
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000);
@@ -501,6 +511,43 @@ export function AgentView() {
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">
{model.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />

View File

@@ -10,6 +10,7 @@ import {
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -30,6 +31,12 @@ import {
FeatureSuggestionsDialog,
FollowUpDialog,
} from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreeSelector } from "./board-view/components";
import { COLUMNS } from "./board-view/constants";
import {
useBoardFeatures,
@@ -44,6 +51,11 @@ import {
useSuggestionsState,
} from "./board-view/hooks";
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
export function BoardView() {
const {
currentProject,
@@ -56,6 +68,10 @@ export function BoardView() {
setKanbanCardDetailLevel,
specCreatingForProject,
setSpecCreatingForProject,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
setWorktrees,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -81,6 +97,24 @@ export function BoardView() {
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
} | null>(null);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Follow-up state hook
const {
showFollowUpDialog,
@@ -186,32 +220,62 @@ export function BoardView() {
return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback(
(args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
// Branch suggestions for the branch autocomplete
// Shows all local branches as suggestions, but users can type any new branch name
// When the feature is started, a worktree will be created if needed
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
// If we found a column collision, use that
if (columnCollisions.length > 0) {
return columnCollisions;
// Fetch branches when project changes or worktrees are created/modified
useEffect(() => {
const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
}
// Otherwise, use rectangle intersection for cards
return rectIntersection(args);
},
[]
);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
setBranchSuggestions([]);
return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
// If we found a column collision, use that
if (columnCollisions.length > 0) {
return columnCollisions;
}
// Otherwise, use rectangle intersection for cards
return rectIntersection(args);
}, []);
// Use persistence hook
const {
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
} = useBoardPersistence({ currentProject });
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -221,6 +285,27 @@ export function BoardView() {
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path and branch) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
currentProject
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
// Extract all action handlers into a hook
const {
handleAddFeature,
@@ -234,7 +319,6 @@ export function BoardView() {
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleRevertFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,
@@ -265,6 +349,9 @@ export function BoardView() {
setShowFollowUpDialog,
inProgressFeaturesForShortcuts,
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
currentWorktreeBranch,
});
// Use keyboard shortcuts hook (after actions hook)
@@ -283,6 +370,8 @@ export function BoardView() {
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use column features hook
@@ -290,6 +379,9 @@ export function BoardView() {
features: hookFeatures,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath: currentProject?.path || null,
});
// Use background hook
@@ -341,6 +433,35 @@ export function BoardView() {
isMounted={isMounted}
/>
{/* Worktree Selector */}
<WorktreeSelector
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({
id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
@@ -383,8 +504,6 @@ export function BoardView() {
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onRevert={handleRevertFeature}
onMerge={handleMergeFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
featuresWithContext={featuresWithContext}
@@ -430,7 +549,9 @@ export function BoardView() {
onOpenChange={setShowAddDialog}
onAdd={handleAddFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -442,6 +563,7 @@ export function BoardView() {
onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -494,6 +616,101 @@ export function BoardView() {
isGenerating={isGeneratingSuggestions}
setIsGenerating={setIsGeneratingSuggestions}
/>
{/* Create Worktree Dialog */}
<CreateWorktreeDialog
open={showCreateWorktreeDialog}
onOpenChange={setShowCreateWorktreeDialog}
projectPath={currentProject.path}
onCreated={(newWorktree) => {
// Add the new worktree to the store immediately to avoid race condition
// when deriving currentWorktreeBranch for filtering
const currentWorktrees = getWorktrees(currentProject.path);
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
// Now set the current worktree with both path and branch
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
}}
/>
{/* Delete Worktree Dialog */}
<DeleteWorktreeDialog
open={showDeleteWorktreeDialog}
onOpenChange={setShowDeleteWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree
hookFeatures.forEach((feature) => {
const matchesByPath =
feature.worktreePath &&
pathsEqual(feature.worktreePath, deletedWorktree.path);
const matchesByBranch =
feature.branchName === deletedWorktree.branch;
if (matchesByPath || matchesByBranch) {
// Reset the feature's worktree assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
worktreePath: null as unknown as string | undefined,
});
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Commit Worktree Dialog */}
<CommitWorktreeDialog
open={showCommitWorktreeDialog}
onOpenChange={setShowCommitWorktreeDialog}
worktree={selectedWorktreeForAction}
onCommitted={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create PR Dialog */}
<CreatePRDialog
open={showCreatePRDialog}
onOpenChange={setShowCreatePRDialog}
worktree={selectedWorktreeForAction}
onCreated={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create Branch Dialog */}
<CreateBranchDialog
open={showCreateBranchDialog}
onOpenChange={setShowCreateBranchDialog}
worktree={selectedWorktreeForAction}
onCreated={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
</div>
);
}

View File

@@ -1,2 +1,3 @@
export { KanbanCard } from "./kanban-card";
export { KanbanColumn } from "./kanban-column";
export { WorktreeSelector } from "./worktree-selector";

View File

@@ -52,8 +52,6 @@ import {
MoreVertical,
AlertCircle,
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
Brain,
@@ -105,8 +103,6 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void;
onComplete?: () => void;
hasContext?: boolean;
@@ -132,8 +128,6 @@ export const KanbanCard = memo(function KanbanCard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
onImplement,
onComplete,
hasContext,
@@ -148,13 +142,10 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel, enableDependencyBlocking, features } = useAppStore();
const hasWorktree = !!feature.branchName;
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
@@ -366,8 +357,8 @@ export const KanbanCard = memo(function KanbanCard({
{feature.priority === 1
? "High Priority"
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? "Medium Priority"
: "Low Priority"}
</p>
</TooltipContent>
</Tooltip>
@@ -477,45 +468,13 @@ export const KanbanCard = memo(function KanbanCard({
</div>
)}
{/* Branch badge */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
feature.priority
? "top-11 left-2"
: feature.error || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">
{feature.branchName}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<CardHeader
className={cn(
"p-3 pb-2 block",
feature.priority && "pt-12",
!feature.priority &&
(feature.error || isJustFinished) &&
"pt-10",
hasWorktree &&
(feature.error || isJustFinished) &&
"pt-14"
(feature.skipTests || feature.error || isJustFinished) &&
"pt-10"
)}
>
{isCurrentAutoTask && (
@@ -724,6 +683,16 @@ export const KanbanCard = memo(function KanbanCard({
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Target Branch Display */}
{useWorktrees && feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}>
{feature.branchName}
</span>
</div>
)}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5">
@@ -912,9 +881,9 @@ export const KanbanCard = memo(function KanbanCard({
<>
{onViewOutput && (
<Button
variant="default"
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -926,7 +895,7 @@ export const KanbanCard = memo(function KanbanCard({
Logs
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20"
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
@@ -1018,7 +987,7 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Logs button - styled like Refine */}
{/* Logs button */}
{onViewOutput && (
<Button
variant="secondary"
@@ -1056,30 +1025,6 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Refine prompt button */}
{onFollowUp && (
<Button
@@ -1097,24 +1042,7 @@ export const KanbanCard = memo(function KanbanCard({
<span className="truncate">Refine</span>
</Button>
)}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{!hasWorktree && onCommit && (
{onCommit && (
<Button
variant="default"
size="sm"
@@ -1223,54 +1151,6 @@ export const KanbanCard = memo(function KanbanCard({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-[var(--status-error)] font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);

View File

@@ -0,0 +1,833 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
Plus,
Trash2,
MoreHorizontal,
RefreshCw,
GitCommit,
GitPullRequest,
ExternalLink,
ChevronDown,
Download,
Upload,
GitBranchPlus,
Check,
Search,
Play,
Square,
Globe,
Loader2,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, pathsEqual, normalizePath } from "@/lib/utils";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
interface FeatureInfo {
id: string;
worktreePath?: string;
branchName?: string; // Used as fallback to determine which worktree the spinner should show on
}
interface WorktreeSelectorProps {
projectPath: string;
onCreateWorktree: () => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
/** Increment this to trigger a refresh without unmounting the component */
refreshTrigger?: number;
}
export function WorktreeSelector({
projectPath,
onCreateWorktree,
onDeleteWorktree,
onCommit,
onCreatePR,
onCreateBranch,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
}: WorktreeSelectorProps) {
const [isLoading, setIsLoading] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [runningDevServers, setRunningDevServers] = useState<
Map<string, DevServerInfo>
>(new Map());
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
} catch (error) {
console.error("Failed to fetch worktrees:", error);
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
const fetchDefaultEditor = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getDefaultEditor) {
return;
}
const result = await api.worktree.getDefaultEditor();
if (result.success && result.result?.editorName) {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
}
}, []);
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
} finally {
setIsLoadingBranches(false);
}
}, []);
useEffect(() => {
fetchWorktrees();
fetchDevServers();
fetchDefaultEditor();
}, [fetchWorktrees, fetchDevServers, fetchDefaultEditor]);
// Refresh when refreshTrigger changes (but skip the initial render)
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
}
}, [refreshTrigger, fetchWorktrees]);
// Initialize selection to main if not set OR if the stored worktree no longer exists
// This handles stale data (e.g., a worktree that was deleted)
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
// Check if the currently selected worktree still exists
// null path means main (which always exists if worktrees has items)
// Non-null path means we need to verify it exists in the worktrees list
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
// Reset to main if:
// 1. No worktree is set (currentWorktree is null/undefined)
// 2. Current worktree has a path that doesn't exist in the list (stale data)
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
// Simply select the worktree in the UI with both path and branch
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
};
const handleStartDevServer = async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
};
const handleStopDevServer = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
// Update running servers map (normalize path for cross-platform compatibility)
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
};
const handleOpenDevServerUrl = (worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
};
// Helper to get the path key for a worktree (for looking up in runningDevServers)
// Normalizes path for cross-platform compatibility
const getWorktreeKey = (worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
};
// Helper to check if a worktree has running features
const hasRunningFeatures = (worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
const worktreeKey = getWorktreeKey(worktree);
// Check if any running feature belongs to this worktree
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
// First, check if worktreePath is set and matches
// Use pathsEqual for cross-platform compatibility (Windows uses backslashes)
if (feature.worktreePath) {
if (worktree.isMain) {
// Feature has worktreePath - show on main only if it matches projectPath
return pathsEqual(feature.worktreePath, projectPath);
}
// For non-main worktrees, check if worktreePath matches
return pathsEqual(feature.worktreePath, worktreeKey);
}
// If worktreePath is not set, use branchName as fallback
if (feature.branchName) {
// Feature has a branchName - show spinner on the worktree with matching branch
return worktree.branch === feature.branchName;
}
// No worktreePath and no branchName - default to main
return worktree.isMain;
});
};
const handleOpenInEditor = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
};
const handleSwitchBranch = async (
worktree: WorktreeInfo,
branchName: string
) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh worktrees to get updated branch info
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsSwitching(false);
}
};
const handlePull = async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh worktrees to get updated status
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
} finally {
setIsPulling(false);
}
};
const handlePush = async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
// Refresh to update ahead/behind counts
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
} finally {
setIsPushing(false);
}
};
// The "selected" worktree is based on UI state, not git's current branch
// currentWorktree.path is null for main, or the worktree path for others
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
// Render a worktree tab with branch selector (for main) and actions dropdown
const renderWorktreeTab = (worktree: WorktreeInfo) => {
// Selection is based on UI state, not git's current branch
// Default to main selected if currentWorktree is null/undefined or path is null
const isSelected = worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
const isRunning = hasRunningFeatures(worktree);
return (
<div key={worktree.path} className="flex items-center">
{/* Main branch: clickable button + separate branch switch dropdown */}
{worktree.isMain ? (
<>
{/* Clickable button to select/preview main */}
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
{/* Branch switch dropdown button */}
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
setBranchFilter("");
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">
Switch Branch
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Search input */}
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => setBranchFilter(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : (
(() => {
const filteredBranches = branches.filter((b) =>
b.name
.toLowerCase()
.includes(branchFilter.toLowerCase())
);
if (filteredBranches.length === 0) {
return (
<DropdownMenuItem disabled className="text-xs">
{branchFilter
? "No matching branches"
: "No branches found"}
</DropdownMenuItem>
);
}
return filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() =>
handleSwitchBranch(worktree, branch.name)
}
disabled={
isSwitching || branch.name === worktree.branch
}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
));
})()
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
// Non-main branches - click to switch to this branch
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
)}
{/* Dev server indicator */}
{runningDevServers.has(getWorktreeKey(worktree)) && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => handleOpenDevServerUrl(worktree)}
title={`Open dev server (port ${
runningDevServers.get(getWorktreeKey(worktree))?.port
})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
{/* Actions dropdown */}
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Dev server controls */}
{runningDevServers.has(getWorktreeKey(worktree)) ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:
{runningDevServers.get(getWorktreeKey(worktree))?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => handleStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Pull option */}
<DropdownMenuItem
onClick={() => handlePull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
{/* Push option */}
<DropdownMenuItem
onClick={() => handlePush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Open in editor */}
<DropdownMenuItem
onClick={() => handleOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Commit changes */}
{worktree.hasChanges && (
<DropdownMenuItem
onClick={() => onCommit(worktree)}
className="text-xs"
>
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
)}
{/* Show PR option if not on main branch, or if on main with changes */}
{(worktree.branch !== "main" || worktree.hasChanges) && (
<DropdownMenuItem
onClick={() => onCreatePR(worktree)}
className="text-xs"
>
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{/* Only show delete for non-main worktrees */}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
// Don't render the worktree selector if the feature is disabled
if (!useWorktreesEnabled) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
{/* Worktree Tabs */}
<div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => renderWorktreeTab(worktree))}
{/* Add Worktree Button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
{/* Refresh Button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={fetchWorktrees}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>
);
}

View File

@@ -14,12 +14,19 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical, Sparkles, ChevronDown } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
@@ -56,10 +63,13 @@ interface AddFeatureDialogProps {
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
defaultSkipTests: boolean;
defaultBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -70,7 +80,9 @@ export function AddFeatureDialog({
onOpenChange,
onAdd,
categorySuggestions,
branchSuggestions,
defaultSkipTests,
defaultBranch = "main",
isMaximized,
showProfilesOnly,
aiProfiles,
@@ -84,6 +96,7 @@ export function AddFeatureDialog({
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -91,20 +104,23 @@ export function AddFeatureDialog({
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
// Get enhancement model from store
const { enhancementModel } = useAppStore();
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
// Sync skipTests default when dialog opens
// Sync defaults when dialog opens
useEffect(() => {
if (open) {
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch,
}));
}
}, [open, defaultSkipTests]);
}, [open, defaultSkipTests, defaultBranch]);
const handleAdd = () => {
if (!newFeature.description.trim()) {
@@ -127,6 +143,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
priority: newFeature.priority,
});
@@ -141,6 +158,7 @@ export function AddFeatureDialog({
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: defaultBranch,
});
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
@@ -171,7 +189,7 @@ export function AddFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature(prev => ({ ...prev, description: enhancedText }));
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
@@ -194,7 +212,10 @@ export function AddFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
setNewFeature({
...newFeature,
model,
@@ -248,7 +269,10 @@ export function AddFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -273,25 +297,38 @@ export function AddFeatureDialog({
<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'}
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -321,6 +358,24 @@ export function AddFeatureDialog({
data-testid="feature-category-input"
/>
</div>
{useWorktrees && (
<div className="space-y-2">
<Label htmlFor="branch">Target Branch</Label>
<BranchAutocomplete
value={newFeature.branchName}
onChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
branches={branchSuggestions}
placeholder="Select or create branch..."
data-testid="feature-branch-input"
/>
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if
needed.
</p>
</div>
)}
{/* Priority Selector */}
<PrioritySelector
@@ -333,7 +388,10 @@ export function AddFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
<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">
@@ -396,16 +454,17 @@ export function AddFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto cursor-default">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
steps={newFeature.steps}
onStepsChange={(steps) =>
setNewFeature({ ...newFeature, steps })
}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { GitCommit, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCommitted: () => void;
}
export function CommitWorktreeDialog({
open,
onOpenChange,
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.commit(worktree.path, message);
if (result.success && result.result) {
if (result.result.committed) {
toast.success("Changes committed", {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
} else {
toast.info("No changes to commit", {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing...
</>
) : (
<>
<GitCommit className="w-4 h-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { GitBranchPlus, Loader2 } from "lucide-react";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreateBranchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
}
export function CreateBranchDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setBranchName("");
setError(null);
}
}, [open]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
return;
}
setIsCreating(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
if (result.success && result.result) {
toast.success(result.result.message);
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,375 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreatePRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
}
export function CreatePRDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
// These are set by the API response and should persist until dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}
}, [open, worktree?.path]);
const handleCreate = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.createPR(worktree.path, {
commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
});
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
onCreated();
} else {
// Branch was pushed successfully
const prError = result.result.prError;
const hasBrowserUrl = !!result.result.browserUrl;
// Check if we should show browser fallback
if (!result.result.prCreated && hasBrowserUrl) {
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return; // Don't close dialog, show browser fallback UI
}
// gh CLI is available but failed - show error with browser option
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
if (prError.includes("No commits between")) {
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
} else if (prError.includes("already exists")) {
errorMessage = "A pull request already exists for this branch.";
} else if (prError.includes("not logged in") || prError.includes("auth")) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return;
}
}
// Show success toast for push
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// No browser URL available, just close
if (!result.result.prCreated) {
if (!hasBrowserUrl) {
toast.info("PR not created", {
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
duration: 8000,
});
}
}
onCreated();
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after dialog closes
setTimeout(() => {
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}, 200);
};
if (!worktree) return null;
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Create Pull Request
</DialogTitle>
<DialogDescription>
Push changes and create a pull request from{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</DialogDescription>
</DialogHeader>
{prUrl ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
<GitPullRequest className="w-8 h-8 text-green-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your PR is ready for review
</p>
</div>
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
</div>
) : shouldShowBrowserFallback ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
<GitPullRequest className="w-8 h-8 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your changes have been pushed to GitHub.
<br />
Click below to create a pull request in your browser.
</p>
</div>
<div className="space-y-3">
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
}
}}
className="gap-2 w-full"
size="lg"
>
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
{browserUrl}
</div>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
</p>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</DialogFooter>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-4">
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
placeholder="Leave empty to auto-generate"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
</p>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<Input
id="pr-title"
placeholder={worktree.branch}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pr-body">Description</Label>
<Textarea
id="pr-body"
placeholder="Describe the changes in this PR..."
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[80px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="flex items-end">
<div className="flex items-center space-x-2">
<Checkbox
id="draft"
checked={isDraft}
onCheckedChange={(checked) => setIsDraft(checked === true)}
/>
<Label htmlFor="draft" className="cursor-pointer">
Create as draft
</Label>
</div>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface CreatedWorktreeInfo {
path: string;
branch: string;
}
interface CreateWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
onCreated: (worktree: CreatedWorktreeInfo) => void;
}
export function CreateWorktreeDialog({
open,
onOpenChange,
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError("Branch name is required");
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
);
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
toast.success(
`Worktree created for branch "${result.worktree.branch}"`,
{
description: result.worktree.isNew
? "New branch created"
: "Using existing branch",
}
);
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName("");
} else {
setError(result.error || "Failed to create worktree");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading && branchName.trim()) {
handleCreate();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside pl-2 space-y-0.5">
<li>
<code className="bg-muted px-1 rounded">feature/user-auth</code>
</li>
<li>
<code className="bg-muted px-1 rounded">fix/login-bug</code>
</li>
<li>
<code className="bg-muted px-1 rounded">hotfix/security-patch</code>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitBranch className="w-4 h-4 mr-2" />
Create Worktree
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface DeleteWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
}
export function DeleteWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onDeleted,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => {
if (!worktree) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
if (result.success) {
toast.success(`Worktree deleted`, {
description: deleteBranch
? `Branch "${worktree.branch}" was also deleted`
: `Branch "${worktree.branch}" was kept`,
});
onDeleted(worktree, deleteBranch);
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
description: result.error,
});
}
} catch (err) {
toast.error("Failed to delete worktree", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
Delete Worktree
</DialogTitle>
<DialogDescription className="space-y-3">
<span>
Are you sure you want to delete the worktree for branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
?
</span>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted
change(s). These will be lost if you proceed.
</span>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="delete-branch"
checked={deleteBranch}
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
/>
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
Also delete the branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</Label>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -14,12 +14,20 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical, Sparkles, ChevronDown, GitBranch } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
@@ -58,10 +66,12 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -73,6 +83,7 @@ export function EditFeatureDialog({
onClose,
onUpdate,
categorySuggestions,
branchSuggestions,
isMaximized,
showProfilesOnly,
aiProfiles,
@@ -83,11 +94,13 @@ export function EditFeatureDialog({
useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false);
// Get enhancement model from store
const { enhancementModel } = useAppStore();
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
useEffect(() => {
setEditingFeature(feature);
@@ -101,8 +114,10 @@ export function EditFeatureDialog({
if (!editingFeature) return;
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? "none")
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const updates = {
@@ -113,6 +128,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
priority: editingFeature.priority ?? 2,
};
@@ -139,7 +155,10 @@ export function EditFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -162,7 +181,9 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature(prev => prev ? { ...prev, description: enhancedText } : prev);
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
@@ -223,7 +244,10 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -250,25 +274,38 @@ export function EditFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-[180px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<Button
variant="outline"
size="sm"
className="w-[180px] 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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -301,6 +338,35 @@ export function EditFeatureDialog({
data-testid="edit-feature-category"
/>
</div>
{useWorktrees && (
<div className="space-y-2">
<Label htmlFor="edit-branch">Target Branch</Label>
<BranchAutocomplete
value={editingFeature.branchName ?? "main"}
onChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branches={branchSuggestions}
placeholder="Select or create branch..."
data-testid="edit-feature-branch"
disabled={editingFeature.status !== "backlog"}
/>
{editingFeature.status !== "backlog" && (
<p className="text-xs text-muted-foreground">
Branch cannot be changed after work has started.
</p>
)}
{editingFeature.status === "backlog" && (
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created
if needed.
</p>
)}
</div>
)}
{/* Priority Selector */}
<PrioritySelector
@@ -316,7 +382,10 @@ export function EditFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
<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">
@@ -382,7 +451,10 @@ export function EditFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto cursor-default">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>

View File

@@ -1,5 +1,11 @@
import { useCallback, useState } from "react";
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
import { useCallback } from "react";
import {
Feature,
FeatureImage,
AgentModel,
ThinkingLevel,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
@@ -13,7 +19,10 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -29,6 +38,9 @@ interface UseBoardActionsProps {
setShowFollowUpDialog: (show: boolean) => void;
inProgressFeaturesForShortcuts: Feature[];
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
}
export function useBoardActions({
@@ -53,12 +65,81 @@ export function useBoardActions({
setShowFollowUpDialog,
inProgressFeaturesForShortcuts,
outputFeature,
projectPath,
onWorktreeCreated,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees, enableDependencyBlocking } = useAppStore();
const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
enableDependencyBlocking,
} = useAppStore();
const autoMode = useAutoMode();
/**
* Get or create the worktree path for a feature based on its branchName.
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[BoardActions] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error(
"[BoardActions] Failed to create worktree:",
result.error
);
toast.error("Failed to create worktree", {
description:
result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[BoardActions] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleAddFeature = useCallback(
(featureData: {
async (featureData: {
category: string;
description: string;
steps: string[];
@@ -67,21 +148,41 @@ export function useBoardActions({
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => {
let worktreePath: string | undefined;
// If worktrees are enabled and a non-main branch is selected, create the worktree
if (useWorktrees && featureData.branchName) {
const branchName = featureData.branchName;
if (branchName !== "main" && branchName !== "master") {
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
const tempFeature = { branchName } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
const newFeatureData = {
...featureData,
status: "backlog" as const,
worktreePath,
};
const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
[addFeature, persistFeatureCreate, saveCategory]
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
);
const handleUpdateFeature = useCallback(
(
async (
featureId: string,
updates: {
category: string;
@@ -91,17 +192,57 @@ export function useBoardActions({
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => {
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
// Get the current feature to check if branch is changing
const currentFeature = features.find((f) => f.id === featureId);
const currentBranch = currentFeature?.branchName || "main";
const newBranch = updates.branchName || "main";
const branchIsChanging = currentBranch !== newBranch;
let worktreePath: string | undefined;
let shouldClearWorktreePath = false;
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
if (useWorktrees && branchIsChanging) {
if (newBranch === "main" || newBranch === "master") {
// Changing to main - clear the worktreePath
shouldClearWorktreePath = true;
} else {
// Changing to a feature branch - create worktree if needed
const tempFeature = { branchName: newBranch } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
// Build final updates with worktreePath if it was changed
let finalUpdates: typeof updates & { worktreePath?: string };
if (branchIsChanging && useWorktrees) {
if (shouldClearWorktreePath) {
// Use null to clear the value in persistence (cast to work around type system)
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
} else {
finalUpdates = { ...updates, worktreePath };
}
} else {
finalUpdates = updates;
}
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
if (updates.category) {
saveCategory(updates.category);
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
);
const handleDeleteFeature = useCallback(
@@ -115,7 +256,9 @@ export function useBoardActions({
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
@@ -133,11 +276,17 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
}
}
} catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
}
}
@@ -158,14 +307,22 @@ export function useBoardActions({
return;
}
// Use the feature's assigned worktreePath (set when moving to in_progress)
// This ensures work happens in the correct worktree based on the feature's branchName
const featureWorktreePath = feature.worktreePath;
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
useWorktrees,
featureWorktreePath || undefined
);
if (result.success) {
console.log("[Board] Feature run started successfully");
console.log(
"[Board] Feature run started successfully in worktree:",
featureWorktreePath || "main"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
await loadFeatures();
@@ -209,7 +366,8 @@ export function useBoardActions({
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature);
return true;
@@ -228,7 +386,10 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature verification started successfully");
@@ -255,7 +416,11 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id);
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
@@ -268,7 +433,7 @@ export function useBoardActions({
await loadFeatures();
}
},
[currentProject, loadFeatures]
[currentProject, loadFeatures, useWorktrees]
);
const handleManualVerify = useCallback(
@@ -279,7 +444,9 @@ export function useBoardActions({
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -294,7 +461,9 @@ export function useBoardActions({
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -307,7 +476,12 @@ export function useBoardActions({
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
);
const handleSendFollowUp = useCallback(async () => {
@@ -340,17 +514,28 @@ export function useBoardActions({
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
// Use the feature's worktreePath to ensure work happens in the correct branch
const featureWorktreePath = followUpFeature.worktreePath;
api.autoMode
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths)
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths,
featureWorktreePath
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
loadFeatures();
});
@@ -378,19 +563,29 @@ export function useBoardActions({
if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available");
toast.error("Commit not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.autoMode.commitFeature(currentProject.path, feature.id);
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id,
feature.worktreePath
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", {
@@ -401,49 +596,19 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures]
);
const handleRevertFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description: "This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(currentProject.path, feature.id);
if (result.success) {
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
toast.error("Failed to revert feature", {
description: result.error || "An error occurred",
});
}
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description: error instanceof Error ? error.message : "An error occurred",
});
}
},
[currentProject, loadFeatures]
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
);
const handleMergeFeature = useCallback(
@@ -455,17 +620,23 @@ export function useBoardActions({
if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available");
toast.error("Merge not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
if (result.success) {
await loadFeatures();
toast.success("Feature merged", {
description: `Changes merged to main branch: ${truncateDescription(feature.description)}`,
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
@@ -476,7 +647,8 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -507,7 +679,9 @@ export function useBoardActions({
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -536,7 +710,12 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
);
const handleForceStopFeature = useCallback(
@@ -551,19 +730,25 @@ export function useBoardActions({
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus });
// Must await to ensure file is written before user can restart
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
description:
targetStatus === "waiting_approval"
? `Stopped commit - returned to waiting approval: ${truncateDescription(feature.description)}`
: `Stopped working on: ${truncateDescription(feature.description)}`,
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -571,7 +756,32 @@ export function useBoardActions({
);
const handleStartNextFeatures = useCallback(async () => {
const backlogFeatures = features.filter((f) => f.status === "backlog");
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
// Determine the feature's branch (default to "main" if not set)
const featureBranch = f.branchName || "main";
// If no worktree is selected (currentWorktreeBranch is null or main-like),
// show features with no branch or "main"/"master" branch
if (
!currentWorktreeBranch ||
currentWorktreeBranch === "main" ||
currentWorktreeBranch === "master"
) {
return (
!f.branchName ||
featureBranch === "main" ||
featureBranch === "master"
);
}
// Otherwise, only show features matching the selected worktree branch
return featureBranch === currentWorktreeBranch;
});
const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
@@ -583,12 +793,56 @@ export function useBoardActions({
return;
}
const featuresToStart = backlogFeatures.slice(0, availableSlots);
if (backlogFeatures.length === 0) {
toast.info("Backlog empty", {
description:
currentWorktreeBranch &&
currentWorktreeBranch !== "main" &&
currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
});
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
// This matches the auto mode service behavior for consistency
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Start only one feature per keypress (user must press again for next)
const featuresToStart = sortedBacklog.slice(0, 1);
for (const feature of featuresToStart) {
await handleStartImplementation(feature);
// Only create worktrees if the feature is enabled
let worktreePath: string | null = null;
if (useWorktrees) {
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
worktreePath = await getOrCreateWorktreeForFeature(feature);
if (worktreePath) {
await persistFeatureUpdate(feature.id, { worktreePath });
}
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
// Start the implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({
...feature,
worktreePath: worktreePath || undefined,
});
}
}, [features, runningAutoTasks, handleStartImplementation]);
}, [
features,
runningAutoTasks,
handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
currentWorktreeBranch,
useWorktrees,
]);
const handleDeleteAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
@@ -599,10 +853,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before delete:",
error
);
console.error("[Board] Error stopping feature before delete:", error);
}
}
removeFeature(feature.id);
@@ -612,7 +863,13 @@ export function useBoardActions({
toast.success("All verified features deleted", {
description: `Deleted ${verifiedFeatures.length} feature(s).`,
});
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]);
}, [
features,
runningAutoTasks,
autoMode,
removeFeature,
persistFeatureDelete,
]);
return {
handleAddFeature,
@@ -626,7 +883,6 @@ export function useBoardActions({
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleRevertFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,

View File

@@ -1,6 +1,7 @@
import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { resolveDependencies } from "@/lib/dependency-resolver";
import { pathsEqual } from "@/lib/utils";
type ColumnId = Feature["status"];
@@ -8,12 +9,18 @@ interface UseBoardColumnFeaturesProps {
features: Feature[];
runningAutoTasks: string[];
searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
projectPath: string | null; // Main project path (for main worktree)
}
export function useBoardColumnFeatures({
features,
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
@@ -35,16 +42,63 @@ export function useBoardColumnFeatures({
)
: features;
// Determine the effective worktree path and branch for filtering
// If currentWorktreePath is null, we're on the main worktree
const effectiveWorktreePath = currentWorktreePath || projectPath;
// Use the branch name from the selected worktree
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
// should contain the main branch's actual name, defaulting to "main"
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
// In that case, we can't do branch-based filtering, so we'll handle it specially below
const effectiveBranch = currentWorktreeBranch;
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
// Check if feature matches the current worktree
// Match by worktreePath if set, OR by branchName if set
// Features with neither are considered unassigned (show on ALL worktrees)
const featureBranch = f.branchName || "main";
const hasWorktreeAssigned = f.worktreePath || f.branchName;
let matchesWorktree: boolean;
if (!hasWorktreeAssigned) {
// No worktree or branch assigned - show on ALL worktrees (unassigned)
matchesWorktree = true;
} else if (f.worktreePath) {
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
} else if (effectiveBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
// Show features assigned to main/master branch since we're on the main worktree.
matchesWorktree = featureBranch === "main" || featureBranch === "master";
} else {
// Has branchName but no worktreePath - match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
if (isRunning) {
map.in_progress.push(f);
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
if (map[status]) {
map[status].push(f);
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
map.backlog.push(f);
@@ -61,7 +115,7 @@ export function useBoardColumnFeatures({
}
return map;
}, [features, runningAutoTasks, searchQuery]);
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {

View File

@@ -4,6 +4,7 @@ import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
import { getElectronAPI } from "@/lib/electron";
interface UseBoardDragDropProps {
features: Feature[];
@@ -14,6 +15,8 @@ interface UseBoardDragDropProps {
updates: Partial<Feature>
) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
projectPath: string | null; // Main project path
onWorktreeCreated?: () => void; // Callback when a new worktree is created
}
export function useBoardDragDrop({
@@ -22,9 +25,66 @@ export function useBoardDragDrop({
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
projectPath,
onWorktreeCreated,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore();
const { moveFeature, useWorktrees } = useAppStore();
/**
* Get or create the worktree path for a feature based on its branchName.
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[DragDrop] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error("[DragDrop] Failed to create worktree:", result.error);
toast.error("Failed to create worktree", {
description: result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[DragDrop] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -97,8 +157,20 @@ export function useBoardDragDrop({
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
// Only create worktrees if the feature is enabled
let worktreePath: string | null = null;
if (useWorktrees) {
// Get or create worktree based on the feature's assigned branch
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
if (worktreePath) {
await persistFeatureUpdate(featureId, { worktreePath });
}
// Refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
}
// Use helper function to handle concurrency check and start implementation
await handleStartImplementation(draggedFeature);
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -123,10 +195,11 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
// Clear justFinishedAt timestamp when moving back to backlog
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
worktreePath: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -166,7 +239,8 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
// Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -189,7 +263,8 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
// Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -205,6 +280,9 @@ export function useBoardDragDrop({
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
getOrCreateWorktreeForFeature,
onWorktreeCreated,
useWorktrees,
]
);

View File

@@ -45,8 +45,6 @@ interface KanbanBoardProps {
onMoveBackToInProgress: (feature: Feature) => void;
onFollowUp: (feature: Feature) => void;
onCommit: (feature: Feature) => void;
onRevert: (feature: Feature) => void;
onMerge: (feature: Feature) => void;
onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void;
featuresWithContext: Set<string>;
@@ -77,8 +75,6 @@ export function KanbanBoard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
onComplete,
onImplement,
featuresWithContext,
@@ -191,8 +187,6 @@ export function KanbanBoard({
}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onRevert={() => onRevert(feature)}
onMerge={() => onMerge(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
hasContext={featuresWithContext.has(feature.id)}

View File

@@ -19,6 +19,7 @@ import {
BookOpen,
EditIcon,
Eye,
Pencil,
} from "lucide-react";
import {
useKeyboardShortcuts,
@@ -56,6 +57,8 @@ export function ContextView() {
const [editedContent, setEditedContent] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
@@ -240,6 +243,60 @@ export function ContextView() {
}
};
// Rename selected file
const handleRenameFile = async () => {
const contextPath = getContextPath();
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
const newName = renameFileName.trim();
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${contextPath}/${newName}`;
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error("A file with this name already exists");
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error("Failed to read file for rename");
return;
}
// Write to new path
await api.writeFile(newPath, result.content);
// Delete old file
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName("");
// Reload files and select the renamed file
await loadContextFiles();
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? "image" : "text",
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
console.error("Failed to rename file:", error);
}
};
// Handle image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -418,24 +475,40 @@ export function ContextView() {
) : (
<div className="space-y-1">
{contextFiles.map((file) => (
<button
<div
key={file.path}
onClick={() => handleSelectFile(file)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
<button
onClick={() => handleSelectFile(file)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
data-testid={`rename-context-file-${file.name}`}
>
<Pencil className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
@@ -730,6 +803,53 @@ export function ContextView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent data-testid="rename-context-dialog">
<DialogHeader>
<DialogTitle>Rename Context File</DialogTitle>
<DialogDescription>
Enter a new name for "{selectedFile?.name}".
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label htmlFor="rename-filename">File Name</Label>
<Input
id="rename-filename"
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === "Enter" && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName("");
}}
>
Cancel
</Button>
<Button
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
data-testid="confirm-rename-file"
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -137,21 +137,20 @@ export function FeatureDefaultsSection({
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true)
}
disabled={true}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground font-medium flex items-center gap-2"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
@@ -163,9 +162,6 @@ export function FeatureDefaultsSection({
Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory.
</p>
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
This feature is still under development and temporarily disabled.
</p>
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
WelcomeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
} from "./setup-view/steps";
// Main Setup View
@@ -19,12 +20,13 @@ export function SetupView() {
} = useSetupStore();
const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "complete"] as const;
const steps = ["welcome", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "github") return "github";
return "complete";
};
const currentIndex = steps.indexOf(getStepName());
@@ -42,6 +44,10 @@ export function SetupView() {
setCurrentStep("claude_detect");
break;
case "claude":
console.log("[Setup Flow] Moving to github step");
setCurrentStep("github");
break;
case "github":
console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete");
break;
@@ -54,12 +60,20 @@ export function SetupView() {
case "claude":
setCurrentStep("welcome");
break;
case "github":
setCurrentStep("claude_detect");
break;
}
};
const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true);
setCurrentStep("github");
};
const handleSkipGithub = () => {
console.log("[Setup Flow] Skipping GitHub setup");
setCurrentStep("complete");
};
@@ -110,6 +124,14 @@ export function SetupView() {
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}

View File

@@ -0,0 +1,333 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import {
CheckCircle2,
Loader2,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
RefreshCw,
AlertTriangle,
Github,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "../components";
interface GitHubSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function GitHubSetupStep({
onNext,
onBack,
onSkip,
}: GitHubSetupStepProps) {
const { ghCliStatus, setGhCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getGhStatus) {
return;
}
const result = await api.setup.getGhStatus();
if (result.success) {
setGhCliStatus({
installed: result.installed,
authenticated: result.authenticated,
version: result.version,
path: result.path,
user: result.user,
});
}
} catch (error) {
console.error("Failed to check gh status:", error);
} finally {
setIsChecking(false);
}
}, [setGhCliStatus]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
};
const isReady = ghCliStatus?.installed && ghCliStatus?.authenticated;
const getStatusBadge = () => {
if (isChecking) {
return <StatusBadge status="checking" label="Checking..." />;
}
if (ghCliStatus?.authenticated) {
return <StatusBadge status="authenticated" label="Ready" />;
}
if (ghCliStatus?.installed) {
return <StatusBadge status="unverified" label="Not Logged In" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Github className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
GitHub CLI Setup
</h2>
<p className="text-muted-foreground">
Optional - Used for creating pull requests
</p>
</div>
{/* Info Banner */}
<Card className="bg-amber-500/10 border-amber-500/20">
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-foreground">
This step is optional
</p>
<p className="text-sm text-muted-foreground mt-1">
The GitHub CLI allows you to create pull requests directly from
the app. Without it, you can still create PRs manually in your
browser.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Github className="w-5 h-5" />
GitHub CLI Status
</CardTitle>
<div className="flex items-center gap-2">
{getStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
<CardDescription>
{ghCliStatus?.installed
? ghCliStatus.authenticated
? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}`
: "Installed but not logged in"
: "Not installed on your system"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State */}
{isReady && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
GitHub CLI is ready!
</p>
<p className="text-sm text-muted-foreground">
You can create pull requests directly from the app.
{ghCliStatus?.version && (
<span className="ml-1">Version: {ghCliStatus.version}</span>
)}
</p>
</div>
</div>
)}
{/* Not Installed */}
{!ghCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not found
</p>
<p className="text-sm text-muted-foreground mt-1">
Install the GitHub CLI to enable PR creation from the app.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">
Installation Commands:
</p>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">macOS (Homebrew)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
brew install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("brew install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Windows (winget)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
winget install GitHub.cli
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("winget install GitHub.cli")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Linux (apt)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
sudo apt install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("sudo apt install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<a
href="https://cli.github.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
>
View all installation options
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{/* Installed but not authenticated */}
{ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not logged in
</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate with GitHub.
</p>
</div>
</div>
<div className="space-y-2 p-4 rounded-lg bg-muted/30 border border-border">
<p className="text-sm text-muted-foreground">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
gh auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("gh auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Checking GitHub CLI status...
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
{isReady ? "Skip" : "Skip for now"}
</Button>
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="github-next-button"
>
{isReady ? "Continue" : "Continue without GitHub CLI"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,3 +2,4 @@
export { WelcomeStep } from "./welcome-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";

View File

@@ -7,6 +7,7 @@ import { getElectronAPI } from "@/lib/electron";
interface UseElectronAgentOptions {
sessionId: string;
workingDirectory?: string;
model?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void;
}
@@ -33,6 +34,7 @@ interface UseElectronAgentResult {
export function useElectronAgent({
sessionId,
workingDirectory,
model,
onToolUse,
}: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState<Message[]>([]);
@@ -88,7 +90,8 @@ export function useElectronAgent({
sessionId,
content,
workingDirectory,
imagePaths
imagePaths,
model
);
if (!result.success) {
@@ -104,7 +107,7 @@ export function useElectronAgent({
throw err;
}
},
[sessionId, workingDirectory, isProcessing]
[sessionId, workingDirectory, model, isProcessing]
);
// Message queue for queuing messages when agent is busy
@@ -344,7 +347,8 @@ export function useElectronAgent({
sessionId,
content,
workingDirectory,
imagePaths
imagePaths,
model
);
if (!result.success) {
@@ -359,7 +363,7 @@ export function useElectronAgent({
setIsProcessing(false);
}
},
[sessionId, workingDirectory, isProcessing]
[sessionId, workingDirectory, model, isProcessing]
);
// Stop current execution

View File

@@ -68,6 +68,13 @@ function isInputFocused(): boolean {
return true;
}
// Check for any open dropdown menus (Radix UI uses role="menu")
// This prevents shortcuts from firing when user is typing in dropdown filters
const dropdownMenu = document.querySelector('[role="menu"]');
if (dropdownMenu) {
return true;
}
return false;
}

View File

@@ -158,7 +158,10 @@ export interface SpecRegenerationAPI {
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>;
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
generateFeatures: (
projectPath: string,
maxFeatures?: number
) => Promise<{
success: boolean;
error?: string;
}>;
@@ -224,7 +227,8 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
verifyFeature: (
projectPath: string,
@@ -232,7 +236,8 @@ export interface AutoModeAPI {
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
resumeFeature: (
projectPath: string,
featureId: string
featureId: string,
useWorktrees?: boolean
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
contextExists: (
projectPath: string,
@@ -245,11 +250,13 @@ export interface AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
commitFeature: (
projectPath: string,
featureId: string
featureId: string,
worktreePath?: string
) => Promise<{ success: boolean; error?: string }>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}
@@ -317,7 +324,11 @@ export interface ElectronAPI {
features?: FeaturesAPI;
runningAgents?: RunningAgentsAPI;
enhancePrompt?: {
enhance: (originalText: string, enhancementMode: string, model?: string) => Promise<{
enhance: (
originalText: string,
enhancementMode: string,
model?: string
) => Promise<{
success: boolean;
enhancedText?: string;
error?: string;
@@ -384,6 +395,15 @@ export interface ElectronAPI {
authenticated: boolean;
error?: string;
}>;
getGhStatus?: () => Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
};
@@ -400,7 +420,8 @@ export interface ElectronAPI {
sessionId: string,
message: string,
workingDirectory?: string,
imagePaths?: string[]
imagePaths?: string[],
model?: string
) => Promise<{ success: boolean; error?: string }>;
getHistory: (sessionId: string) => Promise<{
success: boolean;
@@ -906,6 +927,15 @@ interface SetupAPI {
authenticated: boolean;
error?: string;
}>;
getGhStatus?: () => Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
}
@@ -992,6 +1022,18 @@ function createMockSetupAPI(): SetupAPI {
};
},
getGhStatus: async () => {
console.log("[Mock] Getting GitHub CLI status");
return {
success: true,
installed: false,
authenticated: false,
version: null,
path: null,
user: null,
};
},
onInstallProgress: (callback) => {
// Mock progress events
return () => {};
@@ -1007,11 +1049,6 @@ function createMockSetupAPI(): SetupAPI {
// Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI {
return {
revertFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Reverting feature:", { projectPath, featureId });
return { success: true, removedPath: `/mock/worktree/${featureId}` };
},
mergeFeature: async (
projectPath: string,
featureId: string,
@@ -1057,6 +1094,106 @@ function createMockWorktreeAPI(): WorktreeAPI {
return { success: true, worktrees: [] };
},
listAll: async (projectPath: string, includeDetails?: boolean) => {
console.log("[Mock] Listing all worktrees:", {
projectPath,
includeDetails,
});
return {
success: true,
worktrees: [
{
path: projectPath,
branch: "main",
isMain: true,
isCurrent: true,
hasWorktree: true,
hasChanges: false,
changedFilesCount: 0,
},
],
};
},
create: async (
projectPath: string,
branchName: string,
baseBranch?: string
) => {
console.log("[Mock] Creating worktree:", {
projectPath,
branchName,
baseBranch,
});
return {
success: true,
worktree: {
path: `${projectPath}/.worktrees/${branchName}`,
branch: branchName,
isNew: true,
},
};
},
delete: async (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) => {
console.log("[Mock] Deleting worktree:", {
projectPath,
worktreePath,
deleteBranch,
});
return {
success: true,
deleted: {
worktreePath,
branch: deleteBranch ? "feature-branch" : null,
},
};
},
commit: async (worktreePath: string, message: string) => {
console.log("[Mock] Committing changes:", { worktreePath, message });
return {
success: true,
result: {
committed: true,
commitHash: "abc123",
branch: "feature-branch",
message,
},
};
},
push: async (worktreePath: string, force?: boolean) => {
console.log("[Mock] Pushing worktree:", { worktreePath, force });
return {
success: true,
result: {
branch: "feature-branch",
pushed: true,
message: "Successfully pushed to origin/feature-branch",
},
};
},
createPR: async (worktreePath: string, options?: any) => {
console.log("[Mock] Creating PR:", { worktreePath, options });
return {
success: true,
result: {
branch: "feature-branch",
committed: true,
commitHash: "abc123",
pushed: true,
prUrl: "https://github.com/example/repo/pull/1",
prCreated: true,
},
};
},
getDiffs: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting file diffs:", { projectPath, featureId });
return {
@@ -1086,6 +1223,129 @@ function createMockWorktreeAPI(): WorktreeAPI {
filePath,
};
},
pull: async (worktreePath: string) => {
console.log("[Mock] Pulling latest changes for:", worktreePath);
return {
success: true,
result: {
branch: "main",
pulled: true,
message: "Pulled latest changes",
},
};
},
checkoutBranch: async (worktreePath: string, branchName: string) => {
console.log("[Mock] Creating and checking out branch:", {
worktreePath,
branchName,
});
return {
success: true,
result: {
previousBranch: "main",
newBranch: branchName,
message: `Created and checked out branch '${branchName}'`,
},
};
},
listBranches: async (worktreePath: string) => {
console.log("[Mock] Listing branches for:", worktreePath);
return {
success: true,
result: {
currentBranch: "main",
branches: [
{ name: "main", isCurrent: true, isRemote: false },
{ name: "develop", isCurrent: false, isRemote: false },
{ name: "feature/example", isCurrent: false, isRemote: false },
],
aheadCount: 2,
behindCount: 0,
},
};
},
switchBranch: async (worktreePath: string, branchName: string) => {
console.log("[Mock] Switching to branch:", { worktreePath, branchName });
return {
success: true,
result: {
previousBranch: "main",
currentBranch: branchName,
message: `Switched to branch '${branchName}'`,
},
};
},
openInEditor: async (worktreePath: string) => {
console.log("[Mock] Opening in editor:", worktreePath);
return {
success: true,
result: {
message: `Opened ${worktreePath} in VS Code`,
editorName: "VS Code",
},
};
},
getDefaultEditor: async () => {
console.log("[Mock] Getting default editor");
return {
success: true,
result: {
editorName: "VS Code",
editorCommand: "code",
},
};
},
initGit: async (projectPath: string) => {
console.log("[Mock] Initializing git:", projectPath);
return {
success: true,
result: {
initialized: true,
message: `Initialized git repository in ${projectPath}`,
},
};
},
startDevServer: async (projectPath: string, worktreePath: string) => {
console.log("[Mock] Starting dev server:", { projectPath, worktreePath });
return {
success: true,
result: {
worktreePath,
port: 3001,
url: "http://localhost:3001",
message: "Dev server started on port 3001",
},
};
},
stopDevServer: async (worktreePath: string) => {
console.log("[Mock] Stopping dev server:", worktreePath);
return {
success: true,
result: {
worktreePath,
message: "Dev server stopped",
},
};
},
listDevServers: async () => {
console.log("[Mock] Listing dev servers");
return {
success: true,
result: {
servers: [],
},
};
},
};
}
@@ -1192,7 +1452,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
runFeature: async (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => {
if (mockRunningFeatures.has(featureId)) {
return {
@@ -1202,7 +1463,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
}
console.log(
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}`
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
);
mockRunningFeatures.add(featureId);
simulateAutoModeLoop(projectPath, featureId);
@@ -1224,7 +1485,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true, passes: true };
},
resumeFeature: async (projectPath: string, featureId: string) => {
resumeFeature: async (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) => {
if (mockRunningFeatures.has(featureId)) {
return {
success: false,
@@ -1362,7 +1627,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => {
if (mockRunningFeatures.has(featureId)) {
return {
@@ -1387,8 +1653,16 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true };
},
commitFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Committing feature:", { projectPath, featureId });
commitFeature: async (
projectPath: string,
featureId: string,
worktreePath?: string
) => {
console.log("[Mock] Committing feature:", {
projectPath,
featureId,
worktreePath,
});
// Simulate commit operation
emitAutoModeEvent({

View File

@@ -468,12 +468,24 @@ export class HttpApiClient implements ElectronAPI {
isLinux: boolean;
}> => this.get("/api/setup/platform"),
verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{
verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
},
@@ -515,17 +527,27 @@ export class HttpApiClient implements ElectronAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/resume-feature", { projectPath, featureId }),
resumeFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
@@ -534,16 +556,26 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
projectPath,
featureId,
worktreePath,
}),
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
@@ -568,8 +600,6 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API
worktree: WorktreeAPI = {
revertFeature: (projectPath: string, featureId: string) =>
this.post("/api/worktree/revert", { projectPath, featureId }),
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
@@ -578,6 +608,30 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
@@ -586,6 +640,24 @@ export class HttpApiClient implements ElectronAPI {
featureId,
filePath,
}),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
};
// Git API
@@ -641,7 +713,10 @@ export class HttpApiClient implements ElectronAPI {
maxFeatures,
}),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }),
this.post("/api/spec-regeneration/generate-features", {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
@@ -699,13 +774,15 @@ export class HttpApiClient implements ElectronAPI {
sessionId: string,
message: string,
workingDirectory?: string,
imagePaths?: string[]
imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", {
sessionId,
message,
workingDirectory,
imagePaths,
model,
}),
getHistory: (

View File

@@ -48,6 +48,31 @@ export async function initializeProject(
const existingFiles: string[] = [];
try {
// Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) {
console.log("[project-init] Initializing git repository...");
try {
// Initialize git and create an initial empty commit via server route
const result = await api.worktree?.initGit(projectPath);
if (result?.success && result.result?.initialized) {
createdFiles.push(".git");
console.log("[project-init] Git repository initialized with initial commit");
} else if (result?.success && !result.result?.initialized) {
// Git already existed (shouldn't happen since we checked, but handle it)
existingFiles.push(".git");
console.log("[project-init] Git repository already exists");
} else {
console.warn("[project-init] Failed to initialize git repository:", result?.error);
}
} catch (gitError) {
console.warn("[project-init] Failed to initialize git repository:", gitError);
// Don't fail the whole initialization if git init fails
}
} else {
existingFiles.push(".git");
}
// Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;

View File

@@ -35,3 +35,20 @@ export function truncateDescription(description: string, maxLength = 50): string
}
return `${description.slice(0, maxLength)}...`;
}
/**
* Normalize a file path to use forward slashes consistently.
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
}
/**
* Compare two paths for equality, handling cross-platform differences.
* Normalizes both paths to forward slashes before comparison.
*/
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}

View File

@@ -399,6 +399,20 @@ export interface AppState {
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
worktreesByProject: Record<
string,
Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
>;
// AI Profiles
aiProfiles: AIProfile[];
@@ -571,6 +585,25 @@ export interface AppActions {
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
@@ -721,6 +754,8 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
currentWorktreeByProject: {},
worktreesByProject: {},
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
@@ -1314,6 +1349,34 @@ export const useAppStore = create<AppState & AppActions>()(
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
setCurrentWorktree: (projectPath, worktreePath, branch) => {
const current = get().currentWorktreeByProject;
set({
currentWorktreeByProject: {
...current,
[projectPath]: { path: worktreePath, branch },
},
});
},
setWorktrees: (projectPath, worktrees) => {
const current = get().worktreesByProject;
set({
worktreesByProject: {
...current,
[projectPath]: worktrees,
},
});
},
getCurrentWorktree: (projectPath) => {
return get().currentWorktreeByProject[projectPath] ?? null;
},
getWorktrees: (projectPath) => {
return get().worktreesByProject[projectPath] ?? [];
},
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2175,6 +2238,8 @@ export const useAppStore = create<AppState & AppActions>()(
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreesByProject: state.worktreesByProject,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,

View File

@@ -10,6 +10,16 @@ export interface CliStatus {
error?: string;
}
// GitHub CLI Status
export interface GhCliStatus {
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =
| "oauth_token_env"
@@ -45,6 +55,7 @@ export type SetupStep =
| "welcome"
| "claude_detect"
| "claude_auth"
| "github"
| "complete";
export interface SetupState {
@@ -58,6 +69,9 @@ export interface SetupState {
claudeAuthStatus: ClaudeAuthStatus | null;
claudeInstallProgress: InstallProgress;
// GitHub CLI state
ghCliStatus: GhCliStatus | null;
// Setup preferences
skipClaudeSetup: boolean;
}
@@ -76,6 +90,9 @@ export interface SetupActions {
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
resetClaudeInstallProgress: () => void;
// GitHub CLI
setGhCliStatus: (status: GhCliStatus | null) => void;
// Preferences
setSkipClaudeSetup: (skip: boolean) => void;
}
@@ -99,6 +116,8 @@ const initialState: SetupState = {
claudeAuthStatus: null,
claudeInstallProgress: { ...initialInstallProgress },
ghCliStatus: null,
skipClaudeSetup: shouldSkipSetup,
};
@@ -145,6 +164,9 @@ export const useSetupStore = create<SetupState & SetupActions>()(
claudeInstallProgress: { ...initialInstallProgress },
}),
// GitHub CLI
setGhCliStatus: (status) => set({ ghCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}),

View File

@@ -84,7 +84,8 @@ export interface AgentAPI {
sessionId: string,
message: string,
workingDirectory?: string,
imagePaths?: string[]
imagePaths?: string[],
model?: string
) => Promise<{
success: boolean;
error?: string;
@@ -285,7 +286,10 @@ export interface SpecRegenerationAPI {
error?: string;
}>;
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
generateFeatures: (
projectPath: string,
maxFeatures?: number
) => Promise<{
success: boolean;
error?: string;
}>;
@@ -339,7 +343,8 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => Promise<{
success: boolean;
passes?: boolean;
@@ -357,7 +362,8 @@ export interface AutoModeAPI {
resumeFeature: (
projectPath: string,
featureId: string
featureId: string,
useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -383,7 +389,8 @@ export interface AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => Promise<{
success: boolean;
passes?: boolean;
@@ -570,16 +577,6 @@ export interface FileDiffResult {
}
export interface WorktreeAPI {
// Revert feature changes by removing the worktree
revertFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
removedPath?: string;
error?: string;
}>;
// Merge feature worktree changes back to main branch
mergeFeature: (
projectPath: string,
@@ -620,6 +617,108 @@ export interface WorktreeAPI {
error?: string;
}>;
// List all worktrees with details (for worktree selector)
listAll: (
projectPath: string,
includeDetails?: boolean
) => Promise<{
success: boolean;
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}>;
error?: string;
}>;
// Create a new worktree
create: (
projectPath: string,
branchName: string,
baseBranch?: string
) => Promise<{
success: boolean;
worktree?: {
path: string;
branch: string;
isNew: boolean;
};
error?: string;
}>;
// Delete a worktree
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) => Promise<{
success: boolean;
deleted?: {
worktreePath: string;
branch: string | null;
};
error?: string;
}>;
// Commit changes in a worktree
commit: (
worktreePath: string,
message: string
) => Promise<{
success: boolean;
result?: {
committed: boolean;
commitHash?: string;
branch?: string;
message?: string;
};
error?: string;
}>;
// Push a worktree branch to remote
push: (
worktreePath: string,
force?: boolean
) => Promise<{
success: boolean;
result?: {
branch: string;
pushed: boolean;
message: string;
};
error?: string;
}>;
// Create a pull request from a worktree
createPR: (
worktreePath: string,
options?: {
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
}
) => Promise<{
success: boolean;
result?: {
branch: string;
committed: boolean;
commitHash?: string;
pushed: boolean;
prUrl?: string;
prCreated: boolean;
prError?: string;
browserUrl?: string;
ghCliAvailable?: boolean;
};
error?: string;
}>;
// Get file diffs for a feature worktree
getDiffs: (
projectPath: string,
@@ -632,6 +731,129 @@ export interface WorktreeAPI {
featureId: string,
filePath: string
) => Promise<FileDiffResult>;
// Pull latest changes from remote
pull: (worktreePath: string) => Promise<{
success: boolean;
result?: {
branch: string;
pulled: boolean;
message: string;
};
error?: string;
}>;
// Create and checkout a new branch
checkoutBranch: (
worktreePath: string,
branchName: string
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
newBranch: string;
message: string;
};
error?: string;
}>;
// List all local branches
listBranches: (worktreePath: string) => Promise<{
success: boolean;
result?: {
currentBranch: string;
branches: Array<{
name: string;
isCurrent: boolean;
isRemote: boolean;
}>;
aheadCount: number;
behindCount: number;
};
error?: string;
}>;
// Switch to an existing branch
switchBranch: (
worktreePath: string,
branchName: string
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
currentBranch: string;
message: string;
};
error?: string;
}>;
// Open a worktree directory in the editor
openInEditor: (worktreePath: string) => Promise<{
success: boolean;
result?: {
message: string;
editorName?: string;
};
error?: string;
}>;
// Get the default code editor name
getDefaultEditor: () => Promise<{
success: boolean;
result?: {
editorName: string;
editorCommand: string;
};
error?: string;
}>;
// Initialize git repository in a project
initGit: (projectPath: string) => Promise<{
success: boolean;
result?: {
initialized: boolean;
message: string;
};
error?: string;
}>;
// Start a dev server for a worktree
startDevServer: (
projectPath: string,
worktreePath: string
) => Promise<{
success: boolean;
result?: {
worktreePath: string;
port: number;
url: string;
message: string;
};
error?: string;
}>;
// Stop a dev server for a worktree
stopDevServer: (worktreePath: string) => Promise<{
success: boolean;
result?: {
worktreePath: string;
message: string;
};
error?: string;
}>;
// List all running dev servers
listDevServers: () => Promise<{
success: boolean;
result?: {
servers: Array<{
worktreePath: string;
port: number;
url: string;
}>;
};
error?: string;
}>;
}
export interface GitAPI {