diff --git a/.gitignore b/.gitignore index 710cc052..7787ba75 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ out/ /.automaker/* /.automaker/ -/logs +.worktrees/ +/logs # Logs logs/ *.log diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index d4d0f866..26f06499 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -1,7 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.TEST_PORT || 3007; +const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === "true"; +const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true"; export default defineConfig({ testDir: "./tests", @@ -25,15 +27,33 @@ export default defineConfig({ ...(reuseServer ? {} : { - webServer: { - command: `npx next dev -p ${port}`, - url: `http://localhost:${port}`, - reuseExistingServer: !process.env.CI, - timeout: 120000, - env: { - ...process.env, - NEXT_PUBLIC_SKIP_SETUP: "true", + webServer: [ + // Backend server - runs with mock agent enabled in CI + { + command: `cd ../server && npm run dev`, + url: `http://localhost:${serverPort}/api/health`, + reuseExistingServer: true, + timeout: 60000, + env: { + ...process.env, + PORT: String(serverPort), + // Enable mock agent in CI to avoid real API calls + AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false", + // Allow access to test directories and common project paths + ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders", + }, }, - }, + // Frontend Next.js server + { + command: `npx next dev -p ${port}`, + url: `http://localhost:${port}`, + reuseExistingServer: true, + timeout: 120000, + env: { + ...process.env, + NEXT_PUBLIC_SKIP_SETUP: "true", + }, + }, + ], }), }); diff --git a/apps/app/src/components/ui/autocomplete.tsx b/apps/app/src/components/ui/autocomplete.tsx new file mode 100644 index 00000000..23e094c6 --- /dev/null +++ b/apps/app/src/components/ui/autocomplete.tsx @@ -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(0); + const triggerRef = React.useRef(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 ( + + + + + + + + + + {isNewValue ? ( +
+ Press enter to create{" "} + {inputValue} +
+ ) : ( + emptyMessage + )} +
+ + {/* Show "Create new" option if typing a new value */} + {isNewValue && ( + { + onChange(inputValue); + setInputValue(""); + setOpen(false); + }} + className="text-[var(--status-success)]" + data-testid={`${itemTestIdPrefix}-create-new`} + > + {Icon && } + {createLabel(inputValue)} + + (new) + + + )} + {filteredOptions.map((option) => ( + { + onChange(currentValue === value ? "" : currentValue); + setInputValue(""); + setOpen(false); + }} + data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`} + > + {Icon && } + {option.label} + + {option.badge && ( + + ({option.badge}) + + )} + + ))} + +
+
+
+
+ ); +} diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx new file mode 100644 index 00000000..60838354 --- /dev/null +++ b/apps/app/src/components/ui/branch-autocomplete.tsx @@ -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 ( + `Create "${v}"`} + data-testid={testId} + itemTestIdPrefix="branch-option" + /> + ); +} diff --git a/apps/app/src/components/ui/category-autocomplete.tsx b/apps/app/src/components/ui/category-autocomplete.tsx index 8f4b0054..125a15b7 100644 --- a/apps/app/src/components/ui/category-autocomplete.tsx +++ b/apps/app/src/components/ui/category-autocomplete.tsx @@ -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(0); - const triggerRef = React.useRef(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 ( - - - - - - - - - No category found. - - {suggestions.map((suggestion) => ( - { - onChange(currentValue === value ? "" : currentValue); - setOpen(false); - }} - data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`} - > - {suggestion} - - - ))} - - - - - + ); } diff --git a/apps/app/src/components/views/agent-view.tsx b/apps/app/src/components/views/agent-view.tsx index 8386554d..daa9e48b 100644 --- a/apps/app/src/components/views/agent-view.tsx +++ b/apps/app/src/components/views/agent-view.tsx @@ -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(null); const [showSessionManager, setShowSessionManager] = useState(true); const [isDragOver, setIsDragOver] = useState(false); + const [selectedModel, setSelectedModel] = useState("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 */}
+ {/* Model Selector */} + + + + + + {CLAUDE_MODELS.map((model) => ( + setSelectedModel(model.id)} + className={cn( + "cursor-pointer", + selectedModel === model.id && "bg-accent" + )} + data-testid={`model-option-${model.id}`} + > +
+ {model.label} + + {model.description} + +
+
+ ))} +
+
+ {currentTool && (
diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 2836c917..040afb51 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -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["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(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([]); - // 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 */} + 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 */}
{/* 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 */} + { + // 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 */} + { + // 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 */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create PR Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create Branch Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + />
); } diff --git a/apps/app/src/components/views/board-view/components/index.ts b/apps/app/src/components/views/board-view/components/index.ts index 49cf06ef..24517ad2 100644 --- a/apps/app/src/components/views/board-view/components/index.ts +++ b/apps/app/src/components/views/board-view/components/index.ts @@ -1,2 +1,3 @@ export { KanbanCard } from "./kanban-card"; export { KanbanColumn } from "./kanban-column"; +export { WorktreeSelector } from "./worktree-selector"; diff --git a/apps/app/src/components/views/board-view/components/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx index 4d23a31f..3ba39a06 100644 --- a/apps/app/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/app/src/components/views/board-view/components/kanban-card.tsx @@ -52,8 +52,6 @@ import { MoreVertical, AlertCircle, GitBranch, - Undo2, - GitMerge, ChevronDown, ChevronUp, Brain, @@ -103,8 +101,6 @@ interface KanbanCardProps { onMoveBackToInProgress?: () => void; onFollowUp?: () => void; onCommit?: () => void; - onRevert?: () => void; - onMerge?: () => void; onImplement?: () => void; onComplete?: () => void; hasContext?: boolean; @@ -130,8 +126,6 @@ export const KanbanCard = memo(function KanbanCard({ onMoveBackToInProgress, onFollowUp, onCommit, - onRevert, - onMerge, onImplement, onComplete, hasContext, @@ -146,13 +140,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(null); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [currentTime, setCurrentTime] = useState(() => Date.now()); - const { kanbanCardDetailLevel } = useAppStore(); - - const hasWorktree = !!feature.branchName; + const { kanbanCardDetailLevel, useWorktrees } = useAppStore(); const showSteps = kanbanCardDetailLevel === "standard" || @@ -356,8 +347,8 @@ export const KanbanCard = memo(function KanbanCard({ {feature.priority === 1 ? "High Priority" : feature.priority === 2 - ? "Medium Priority" - : "Low Priority"} + ? "Medium Priority" + : "Low Priority"}

@@ -373,99 +364,63 @@ export const KanbanCard = memo(function KanbanCard({
)} - {/* Skip Tests (Manual) indicator badge */} - {feature.skipTests && !feature.error && ( - - - -
- -
-
- -

Manual verification required

-
-
-
- )} - - {/* Error indicator badge */} - {feature.error && ( - - - -
- -
-
- -

{feature.error}

-
-
-
- )} - - {/* Just Finished indicator badge */} - {isJustFinished && ( + {/* Status badges row */} + {(feature.skipTests || feature.error || isJustFinished) && (
- -
- )} + {/* Skip Tests (Manual) indicator badge */} + {feature.skipTests && !feature.error && ( + + + +
+ +
+
+ +

Manual verification required

+
+
+
+ )} - {/* Branch badge */} - {hasWorktree && !isCurrentAutoTask && ( - - - -
- -
-
- -

- {feature.branchName} -

-
-
-
+ {/* Error indicator badge */} + {feature.error && ( + + + +
+ +
+
+ +

{feature.error}

+
+
+
+ )} + + {/* Just Finished indicator badge */} + {isJustFinished && ( +
+ +
+ )} +
)} {isCurrentAutoTask && ( @@ -675,6 +627,16 @@ export const KanbanCard = memo(function KanbanCard({ + {/* Target Branch Display */} + {useWorktrees && feature.branchName && ( +
+ + + {feature.branchName} + +
+ )} + {/* Steps Preview */} {showSteps && feature.steps && feature.steps.length > 0 && (
@@ -863,9 +825,9 @@ export const KanbanCard = memo(function KanbanCard({ <> {onViewOutput && ( - - -

Revert changes

-
- - - )} {/* Refine prompt button */} {onFollowUp && ( )} - {hasWorktree && onMerge && ( - - )} - {!hasWorktree && onCommit && ( + {onCommit && ( - - - - ); diff --git a/apps/app/src/components/views/board-view/components/worktree-selector.tsx b/apps/app/src/components/views/board-view/components/worktree-selector.tsx new file mode 100644 index 00000000..a4856dc2 --- /dev/null +++ b/apps/app/src/components/views/board-view/components/worktree-selector.tsx @@ -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([]); + const [branches, setBranches] = useState([]); + const [aheadCount, setAheadCount] = useState(0); + const [behindCount, setBehindCount] = useState(0); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [branchFilter, setBranchFilter] = useState(""); + const [runningDevServers, setRunningDevServers] = useState< + Map + >(new Map()); + const [defaultEditorName, setDefaultEditorName] = useState("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(); + 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 ( +
+ {/* Main branch: clickable button + separate branch switch dropdown */} + {worktree.isMain ? ( + <> + {/* Clickable button to select/preview main */} + + {/* Branch switch dropdown button */} + { + if (open) { + fetchBranches(worktree.path); + setBranchFilter(""); + } + }} + > + + + + + + Switch Branch + + + {/* Search input */} +
+
+ + setBranchFilter(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + onKeyPress={(e) => e.stopPropagation()} + className="h-7 pl-7 text-xs" + autoFocus + /> +
+
+ +
+ {isLoadingBranches ? ( + + + Loading branches... + + ) : ( + (() => { + const filteredBranches = branches.filter((b) => + b.name + .toLowerCase() + .includes(branchFilter.toLowerCase()) + ); + if (filteredBranches.length === 0) { + return ( + + {branchFilter + ? "No matching branches" + : "No branches found"} + + ); + } + return filteredBranches.map((branch) => ( + + handleSwitchBranch(worktree, branch.name) + } + disabled={ + isSwitching || branch.name === worktree.branch + } + className="text-xs font-mono" + > + {branch.name === worktree.branch ? ( + + ) : ( + + )} + {branch.name} + + )); + })() + )} +
+ + onCreateBranch(worktree)} + className="text-xs" + > + + Create New Branch... + +
+
+ + ) : ( + // Non-main branches - click to switch to this branch + + )} + + {/* Dev server indicator */} + {runningDevServers.has(getWorktreeKey(worktree)) && ( + + )} + + {/* Actions dropdown */} + { + if (open) { + fetchBranches(worktree.path); + } + }} + > + + + + + {/* Dev server controls */} + {runningDevServers.has(getWorktreeKey(worktree)) ? ( + <> + + + Dev Server Running (: + {runningDevServers.get(getWorktreeKey(worktree))?.port}) + + handleOpenDevServerUrl(worktree)} + className="text-xs" + > + + Open in Browser + + handleStopDevServer(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Stop Dev Server + + + + ) : ( + <> + handleStartDevServer(worktree)} + disabled={isStartingDevServer} + className="text-xs" + > + + {isStartingDevServer ? "Starting..." : "Start Dev Server"} + + + + )} + {/* Pull option */} + handlePull(worktree)} + disabled={isPulling} + className="text-xs" + > + + {isPulling ? "Pulling..." : "Pull"} + {behindCount > 0 && ( + + {behindCount} behind + + )} + + {/* Push option */} + handlePush(worktree)} + disabled={isPushing || aheadCount === 0} + className="text-xs" + > + + {isPushing ? "Pushing..." : "Push"} + {aheadCount > 0 && ( + + {aheadCount} ahead + + )} + + + {/* Open in editor */} + handleOpenInEditor(worktree)} + className="text-xs" + > + + Open in {defaultEditorName} + + + {/* Commit changes */} + {worktree.hasChanges && ( + onCommit(worktree)} + className="text-xs" + > + + Commit Changes + + )} + {/* Show PR option if not on main branch, or if on main with changes */} + {(worktree.branch !== "main" || worktree.hasChanges) && ( + onCreatePR(worktree)} + className="text-xs" + > + + Create Pull Request + + )} + {/* Only show delete for non-main worktrees */} + {!worktree.isMain && ( + <> + + onDeleteWorktree(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Delete Worktree + + + )} + + +
+ ); + }; + + // Don't render the worktree selector if the feature is disabled + if (!useWorktreesEnabled) { + return null; + } + + return ( +
+ + Branch: + + {/* Worktree Tabs */} +
+ {worktrees.map((worktree) => renderWorktreeTab(worktree))} + + {/* Add Worktree Button */} + + + {/* Refresh Button */} + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx index b701d0ed..ef73b370 100644 --- a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -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({ {/* Prompt Tab */} - +
- - setEnhancementMode('improve')}> + setEnhancementMode("improve")} + > Improve Clarity - setEnhancementMode('technical')}> + setEnhancementMode("technical")} + > Add Technical Details - setEnhancementMode('simplify')}> + setEnhancementMode("simplify")} + > Simplify - setEnhancementMode('acceptance')}> + setEnhancementMode("acceptance")} + > Add Acceptance Criteria @@ -321,6 +358,24 @@ export function AddFeatureDialog({ data-testid="feature-category-input" />
+ {useWorktrees && ( +
+ + + setNewFeature({ ...newFeature, branchName: value }) + } + branches={branchSuggestions} + placeholder="Select or create branch..." + data-testid="feature-branch-input" + /> +

+ Work will be done in this branch. A worktree will be created if + needed. +

+
+ )} {/* Priority Selector */} {/* Model Tab */} - + {/* Show Advanced Options Toggle */} {showProfilesOnly && (
@@ -396,16 +454,17 @@ export function AddFeatureDialog({ {/* Testing Tab */} - + setNewFeature({ ...newFeature, skipTests }) } steps={newFeature.steps} - onStepsChange={(steps) => - setNewFeature({ ...newFeature, steps }) - } + onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })} /> diff --git a/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx new file mode 100644 index 00000000..048169f2 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -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(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 ( + + + + + + Commit Changes + + + Commit changes in the{" "} + + {worktree.branch} + {" "} + worktree. + {worktree.changedFilesCount && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? "s" : ""} changed) + + )} + + + +
+
+ +