refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options

This commit is contained in:
gsxdsm
2026-02-17 22:02:58 -08:00
parent f4e87d4c25
commit 9af63bc1ef
89 changed files with 6811 additions and 351 deletions

View File

@@ -0,0 +1,436 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
FolderOpen,
Folder,
FileCode,
ChevronRight,
ArrowLeft,
Check,
Search,
X,
} from 'lucide-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 { Checkbox } from '@/components/ui/checkbox';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
import { cn } from '@/lib/utils';
interface ProjectFileEntry {
name: string;
relativePath: string;
isDirectory: boolean;
isFile: boolean;
}
interface BrowseResult {
success: boolean;
currentRelativePath: string;
parentRelativePath: string | null;
entries: ProjectFileEntry[];
warning?: string;
error?: string;
}
interface ProjectFileSelectorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (paths: string[]) => void;
projectPath: string;
existingFiles?: string[];
title?: string;
description?: string;
}
export function ProjectFileSelectorDialog({
open,
onOpenChange,
onSelect,
projectPath,
existingFiles = [],
title = 'Select Files to Copy',
description = 'Browse your project and select files or directories to copy into new worktrees.',
}: ProjectFileSelectorDialogProps) {
const { isMac } = useOSDetection();
const [currentRelativePath, setCurrentRelativePath] = useState('');
const [parentRelativePath, setParentRelativePath] = useState<string | null>(null);
const [entries, setEntries] = useState<ProjectFileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
// Track the path segments for breadcrumb navigation
const breadcrumbs = useMemo(() => {
if (!currentRelativePath) return [];
const parts = currentRelativePath.split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/'),
}));
}, [currentRelativePath]);
const browseDirectory = useCallback(
async (relativePath?: string) => {
setLoading(true);
setError('');
setWarning('');
setSearchQuery('');
try {
const result = await apiPost<BrowseResult>('/api/fs/browse-project-files', {
projectPath,
relativePath: relativePath || '',
});
if (result.success) {
setCurrentRelativePath(result.currentRelativePath);
setParentRelativePath(result.parentRelativePath);
setEntries(result.entries);
setWarning(result.warning || '');
} else {
setError(result.error || 'Failed to browse directory');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directory contents');
} finally {
setLoading(false);
}
},
[projectPath]
);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setSelectedPaths(new Set());
setSearchQuery('');
browseDirectory();
} else {
setCurrentRelativePath('');
setParentRelativePath(null);
setEntries([]);
setError('');
setWarning('');
setSelectedPaths(new Set());
setSearchQuery('');
}
}, [open, browseDirectory]);
const handleNavigateInto = useCallback(
(entry: ProjectFileEntry) => {
if (entry.isDirectory) {
browseDirectory(entry.relativePath);
}
},
[browseDirectory]
);
const handleGoBack = useCallback(() => {
if (parentRelativePath !== null) {
browseDirectory(parentRelativePath || undefined);
}
}, [parentRelativePath, browseDirectory]);
const handleGoToRoot = useCallback(() => {
browseDirectory();
}, [browseDirectory]);
const handleBreadcrumbClick = useCallback(
(path: string) => {
browseDirectory(path);
},
[browseDirectory]
);
const handleToggleSelect = useCallback((entry: ProjectFileEntry) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (next.has(entry.relativePath)) {
next.delete(entry.relativePath);
} else {
next.add(entry.relativePath);
}
return next;
});
}, []);
const handleConfirmSelection = useCallback(() => {
const paths = Array.from(selectedPaths);
if (paths.length > 0) {
onSelect(paths);
onOpenChange(false);
}
}, [selectedPaths, onSelect, onOpenChange]);
// Check if a path is already configured
const isAlreadyConfigured = useCallback(
(relativePath: string) => {
return existingFiles.includes(relativePath);
},
[existingFiles]
);
// Filter entries based on search query
const filteredEntries = useMemo(() => {
if (!searchQuery.trim()) return entries;
const query = searchQuery.toLowerCase();
return entries.filter((entry) => entry.name.toLowerCase().includes(query));
}, [entries, searchQuery]);
// Handle Command/Ctrl+Enter keyboard shortcut
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (selectedPaths.size > 0 && !loading) {
handleConfirmSelection();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, selectedPaths, loading, handleConfirmSelection]);
const selectedCount = selectedPaths.size;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none">
<DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 min-h-[300px] flex-1 overflow-hidden py-1">
{/* Navigation bar */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleGoBack}
className="h-7 w-7 shrink-0"
disabled={loading || parentRelativePath === null}
aria-label="Go back"
>
<ArrowLeft className="w-4 h-4" />
</Button>
{/* Breadcrumb path */}
<div className="flex items-center gap-1 min-w-0 flex-1 h-8 px-3 rounded-md border border-input bg-background/50 overflow-x-auto scrollbar-none">
<button
onClick={handleGoToRoot}
className={cn(
'text-xs font-mono shrink-0 transition-colors',
currentRelativePath
? 'text-muted-foreground hover:text-foreground'
: 'text-foreground font-medium'
)}
disabled={loading}
>
Project Root
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick(crumb.path)}
className={cn(
'text-xs font-mono truncate max-w-[150px] transition-colors',
crumb.path === currentRelativePath
? 'text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
disabled={loading}
>
{crumb.name}
</button>
</span>
))}
</div>
</div>
{/* Search filter */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter files and directories..."
className="h-8 text-xs pl-8 pr-8"
disabled={loading}
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Selected items indicator */}
{selectedCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-brand-500/10 border border-brand-500/20 text-xs">
<Check className="w-3.5 h-3.5 text-brand-500" />
<span className="text-brand-500 font-medium">
{selectedCount} {selectedCount === 1 ? 'item' : 'items'} selected
</span>
<button
onClick={() => setSelectedPaths(new Set())}
className="ml-auto text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
)}
{/* File/directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
{loading && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">Loading...</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-destructive">{error}</div>
</div>
)}
{warning && (
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
<div className="text-xs text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && filteredEntries.length === 0 && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
{searchQuery ? 'No matching files or directories' : 'This directory is empty'}
</div>
</div>
)}
{!loading && !error && filteredEntries.length > 0 && (
<div className="divide-y divide-sidebar-border">
{filteredEntries.map((entry) => {
const isSelected = selectedPaths.has(entry.relativePath);
const isConfigured = isAlreadyConfigured(entry.relativePath);
return (
<div
key={entry.relativePath}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 transition-colors text-left group',
isConfigured
? 'opacity-50'
: isSelected
? 'bg-brand-500/10'
: 'hover:bg-sidebar-accent/10'
)}
>
{/* Checkbox for selection */}
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelect(entry)}
disabled={isConfigured}
className="shrink-0"
aria-label={`Select ${entry.name}`}
/>
{/* Icon */}
{entry.isDirectory ? (
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
) : (
<FileCode className="w-4 h-4 text-muted-foreground/60 shrink-0" />
)}
{/* File/directory name */}
<span
className="flex-1 truncate text-xs font-mono cursor-pointer"
onClick={() => {
if (!isConfigured) {
handleToggleSelect(entry);
}
}}
>
{entry.name}
</span>
{/* Already configured badge */}
{isConfigured && (
<span className="text-[10px] text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded shrink-0">
Already added
</span>
)}
{/* Navigate into directory button */}
{entry.isDirectory && (
<button
onClick={(e) => {
e.stopPropagation();
handleNavigateInto(entry);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent/50 transition-all shrink-0"
title={`Open ${entry.name}`}
>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
<div className="text-[10px] text-muted-foreground">
Select files or directories to copy into new worktrees. Directories are copied
recursively. Click the arrow to browse into a directory.
</div>
</div>
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirmSelection}
disabled={selectedCount === 0 || loading}
title={`Add ${selectedCount} selected items (${isMac ? '⌘' : 'Ctrl'}+Enter)`}
>
<Check className="w-3.5 h-3.5 mr-1.5" />
Add {selectedCount > 0 ? `${selectedCount} ` : ''}
{selectedCount === 1 ? 'Item' : 'Items'}
<KbdGroup className="ml-1">
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
<Kbd></Kbd>
</KbdGroup>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -37,18 +37,21 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="bg-popover border-border max-w-lg"
className="bg-popover border-border max-w-lg flex flex-col"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6" />
<ShieldAlert className="w-6 h-6 flex-shrink-0" />
Sandbox Environment Not Detected
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<div className="space-y-4 pt-2 pb-2">
<p className="text-muted-foreground">
<strong>Warning:</strong> This application is running outside of a containerized
sandbox environment. AI agents will have direct access to your filesystem and can
@@ -94,9 +97,9 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
</div>
</div>
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4 flex-shrink-0 border-t border-border mt-4">
<div className="flex items-center space-x-2 self-start">
<Checkbox
id="skip-sandbox-warning"

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor, LogOut } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store';
@@ -196,11 +196,13 @@ export function ProjectContextMenu({
const menuRef = useRef<HTMLDivElement>(null);
const {
moveProjectToTrash,
removeProject,
theme: globalTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -282,7 +284,7 @@ export function ProjectContextMenu({
useEffect(() => {
const handleClickOutside = (event: globalThis.MouseEvent) => {
// Don't close if a confirmation dialog is open (dialog is in a portal)
if (showRemoveDialog) return;
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) {
setPreviewTheme(null);
@@ -292,7 +294,7 @@ export function ProjectContextMenu({
const handleEscape = (event: globalThis.KeyboardEvent) => {
// Don't close if a confirmation dialog is open (let the dialog handle escape)
if (showRemoveDialog) return;
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
if (event.key === 'Escape') {
setPreviewTheme(null);
@@ -307,7 +309,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose, setPreviewTheme, showRemoveDialog]);
}, [onClose, setPreviewTheme, showRemoveDialog, showRemoveFromAutomakerDialog]);
const handleEdit = () => {
onEdit(project);
@@ -359,10 +361,31 @@ export function ProjectContextMenu({
[onClose]
);
const handleRemoveFromAutomaker = () => {
setShowRemoveFromAutomakerDialog(true);
};
const handleConfirmRemoveFromAutomaker = useCallback(() => {
removeProject(project.id);
toast.success('Project removed from Automaker', {
description: `${project.name} has been removed. The folder remains on disk.`,
});
}, [removeProject, project.id, project.name]);
const handleRemoveFromAutomakerDialogClose = useCallback(
(isOpen: boolean) => {
setShowRemoveFromAutomakerDialog(isOpen);
if (!isOpen) {
onClose();
}
},
[onClose]
);
return (
<>
{/* Hide context menu when confirm dialog is open */}
{!showRemoveDialog && (
{!showRemoveDialog && !showRemoveFromAutomakerDialog && (
<div
ref={menuRef}
className={cn(
@@ -509,7 +532,22 @@ export function ProjectContextMenu({
data-testid="remove-project-button"
>
<Trash2 className="w-4 h-4" />
<span>Remove Project</span>
<span>Move to Trash</span>
</button>
<button
onClick={handleRemoveFromAutomaker}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="remove-from-automaker-button"
>
<LogOut className="w-4 h-4" />
<span>Remove from Automaker</span>
</button>
</div>
</div>
@@ -519,13 +557,25 @@ export function ProjectContextMenu({
open={showRemoveDialog}
onOpenChange={handleDialogClose}
onConfirm={handleConfirmRemove}
title="Remove Project"
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}
title="Move to Trash"
description={`Are you sure you want to move "${project.name}" to Trash? You can restore it later from the Recycle Bin.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Remove"
confirmText="Move to Trash"
confirmVariant="destructive"
/>
<ConfirmDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={handleRemoveFromAutomakerDialogClose}
onConfirm={handleConfirmRemoveFromAutomaker}
title="Remove from Automaker"
description={`Remove "${project.name}" from Automaker? The folder will remain on disk and can be re-added later by opening it.`}
icon={LogOut}
iconClassName="text-muted-foreground"
confirmText="Remove from Automaker"
confirmVariant="secondary"
/>
</>
);
}

View File

@@ -11,6 +11,7 @@ import {
RotateCcw,
Trash2,
Search,
LogOut,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
@@ -47,6 +48,8 @@ interface ProjectSelectorWithOptionsProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void;
/** Callback to show the remove from automaker confirmation dialog */
setShowRemoveFromAutomakerDialog: (show: boolean) => void;
}
/**
@@ -70,6 +73,7 @@ export function ProjectSelectorWithOptions({
isProjectPickerOpen,
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
setShowRemoveFromAutomakerDialog,
}: ProjectSelectorWithOptionsProps) {
const {
projects,
@@ -371,8 +375,16 @@ export function ProjectSelectorWithOptions({
</>
)}
{/* Move to Trash Section */}
{/* Remove / Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowRemoveFromAutomakerDialog(true)}
className="text-muted-foreground focus:text-foreground"
data-testid="remove-from-automaker"
>
<LogOut className="w-4 h-4 mr-2" />
<span>Remove from Automaker</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"

View File

@@ -39,6 +39,7 @@ import { EditProjectDialog } from '../project-switcher/components/edit-project-d
// Import shared dialogs
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '@/components/views/settings-view/components/remove-from-automaker-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -65,6 +66,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
removeProject,
specCreatingForProject,
setSpecCreatingForProject,
setCurrentProject,
@@ -91,6 +93,8 @@ export function Sidebar() {
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for remove from automaker confirmation dialog
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
@@ -488,6 +492,14 @@ export function Sidebar() {
onConfirm={moveProjectToTrash}
/>
{/* Remove from Automaker Confirmation Dialog */}
<RemoveFromAutomakerDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={setShowRemoveFromAutomakerDialog}
project={currentProject}
onConfirm={removeProject}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}

View File

@@ -106,8 +106,11 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
/** Content to display below the item text in the dropdown only (not shown in trigger). */
description?: React.ReactNode;
}
>(({ className, children, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
@@ -122,7 +125,14 @@ const SelectItem = React.forwardRef<
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description ? (
<div className="flex flex-col items-start">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description}
</div>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -34,13 +34,18 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
const now = new Date();
const diff = date.getTime() - now.getTime();
// Guard against past timestamps: clamp negative diffs to a friendly fallback
if (diff <= 0) {
return 'Resets now';
}
if (diff < 3600000) {
const mins = Math.ceil(diff / 60000);
const mins = Math.max(0, Math.ceil(diff / 60000));
return `Resets in ${mins}m`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
const mins = Math.ceil((diff % 3600000) / 60000);
const hours = Math.max(0, Math.floor(diff / 3600000));
const mins = Math.max(0, Math.ceil((diff % 3600000) / 60000));
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;

View File

@@ -49,12 +49,13 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
DependencyLinkDialog,
DuplicateCountDialog,
EditFeatureDialog,
FollowUpDialog,
PlanApprovalDialog,
PullResolveConflictsDialog,
MergeRebaseDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -170,13 +171,16 @@ export function BoardView() {
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
// State for duplicate as child multiple times dialog
const [duplicateMultipleFeature, setDuplicateMultipleFeature] = 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 [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
@@ -596,6 +600,7 @@ export function BoardView() {
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -917,17 +922,25 @@ export function BoardView() {
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
setSelectedWorktreeForAction(worktree);
setShowPullResolveConflictsDialog(true);
setShowMergeRebaseDialog(true);
}, []);
// Handler called when user confirms the pull & resolve conflicts dialog
// Handler called when user confirms the merge & rebase dialog
const handleConfirmResolveConflicts = useCallback(
async (worktree: WorktreeInfo, remoteBranch: string) => {
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
const isRebase = strategy === 'rebase';
const description = isRebase
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
const title = isRebase
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`;
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`,
title,
category: 'Maintenance',
description,
images: [],
@@ -1562,6 +1575,7 @@ export function BoardView() {
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
}}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
@@ -1603,6 +1617,7 @@ export function BoardView() {
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
@@ -1752,6 +1767,21 @@ export function BoardView() {
branchName={outputFeature?.branchName}
/>
{/* Duplicate as Child Multiple Times Dialog */}
<DuplicateCountDialog
open={duplicateMultipleFeature !== null}
onOpenChange={(open) => {
if (!open) setDuplicateMultipleFeature(null);
}}
onConfirm={async (count) => {
if (duplicateMultipleFeature) {
await handleDuplicateAsChildMultiple(duplicateMultipleFeature, count);
setDuplicateMultipleFeature(null);
}
}}
featureTitle={duplicateMultipleFeature?.title || duplicateMultipleFeature?.description}
/>
{/* Archive All Verified Dialog */}
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
@@ -1899,10 +1929,10 @@ export function BoardView() {
}}
/>
{/* Pull & Resolve Conflicts Dialog */}
<PullResolveConflictsDialog
open={showPullResolveConflictsDialog}
onOpenChange={setShowPullResolveConflictsDialog}
{/* Merge & Rebase Dialog */}
<MergeRebaseDialog
open={showMergeRebaseDialog}
onOpenChange={setShowMergeRebaseDialog}
worktree={selectedWorktreeForAction}
onConfirm={handleConfirmResolveConflicts}
/>

View File

@@ -48,7 +48,7 @@ interface AgentInfoPanelProps {
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
isActivelyRunning?: boolean;
}
export const AgentInfoPanel = memo(function AgentInfoPanel({
@@ -56,7 +56,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
projectPath,
contextContent,
summary,
isCurrentAutoTask,
isActivelyRunning,
}: AgentInfoPanelProps) {
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
@@ -107,7 +107,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
// - Otherwise: no polling
const pollingInterval = useMemo((): number | false => {
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
if (!(isActivelyRunning || feature.status === 'in_progress')) {
return false;
}
// If receiving WebSocket events, use longer polling interval as fallback
@@ -116,7 +116,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
}
// Default polling interval
return 3000;
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
}, [isActivelyRunning, feature.status, isReceivingWsEvents]);
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {

View File

@@ -23,6 +23,7 @@ import {
ChevronUp,
GitFork,
Copy,
Repeat,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -33,9 +34,11 @@ import { getProviderIconForModel } from '@/components/ui/provider-icon';
function DuplicateMenuItems({
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
}: {
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}) {
if (!onDuplicate) return null;
@@ -55,25 +58,23 @@ function DuplicateMenuItems({
);
}
// When sub-child action is available, render a proper DropdownMenuSub with
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
// Split-button pattern: main click duplicates immediately, disclosure arrow shows submenu
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
className="flex-1 pr-0 rounded-r-none text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8 text-xs" />
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -84,6 +85,18 @@ function DuplicateMenuItems({
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
{onDuplicateAsChildMultiple && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChildMultiple();
}}
className="text-xs"
>
<Repeat className="w-3 h-3 mr-2" />
Duplicate as Child ×N
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -100,6 +113,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes;
}
@@ -115,6 +129,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
dragHandleListeners,
dragHandleAttributes,
}: CardHeaderProps) {
@@ -183,6 +198,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {
@@ -251,6 +267,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -343,6 +360,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -417,6 +435,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {

View File

@@ -54,6 +54,7 @@ interface KanbanCardProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -90,6 +91,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -266,6 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
dragHandleListeners={isDraggable ? listeners : undefined}
dragHandleAttributes={isDraggable ? attributes : undefined}
/>
@@ -280,7 +283,7 @@ export const KanbanCard = memo(function KanbanCard({
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
isActivelyRunning={isActivelyRunning}
/>
{/* Actions */}

View File

@@ -45,6 +45,7 @@ export interface ListViewActionHandlers {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
}
export interface ListViewProps {
@@ -332,6 +333,12 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onDuplicateAsChild?.(f);
}
: undefined,
duplicateAsChildMultiple: actionHandlers.onDuplicateAsChildMultiple
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicateAsChildMultiple?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]

View File

@@ -15,6 +15,7 @@ import {
GitFork,
ExternalLink,
Copy,
Repeat,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -49,6 +50,7 @@ export interface RowActionHandlers {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}
export interface RowActionsProps {
@@ -443,6 +445,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -565,6 +574,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -636,6 +652,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -712,6 +735,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -764,6 +794,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -804,6 +841,7 @@ export function createRowActionHandlers(
spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
duplicateAsChildMultiple?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -824,5 +862,8 @@ export function createRowActionHandlers(
onDuplicateAsChild: actions.duplicateAsChild
? () => actions.duplicateAsChild!(featureId)
: undefined,
onDuplicateAsChildMultiple: actions.duplicateAsChildMultiple
? () => actions.duplicateAsChildMultiple!(featureId)
: undefined,
};
}

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, useCallback } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
GitCommit,
AlertTriangle,
Wrench,
User,
Clock,
Copy,
Check,
Cherry,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export interface CherryPickConflictInfo {
commitHashes: string[];
targetBranch: string;
targetWorktreePath: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: Array<{
name: string;
fullRef: string;
}>;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface CherryPickDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCherryPicked: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
type Step = 'select-branch' | 'select-commits' | 'conflict';
export function CherryPickDialog({
open,
onOpenChange,
worktree,
onCherryPicked,
onCreateConflictResolutionFeature,
}: CherryPickDialogProps) {
// Step management
const [step, setStep] = useState<Step>('select-branch');
// Branch selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [localBranches, setLocalBranches] = useState<string[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [loadingBranches, setLoadingBranches] = useState(false);
// Commits state
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [selectedCommitHashes, setSelectedCommitHashes] = useState<Set<string>>(new Set());
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
const [loadingCommits, setLoadingCommits] = useState(false);
const [loadingMoreCommits, setLoadingMoreCommits] = useState(false);
const [commitsError, setCommitsError] = useState<string | null>(null);
const [commitLimit, setCommitLimit] = useState(30);
const [hasMoreCommits, setHasMoreCommits] = useState(false);
// Cherry-pick state
const [isCherryPicking, setIsCherryPicking] = useState(false);
// Conflict state
const [conflictInfo, setConflictInfo] = useState<CherryPickConflictInfo | null>(null);
// All available branch options for the current remote selection
const branchOptions =
selectedRemote === '__local__'
? localBranches.filter((b) => b !== worktree?.branch)
: (remotes.find((r) => r.name === selectedRemote)?.branches || []).map((b) => b.fullRef);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('select-branch');
setSelectedRemote('');
setSelectedBranch('');
setCommits([]);
setSelectedCommitHashes(new Set());
setExpandedCommits(new Set());
setConflictInfo(null);
setCommitsError(null);
setCommitLimit(30);
setHasMoreCommits(false);
}
}, [open]);
// Fetch remotes and local branches when dialog opens
useEffect(() => {
if (!open || !worktree) return;
const fetchBranchData = async () => {
setLoadingBranches(true);
try {
const api = getHttpApiClient();
// Fetch remotes and local branches in parallel
const [remotesResult, branchesResult] = await Promise.all([
api.worktree.listRemotes(worktree.path),
api.worktree.listBranches(worktree.path, false),
]);
if (remotesResult.success && remotesResult.result) {
setRemotes(remotesResult.result.remotes);
// Default to first remote if available, otherwise local
if (remotesResult.result.remotes.length > 0) {
setSelectedRemote(remotesResult.result.remotes[0].name);
} else {
setSelectedRemote('__local__');
}
}
if (branchesResult.success && branchesResult.result) {
const branches = branchesResult.result.branches
.filter(
(b: { isRemote: boolean; name: string }) => !b.isRemote && b.name !== worktree.branch
)
.map((b: { name: string }) => b.name);
setLocalBranches(branches);
}
} catch (err) {
console.error('Failed to fetch branch data:', err);
} finally {
setLoadingBranches(false);
}
};
fetchBranchData();
}, [open, worktree]);
// Fetch commits when branch is selected
const fetchCommits = useCallback(
async (limit: number = 30, append: boolean = false) => {
if (!worktree || !selectedBranch) return;
if (append) {
setLoadingMoreCommits(true);
} else {
setLoadingCommits(true);
setCommitsError(null);
setCommits([]);
setSelectedCommitHashes(new Set());
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
if (result.success && result.result) {
setCommits(result.result.commits);
// If we got exactly the limit, there may be more commits
setHasMoreCommits(result.result.commits.length >= limit);
} else {
setCommitsError(result.error || 'Failed to load commits');
}
} catch (err) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setLoadingCommits(false);
setLoadingMoreCommits(false);
}
},
[worktree, selectedBranch]
);
// Handle proceeding from branch selection to commit selection
const handleProceedToCommits = useCallback(() => {
if (!selectedBranch) return;
setStep('select-commits');
fetchCommits(commitLimit);
}, [selectedBranch, fetchCommits, commitLimit]);
// Handle loading more commits
const handleLoadMore = useCallback(() => {
const newLimit = Math.min(commitLimit + 30, 100);
setCommitLimit(newLimit);
fetchCommits(newLimit, true);
}, [commitLimit, fetchCommits]);
// Toggle commit selection
const toggleCommitSelection = useCallback((hash: string) => {
setSelectedCommitHashes((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Toggle commit file list expansion
const toggleCommitExpanded = useCallback((hash: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedCommits((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Handle cherry-pick execution
const handleCherryPick = useCallback(async () => {
if (!worktree || selectedCommitHashes.size === 0) return;
setIsCherryPicking(true);
try {
const api = getHttpApiClient();
// Order commits from oldest to newest (reverse of display order)
// so they're applied in chronological order
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
const result = await api.worktree.cherryPick(worktree.path, orderedHashes);
if (result.success) {
toast.success(`Cherry-picked ${orderedHashes.length} commit(s)`, {
description: `Successfully applied to ${worktree.branch}`,
});
onCherryPicked();
onOpenChange(false);
} else {
// Check for conflicts
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
(result as { hasConflicts?: boolean }).hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) {
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: result.error,
});
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('cherry-pick failed');
if (hasConflicts && onCreateConflictResolutionFeature) {
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: errorMessage,
});
}
} finally {
setIsCherryPicking(false);
}
}, [
worktree,
selectedCommitHashes,
commits,
onCherryPicked,
onOpenChange,
onCreateConflictResolutionFeature,
]);
// Handle creating a conflict resolution feature
const handleCreateConflictResolutionFeature = useCallback(() => {
if (conflictInfo && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
});
onOpenChange(false);
}
}, [conflictInfo, selectedBranch, onCreateConflictResolutionFeature, onOpenChange]);
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictInfo) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Cherry-Pick Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
There are conflicts when cherry-picking commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> into{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictInfo.targetBranch}
</code>
.
</span>
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
The cherry-pick could not be completed automatically. You can create a feature
task to resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{conflictInfo.targetBranch}
</code>{' '}
branch.
</span>
</div>
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a high-priority feature task that will:
</p>
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
<li>
Cherry-pick the selected commit(s) from{' '}
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
</li>
<li>Resolve any merge conflicts</li>
<li>Ensure the code compiles and tests pass</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('select-commits')}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 2: Select commits
if (step === 'select-commits') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick Commits
</DialogTitle>
<DialogDescription>
Select commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> to apply to{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{loadingCommits && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{commitsError && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{commitsError}</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found on this branch</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => {
const isSelected = selectedCommitHashes.has(commit.hash);
const isExpanded = expandedCommits.has(commit.hash);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
key={commit.hash}
className={cn(
'group relative rounded-md transition-colors',
isSelected
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent',
index === 0 && !isSelected && 'bg-muted/30'
)}
>
<div
onClick={() => toggleCommitSelection(commit.hash)}
className={cn(
'flex gap-3 py-2.5 px-3 cursor-pointer rounded-md transition-colors',
!isSelected && 'hover:bg-muted/50'
)}
>
{/* Checkbox */}
<div className="flex items-start pt-1 shrink-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleCommitSelection(commit.hash)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">
{commit.subject}
</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-2">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={commit.date}
title={new Date(commit.date).toLocaleString()}
>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={(e) => toggleCommitExpanded(commit.hash, e)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{isExpanded && hasFiles && (
<div className="border-t mx-3 px-3 py-2 bg-muted/30 rounded-b-md ml-8">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
{/* Load More button */}
{hasMoreCommits && commitLimit < 100 && (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleLoadMore();
}}
disabled={loadingMoreCommits}
className="text-xs text-muted-foreground hover:text-foreground"
>
{loadingMoreCommits ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
<>
<ChevronDown className="w-3.5 h-3.5 mr-1.5" />
Load More Commits
</>
)}
</Button>
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter className="mt-4 pt-4 border-t">
<Button
variant="ghost"
onClick={() => {
setStep('select-branch');
setSelectedBranch('');
}}
>
Back
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isCherryPicking}>
Cancel
</Button>
<Button
onClick={handleCherryPick}
disabled={selectedCommitHashes.size === 0 || isCherryPicking}
>
{isCherryPicking ? (
<>
<Spinner size="sm" variant="foreground" className="mr-2" />
Cherry Picking...
</>
) : (
<>
<Cherry className="w-4 h-4 mr-2" />
Cherry Pick
{selectedCommitHashes.size > 0 ? ` (${selectedCommitHashes.size})` : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 1: Select branch (and optionally remote)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
Select a branch to cherry-pick commits from into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</span>
{loadingBranches ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
Loading branches...
</div>
) : (
<>
{/* Remote selector - only show if there are remotes */}
{remotes.length > 0 && (
<div className="space-y-2">
<Label className="text-sm text-foreground">Source</Label>
<Select
value={selectedRemote}
onValueChange={(value) => {
setSelectedRemote(value);
setSelectedBranch('');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectItem value="__local__">Local Branches</SelectItem>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.url})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Branch selector */}
<div className="space-y-2">
<Label className="text-sm text-foreground">Branch</Label>
{branchOptions.length === 0 ? (
<p className="text-sm text-muted-foreground">No other branches available</p>
) : (
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleProceedToCommits} disabled={!selectedBranch || loadingBranches}>
<GitCommit className="w-4 h-4 mr-2" />
View Commits
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -307,6 +307,8 @@ export function CommitWorktreeDialog({
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getElectronAPI();
@@ -314,20 +316,24 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for commit dialog:', err);
} finally {
setIsLoadingDiffs(false);
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -11,9 +11,20 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitBranchPlus } from 'lucide-react';
import { GitBranchPlus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
@@ -24,6 +35,12 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
@@ -40,16 +57,45 @@ export function CreateBranchDialog({
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [baseBranch, setBaseBranch] = useState('');
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
const fetchBranches = useCallback(async () => {
if (!worktree) return;
setIsLoadingBranches(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
setBranches(result.result.branches);
// Default to current branch
if (result.result.currentBranch) {
setBaseBranch(result.result.currentBranch);
}
}
} catch (err) {
logger.error('Failed to fetch branches:', err);
} finally {
setIsLoadingBranches(false);
}
}, [worktree]);
// Reset state and fetch branches when dialog opens
useEffect(() => {
if (open) {
setBranchName('');
setBaseBranch('');
setError(null);
setBranches([]);
fetchBranches();
}
}, [open]);
}, [open, fetchBranches]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
@@ -71,7 +117,13 @@ export function CreateBranchDialog({
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
// Pass baseBranch if user selected one different from the current branch
const selectedBase = baseBranch || undefined;
const result = await api.worktree.checkoutBranch(
worktree.path,
branchName.trim(),
selectedBase
);
if (result.success && result.result) {
toast.success(result.result.message);
@@ -88,6 +140,10 @@ export function CreateBranchDialog({
}
};
// Separate local and remote branches
const localBranches = branches.filter((b) => !b.isRemote);
const remoteBranches = branches.filter((b) => b.isRemote);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
@@ -96,12 +152,7 @@ export function CreateBranchDialog({
<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>
<DialogDescription>Create a new branch from a base branch</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -123,8 +174,74 @@ export function CreateBranchDialog({
disabled={isCreating}
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="base-branch">Base Branch</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches || isCreating}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
{isLoadingBranches && branches.length === 0 ? (
<div className="flex items-center justify-center py-3 border rounded-md border-input">
<Spinner size="sm" className="mr-2" />
<span className="text-sm text-muted-foreground">Loading branches...</span>
</div>
) : (
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={isCreating}>
<SelectTrigger id="base-branch">
<SelectValue placeholder="Select base branch" />
</SelectTrigger>
<SelectContent>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel>Local Branches</SelectLabel>
{localBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
<span className={branch.isCurrent ? 'font-medium' : ''}>
{branch.name}
{branch.isCurrent ? ' (current)' : ''}
</span>
</SelectItem>
))}
</SelectGroup>
)}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Remote Branches</SelectLabel>
{remoteBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</>
)}
{localBranches.length === 0 && remoteBranches.length === 0 && (
<SelectItem value="HEAD" disabled>
No branches found
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -13,12 +13,25 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface RemoteInfo {
name: string;
url: string;
}
interface WorktreeInfo {
path: string;
branch: string;
@@ -58,6 +71,14 @@ export function CreatePRDialog({
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Remote selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
@@ -70,6 +91,44 @@ export function CreatePRDialog({
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
// Fetch remotes when dialog opens
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoadingRemotes(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
(r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
})
);
setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} catch {
// Silently fail - remotes selector will just not show
} finally {
setIsLoadingRemotes(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -81,6 +140,9 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
setRemotes([]);
setSelectedRemote('');
setIsGeneratingDescription(false);
operationCompletedRef.current = false;
}, [defaultBaseBranch]);
@@ -90,6 +152,37 @@ export function CreatePRDialog({
resetState();
}, [open, worktree?.path, resetState]);
const handleGenerateDescription = async () => {
if (!worktree) return;
setIsGeneratingDescription(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
if (result.success) {
if (result.title) {
setTitle(result.title);
}
if (result.body) {
setBody(result.body);
}
toast.success('PR description generated');
} else {
toast.error('Failed to generate description', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to generate description', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsGeneratingDescription(false);
}
};
const handleCreate = async () => {
if (!worktree) return;
@@ -109,6 +202,7 @@ export function CreatePRDialog({
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
remote: selectedRemote || undefined,
});
if (result.success && result.result) {
@@ -329,7 +423,33 @@ export function CreatePRDialog({
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<div className="flex items-center justify-between">
<Label htmlFor="pr-title">PR Title</Label>
<Button
variant="ghost"
size="sm"
onClick={handleGenerateDescription}
disabled={isGeneratingDescription || isLoading}
className="h-6 px-2 text-xs"
title={
worktree.hasChanges
? 'Generate title and description from commits and uncommitted changes'
: 'Generate title and description from commits'
}
>
{isGeneratingDescription ? (
<>
<Spinner size="xs" className="mr-1" />
Generating...
</>
) : (
<>
<Sparkles className="w-3 h-3 mr-1" />
Generate with AI
</>
)}
</Button>
</div>
<Input
id="pr-title"
placeholder={worktree.branch}
@@ -350,6 +470,49 @@ export function CreatePRDialog({
</div>
<div className="flex flex-col gap-4">
{/* Remote selector - only show if multiple remotes are available */}
{remotes.length > 1 && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Push to Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchRemotes}
disabled={isLoadingRemotes}
className="h-6 px-2 text-xs"
>
{isLoadingRemotes ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<BranchAutocomplete

View File

@@ -313,8 +313,8 @@ export function DiscardWorktreeChangesDialog({
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
// No files selected by default
setSelectedFiles(new Set());
}
}
} catch (err) {

View File

@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { Copy } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
interface DuplicateCountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (count: number) => void;
featureTitle?: string;
}
export function DuplicateCountDialog({
open,
onOpenChange,
onConfirm,
featureTitle,
}: DuplicateCountDialogProps) {
const [count, setCount] = useState(2);
// Reset count when dialog opens
useEffect(() => {
if (open) {
setCount(2);
}
}, [open]);
const handleConfirm = () => {
if (count >= 1 && count <= 50) {
onConfirm(count);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5 text-primary" />
Duplicate as Child ×N
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Creates a chain of duplicates where each is a child of the previous, so they execute
sequentially.
{featureTitle && (
<span className="block mt-1 text-xs">
Source: <span className="font-medium">{featureTitle}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<label htmlFor="duplicate-count" className="text-sm text-muted-foreground mb-2 block">
Number of copies
</label>
<Input
id="duplicate-count"
type="number"
min={1}
max={50}
value={count}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
setCount(Math.min(50, Math.max(1, val)));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
className="w-full"
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1.5">Enter a number between 1 and 50</p>
</div>
<DialogFooter className="gap-2 sm:gap-2 pt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
Cancel
</Button>
<HotkeyButton
variant="default"
onClick={handleConfirm}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
disabled={count < 1 || count > 50}
>
<Copy className="w-4 h-4 mr-2" />
Create {count} {count === 1 ? 'Copy' : 'Copies'}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,14 +5,20 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { DuplicateCountDialog } from './duplicate-count-dialog';
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { MergeRebaseDialog, type PullStrategy } from './merge-rebase-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { SelectRemoteDialog, type SelectRemoteOperation } from './select-remote-dialog';
export { ViewCommitsDialog } from './view-commits-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';
export { StashChangesDialog } from './stash-changes-dialog';
export { ViewStashesDialog } from './view-stashes-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';

View File

@@ -21,10 +21,12 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
interface RemoteBranch {
name: string;
fullRef: string;
@@ -36,24 +38,29 @@ interface RemoteInfo {
branches: RemoteBranch[];
}
const logger = createLogger('PullResolveConflictsDialog');
const logger = createLogger('MergeRebaseDialog');
interface PullResolveConflictsDialogProps {
interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
onConfirm: (
worktree: WorktreeInfo,
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
}
export function PullResolveConflictsDialog({
export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PullResolveConflictsDialogProps) {
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -70,6 +77,7 @@ export function PullResolveConflictsDialog({
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
}
}, [open]);
@@ -161,7 +169,7 @@ export function PullResolveConflictsDialog({
const handleConfirm = () => {
if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch);
onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
};
@@ -174,10 +182,10 @@ export function PullResolveConflictsDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Pull & Resolve Conflicts
Merge & Rebase
</DialogTitle>
<DialogDescription>
Select a remote branch to pull from and resolve conflicts with{' '}
Select a remote branch to merge or rebase with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
@@ -225,13 +233,16 @@ export function PullResolveConflictsDialog({
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
@@ -264,13 +275,62 @@ export function PullResolveConflictsDialog({
)}
</div>
<div className="grid gap-2">
<Label htmlFor="strategy-select">Strategy</Label>
<Select
value={selectedStrategy}
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
>
<SelectTrigger id="strategy-select">
<SelectValue placeholder="Select a strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="merge"
description={
<span className="text-xs text-muted-foreground">
Creates a merge commit preserving history
</span>
}
>
<span className="flex items-center gap-2">
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Merge</span>
</span>
</SelectItem>
<SelectItem
value="rebase"
description={
<span className="text-xs text-muted-foreground">
Replays commits on top for linear history
</span>
}
>
<span className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">Rebase</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a feature task to pull from{' '}
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
any merge conflicts.
This will create a feature task to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
onto <span className="font-mono text-foreground">{selectedBranch}</span>
</>
) : (
<>
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}{' '}
and resolve any conflicts.
</p>
</div>
)}
@@ -287,7 +347,7 @@ export function PullResolveConflictsDialog({
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Pull & Resolve
Merge & Rebase
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -306,13 +306,16 @@ export function PushToRemoteDialog({
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { getErrorMessage } from '@/lib/utils';
import { Download, Upload, RefreshCw, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
interface RemoteInfo {
name: string;
url: string;
}
const logger = createLogger('SelectRemoteDialog');
export type SelectRemoteOperation = 'pull' | 'push';
interface SelectRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
operation: SelectRemoteOperation;
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
}
export function SelectRemoteDialog({
open,
onOpenChange,
worktree,
operation,
onConfirm,
}: SelectRemoteDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
}, [worktree]);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setError(null);
}
}, [open]);
// Auto-select default remote when remotes are loaded
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
// Default to 'origin' if available, otherwise first remote
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
setSelectedRemote(defaultRemote.name);
}
}, [remotes, selectedRemote]);
const handleRefresh = async () => {
if (!worktree) return;
setIsRefreshing(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const isPull = operation === 'pull';
const Icon = isPull ? Download : Upload;
const title = isPull ? 'Pull from Remote' : 'Push to Remote';
const actionLabel = isPull
? `Pull from ${selectedRemote || 'Remote'}`
: `Push to ${selectedRemote || 'Remote'}`;
const description = isPull ? (
<>
Select a remote to pull changes into{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span>
</>
) : (
<>
Select a remote to push{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span> to
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
{isPull ? (
<>
This will pull changes from{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
into your local branch.
</>
) : (
<>
This will push your local changes to{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>
.
</>
)}
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Icon className="w-4 h-4 mr-2" />
{actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,623 @@
import { useState, useEffect, useMemo, useCallback } 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 { Checkbox } from '@/components/ui/checkbox';
import {
Archive,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashed?: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
case '?':
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
case 'D':
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
case 'M':
case 'U':
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
case 'R':
case 'C':
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
default:
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return 'Changed';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.old ?? ''}
</span>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
export function StashChangesDialog({
open,
onOpenChange,
worktree,
onStashed,
}: StashChangesDialogProps) {
const [message, setMessage] = useState('');
const [isStashing, setIsStashing] = useState(false);
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map<string, ParsedFileDiff>();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getHttpApiClient();
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
if (!cancelled) setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
}
} catch (err) {
console.warn('Failed to load diffs for stash dialog:', err);
} finally {
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleStash = async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsStashing(true);
try {
const api = getHttpApiClient();
// Pass selected files if not all files are selected
const filesToStash =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.stashPush(
worktree.path,
message.trim() || undefined,
filesToStash
);
if (result.success && result.result) {
if (result.result.stashed) {
toast.success('Changes stashed', {
description: result.result.message || 'Your changes have been stashed',
});
setMessage('');
onOpenChange(false);
onStashed?.();
} else {
toast.info('No changes to stash');
}
} else {
toast.error('Failed to stash changes', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to stash changes', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsStashing(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
};
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setMessage('');
}
onOpenChange(isOpen);
}}
>
<DialogContent
className="sm:max-w-[700px] max-h-[85vh] flex flex-col"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stash Changes
</DialogTitle>
<DialogDescription>
Stash uncommitted changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
Files to stash
{isLoadingDiffs ? (
<Spinner size="sm" />
) : (
<span className="text-xs text-muted-foreground font-normal">
({selectedFiles.size}/{files.length} selected)
</span>
)}
</Label>
{files.length > 0 && (
<button
onClick={handleToggleAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
</div>
{isLoadingDiffs ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<Spinner size="sm" className="mr-2" />
<span className="text-sm">Loading changes...</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
{files.map((file) => {
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
)}
>
{/* Checkbox */}
<Checkbox
checked={isChecked}
onCheckedChange={() => handleToggleFile(file.path)}
className="flex-shrink-0"
/>
{/* Clickable file row to show diff */}
<button
onClick={() => handleFileClick(file.path)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
<span className="text-xs font-mono truncate flex-1 text-foreground">
{file.path}
</span>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
getStatusBadgeColor(file.status)
)}
>
{getStatusLabel(file.status)}
</span>
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-[10px] text-red-400 flex-shrink-0">
-{deletions}
</span>
)}
</button>
</div>
{/* Expanded diff view */}
{isExpanded && fileDiff && (
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div
key={hunkIndex}
className="border-b border-border-glass last:border-b-0"
>
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
{isExpanded && !fileDiff && (
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - diff preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Stash Message */}
<div className="space-y-2">
<label htmlFor="stash-message" className="text-sm font-medium">
Stash message <span className="text-muted-foreground">(optional)</span>
</label>
<Input
id="stash-message"
placeholder="e.g., Work in progress on login page"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isStashing}
autoFocus
/>
<p className="text-xs text-muted-foreground">
A descriptive message helps identify this stash later. Press{' '}
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
</kbd>{' '}
to stash.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStashing}>
Cancel
</Button>
<Button onClick={handleStash} disabled={isStashing || selectedFiles.size === 0}>
{isStashing ? (
<>
<Spinner size="xs" className="mr-2" />
Stashing...
</>
) : (
<>
<Archive className="w-4 h-4 mr-2" />
Stash
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ' Changes'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
GitCommit,
User,
Clock,
Copy,
Check,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface ViewCommitsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
function CommitEntryItem({
commit,
index,
isLast,
}: {
commit: CommitInfo;
index: number;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
className={cn('group relative rounded-md transition-colors', index === 0 && 'bg-muted/30')}
>
<div className="flex gap-3 py-2.5 px-3 hover:bg-muted/50 transition-colors rounded-md">
{/* Timeline dot and line */}
<div className="flex flex-col items-center pt-1.5 shrink-0">
<div
className={cn(
'w-2 h-2 rounded-full border-2',
index === 0 ? 'border-primary bg-primary' : 'border-muted-foreground/40 bg-background'
)}
/>
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">{commit.subject}</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-3">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time dateTime={commit.date} title={new Date(commit.date).toLocaleString()}>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={() => setExpanded(!expanded)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && hasFiles && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
const INITIAL_COMMIT_LIMIT = 30;
const LOAD_MORE_INCREMENT = 30;
const MAX_COMMIT_LIMIT = 100;
export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsDialogProps) {
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [limit, setLimit] = useState(INITIAL_COMMIT_LIMIT);
const [hasMore, setHasMore] = useState(false);
const fetchCommits = useCallback(
async (fetchLimit: number, isLoadMore = false) => {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setError(null);
setCommits([]);
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getCommitLog(worktree!.path, fetchLimit);
if (result.success && result.result) {
// Ensure each commit has a files array (backwards compat if server hasn't been rebuilt)
const fetchedCommits = result.result.commits.map((c: CommitInfo) => ({
...c,
files: c.files || [],
}));
setCommits(fetchedCommits);
// If we got back exactly as many commits as we requested, there may be more
setHasMore(fetchedCommits.length === fetchLimit && fetchLimit < MAX_COMMIT_LIMIT);
} else {
setError(result.error || 'Failed to load commits');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[worktree]
);
useEffect(() => {
if (!open || !worktree) return;
setLimit(INITIAL_COMMIT_LIMIT);
setHasMore(false);
fetchCommits(INITIAL_COMMIT_LIMIT);
}, [open, worktree, fetchCommits]);
const handleLoadMore = () => {
const newLimit = Math.min(limit + LOAD_MORE_INCREMENT, MAX_COMMIT_LIMIT);
setLimit(newLimit);
fetchCommits(newLimit, true);
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit History
</DialogTitle>
<DialogDescription>
Recent commits on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found</p>
</div>
)}
{!isLoading && !error && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => (
<CommitEntryItem
key={commit.hash}
commit={commit}
index={index}
isLast={index === commits.length - 1 && !hasMore}
/>
))}
{hasMore && (
<div className="flex justify-center pt-3 pb-1">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer px-4 py-2 rounded-md hover:bg-muted/50"
>
{isLoadingMore ? (
<>
<Spinner size="sm" />
Loading more commits...
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Load more commits
</>
)}
</button>
</div>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,410 @@
import { useEffect, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Archive,
ChevronDown,
ChevronRight,
Clock,
FileText,
GitBranch,
Play,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
interface ViewStashesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashApplied?: () => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unknown date';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function StashEntryItem({
stash,
onApply,
onPop,
onDrop,
isApplying,
isDropping,
}: {
stash: StashEntry;
onApply: (index: number) => void;
onPop: (index: number) => void;
onDrop: (index: number) => void;
isApplying: boolean;
isDropping: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const isBusy = isApplying || isDropping;
// Clean up the stash message for display
const displayMessage =
stash.message.replace(/^(WIP on|On) [^:]+:\s*[a-f0-9]+\s*/, '').trim() || stash.message;
return (
<div
className={cn(
'group relative rounded-md border bg-card transition-colors',
'hover:border-primary/30'
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3">
{/* Expand toggle & stash icon */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 pt-0.5 text-muted-foreground hover:text-foreground transition-colors"
disabled={stash.files.length === 0}
>
{stash.files.length > 0 ? (
expanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)
) : (
<span className="w-3.5" />
)}
<Archive className="w-3.5 h-3.5" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium leading-snug break-words">{displayMessage}</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
stash@{'{' + stash.index + '}'}
</span>
{stash.branch && (
<span className="inline-flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{stash.branch}
</span>
)}
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={stash.date}
title={
!isNaN(new Date(stash.date).getTime())
? new Date(stash.date).toLocaleString()
: stash.date
}
>
{formatRelativeDate(stash.date)}
</time>
</span>
{stash.files.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="w-3 h-3" />
{stash.files.length} file{stash.files.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onApply(stash.index)}
disabled={isBusy}
title="Apply stash (keep in stash list)"
>
{isApplying ? <Spinner size="xs" /> : <Play className="w-3 h-3 mr-1" />}
Apply
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onPop(stash.index)}
disabled={isBusy}
title="Pop stash (apply and remove from stash list)"
>
{isApplying ? <Spinner size="xs" /> : 'Pop'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => onDrop(stash.index)}
disabled={isBusy}
title="Delete this stash"
>
{isDropping ? <Spinner size="xs" /> : <Trash2 className="w-3 h-3" />}
</Button>
</div>
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && stash.files.length > 0 && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{stash.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function ViewStashesDialog({
open,
onOpenChange,
worktree,
onStashApplied,
}: ViewStashesDialogProps) {
const [stashes, setStashes] = useState<StashEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
const fetchStashes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashList(worktree.path);
if (result.success && result.result) {
setStashes(result.result.stashes);
} else {
setError(result.error || 'Failed to load stashes');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load stashes');
} finally {
setIsLoading(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchStashes();
}
if (!open) {
setStashes([]);
setError(null);
}
}, [open, worktree, fetchStashes]);
const handleApply = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, false);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash applied with conflicts', {
description: 'Please resolve the merge conflicts.',
});
} else {
toast.success('Stash applied');
}
onStashApplied?.();
} else {
toast.error('Failed to apply stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to apply stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handlePop = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash popped with conflicts', {
description: 'Please resolve the merge conflicts. The stash was removed.',
});
} else {
toast.success('Stash popped', {
description: 'Changes applied and stash removed.',
});
}
// Refresh the stash list since the stash was removed
await fetchStashes();
onStashApplied?.();
} else {
toast.error('Failed to pop stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to pop stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handleDrop = async (stashIndex: number) => {
if (!worktree) return;
setDroppingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashDrop(worktree.path, stashIndex);
if (result.success) {
toast.success('Stash deleted');
// Refresh the stash list
await fetchStashes();
} else {
toast.error('Failed to delete stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to delete stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setDroppingIndex(null);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stashes
</DialogTitle>
<DialogDescription>
Stashed changes in{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[300px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading stashes...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && stashes.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Archive className="w-8 h-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No stashes found</p>
<p className="text-xs text-muted-foreground">
Use &quot;Stash Changes&quot; to save your uncommitted changes
</p>
</div>
)}
{!isLoading && !error && stashes.length > 0 && (
<div className="space-y-2 mt-2">
{stashes.map((stash) => (
<StashEntryItem
key={stash.index}
stash={stash}
onApply={handleApply}
onPop={handlePop}
onDrop={handleDrop}
isApplying={applyingIndex === stash.index}
isDropping={droppingIndex === stash.index}
/>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -660,9 +660,28 @@ export function useBoardActions({
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
verifyFeatureMutation.mutate(feature.id);
try {
const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) {
// Immediately move card to verified column (optimistic update)
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
});
toast.success('Verification passed', {
description: `Verified: ${truncateDescription(feature.description)}`,
});
} else {
toast.error('Verification failed', {
description: `Tests did not pass for: ${truncateDescription(feature.description)}`,
});
}
} catch {
// Error toast is already shown by the mutation's onError handler
}
},
[currentProject, verifyFeatureMutation]
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
);
const handleResumeFeature = useCallback(
@@ -1176,6 +1195,49 @@ export function useBoardActions({
[handleAddFeature]
);
const handleDuplicateAsChildMultiple = useCallback(
async (feature: Feature, count: number) => {
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
let parentFeature = feature;
for (let i = 0; i < count; i++) {
const {
id: _id,
status: _status,
startedAt: _startedAt,
error: _error,
summary: _summary,
spec: _spec,
passes: _passes,
planSpec: _planSpec,
descriptionHistory: _descriptionHistory,
titleGenerating: _titleGenerating,
...featureData
} = parentFeature;
const duplicatedFeatureData = {
...featureData,
// Each duplicate depends on the previous one in the chain
dependencies: [parentFeature.id],
};
await handleAddFeature(duplicatedFeatureData);
// Get the newly created feature (last added feature) to use as parent for next iteration
const currentFeatures = useAppStore.getState().features;
const newestFeature = currentFeatures[currentFeatures.length - 1];
if (newestFeature) {
parentFeature = newestFeature;
}
}
toast.success(`Created ${count} chained duplicates`, {
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
});
},
[handleAddFeature]
);
return {
handleAddFeature,
handleUpdateFeature,
@@ -1197,5 +1259,6 @@ export function useBoardActions({
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
};
}

View File

@@ -180,19 +180,17 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
);
try {
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
try {
await api.features.delete(currentProject.path, featureId);
// Invalidate to sync with server state
queryClient.invalidateQueries({
@@ -207,6 +205,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw error;
}
},
[currentProject, queryClient]

View File

@@ -48,6 +48,7 @@ interface KanbanBoardProps {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -286,6 +287,7 @@ export function KanbanBoard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -575,6 +577,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
@@ -619,6 +626,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -34,9 +34,13 @@ import {
Undo2,
Zap,
FlaskConical,
History,
Archive,
Cherry,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
@@ -60,6 +64,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
/** When true, git repo status is still being loaded */
isLoadingGitStatus?: boolean;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
@@ -80,6 +86,7 @@ interface WorktreeActionsDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -99,6 +106,12 @@ interface WorktreeActionsDropdownProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -114,6 +127,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning,
devServerInfo,
gitRepoStatus,
isLoadingGitStatus = false,
standalone = false,
isAutoModeRunning = false,
hasTestCommand = false,
@@ -128,6 +142,7 @@ export function WorktreeActionsDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -144,6 +159,9 @@ export function WorktreeActionsDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -203,8 +221,18 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Warning label when git operations are not available */}
{!canPerformGitOps && (
{/* Loading indicator while git status is being determined */}
{isLoadingGitStatus && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-muted-foreground">
<Spinner size="xs" variant="muted" />
Checking git status...
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{/* Warning label when git operations are not available (only show once loaded) */}
{!isLoadingGitStatus && !canPerformGitOps && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle className="w-3.5 h-3.5" />
@@ -387,10 +415,90 @@ export function WorktreeActionsDropdown({
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
Merge & Rebase
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onViewCommits(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onCherryPick(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!gitRepoStatus.isGitRepo) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
disabled={!gitRepoStatus.isGitRepo}
/>
</div>
<DropdownMenuSubContent>
{onViewStashes && (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (

View File

@@ -91,6 +91,7 @@ export interface WorktreeDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -107,6 +108,12 @@ export interface WorktreeDropdownProps {
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
}
/**
@@ -168,6 +175,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -184,6 +192,9 @@ export function WorktreeDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -442,6 +453,7 @@ export function WorktreeDropdown({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -455,6 +467,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -471,6 +484,9 @@ export function WorktreeDropdown({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
)}

View File

@@ -59,6 +59,7 @@ interface WorktreeTabProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -78,6 +79,12 @@ interface WorktreeTabProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
@@ -122,6 +129,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -138,6 +146,9 @@ export function WorktreeTab({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
@@ -418,6 +429,7 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunning}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -431,6 +443,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -447,6 +460,9 @@ export function WorktreeTab({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -46,18 +46,22 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pullMutation.isPending) return;
pullMutation.mutate(worktree.path);
pullMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pushMutation.isPending) return;
pushMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pushMutation]

View File

@@ -33,10 +33,16 @@ import {
import { useAppStore } from '@/store/app-store';
import {
ViewWorktreeChangesDialog,
ViewCommitsDialog,
PushToRemoteDialog,
MergeWorktreeDialog,
DiscardWorktreeChangesDialog,
SelectRemoteDialog,
StashChangesDialog,
ViewStashesDialog,
CherryPickDialog,
} from '../dialogs';
import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
@@ -380,6 +386,10 @@ export function WorktreePanel({
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// View commits dialog state
const [viewCommitsDialogOpen, setViewCommitsDialogOpen] = useState(false);
const [viewCommitsWorktree, setViewCommitsWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
@@ -396,6 +406,21 @@ export function WorktreePanel({
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
// Select remote dialog state (for pull/push with multiple remotes)
const [selectRemoteDialogOpen, setSelectRemoteDialogOpen] = useState(false);
const [selectRemoteWorktree, setSelectRemoteWorktree] = useState<WorktreeInfo | null>(null);
const [selectRemoteOperation, setSelectRemoteOperation] = useState<SelectRemoteOperation>('pull');
// Stash dialog states
const [stashChangesDialogOpen, setStashChangesDialogOpen] = useState(false);
const [stashChangesWorktree, setStashChangesWorktree] = useState<WorktreeInfo | null>(null);
const [viewStashesDialogOpen, setViewStashesDialogOpen] = useState(false);
const [viewStashesWorktree, setViewStashesWorktree] = useState<WorktreeInfo | null>(null);
// Cherry-pick dialog states
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
@@ -464,6 +489,11 @@ export function WorktreePanel({
setViewChangesDialogOpen(true);
}, []);
const handleViewCommits = useCallback((worktree: WorktreeInfo) => {
setViewCommitsWorktree(worktree);
setViewCommitsDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
@@ -473,6 +503,36 @@ export function WorktreePanel({
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle stash changes dialog
const handleStashChanges = useCallback((worktree: WorktreeInfo) => {
setStashChangesWorktree(worktree);
setStashChangesDialogOpen(true);
}, []);
const handleStashCompleted = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle view stashes dialog
const handleViewStashes = useCallback((worktree: WorktreeInfo) => {
setViewStashesWorktree(worktree);
setViewStashesDialogOpen(true);
}, []);
const handleStashApplied = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle cherry-pick dialog
const handleCherryPick = useCallback((worktree: WorktreeInfo) => {
setCherryPickWorktree(worktree);
setCherryPickDialogOpen(true);
}, []);
const handleCherryPicked = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -491,6 +551,68 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Handle pull with remote selection when multiple remotes exist
const handlePullWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePull(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePull(worktree);
}
},
[handlePull]
);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePush(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePush(worktree);
}
},
[handlePush]
);
// Handle confirming remote selection for pull/push
const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') {
handlePull(worktree, remote);
} else {
handlePush(worktree, remote);
}
fetchBranches(worktree.path);
fetchWorktrees();
},
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
@@ -585,19 +707,21 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -614,6 +738,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
hasInitScript={hasInitScript}
/>
)}
@@ -656,6 +783,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -664,6 +798,31 @@ export function WorktreePanel({
onDiscarded={handleDiscardCompleted}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
@@ -681,6 +840,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -753,13 +921,14 @@ export function WorktreePanel({
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -776,6 +945,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
/>
{useWorktreesEnabled && (
@@ -846,13 +1018,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -869,6 +1042,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -919,13 +1094,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -942,6 +1118,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -987,6 +1165,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -1012,6 +1197,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -1032,6 +1226,31 @@ export function WorktreePanel({
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}

View File

@@ -397,7 +397,7 @@ export function LoginView() {
// Login form (awaiting_login or logging_in)
const isLoggingIn = state.phase === 'logging_in';
const apiKey = state.phase === 'awaiting_login' ? state.apiKey : state.apiKey;
const apiKey = state.apiKey;
const error = state.phase === 'awaiting_login' ? state.error : null;
return (

View File

@@ -10,6 +10,7 @@ import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
@@ -28,8 +29,9 @@ interface SettingsProject {
}
export function ProjectSettingsView() {
const { currentProject, moveProjectToTrash } = useAppStore();
const { currentProject, moveProjectToTrash, removeProject } = useAppStore();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
@@ -98,6 +100,7 @@ export function ProjectSettingsView() {
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
onRemoveFromAutomakerClick={() => setShowRemoveFromAutomakerDialog(true)}
/>
);
default:
@@ -178,6 +181,14 @@ export function ProjectSettingsView() {
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Remove from Automaker Confirmation Dialog */}
<RemoveFromAutomakerDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={setShowRemoveFromAutomakerDialog}
project={currentProject}
onConfirm={removeProject}
/>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
@@ -11,6 +12,9 @@ import {
RotateCcw,
Trash2,
PanelBottomClose,
Copy,
Plus,
FolderOpen,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -19,6 +23,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
interface WorktreePreferencesSectionProps {
project: Project;
@@ -42,6 +47,8 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const getWorktreeCopyFiles = useAppStore((s) => s.getWorktreeCopyFiles);
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
@@ -54,6 +61,11 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Copy files state
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
const copyFiles = getWorktreeCopyFiles(project.path);
// Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path);
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
@@ -93,6 +105,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
response.settings.autoDismissInitScriptIndicator
);
}
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
}
} catch (error) {
if (!isCancelled) {
@@ -112,6 +127,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
]);
// Load init script content when project changes
@@ -219,6 +235,97 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setScriptContent(value);
}, []);
// Add a new file path to copy list
const handleAddCopyFile = useCallback(async () => {
const trimmed = newCopyFilePath.trim();
if (!trimmed) return;
// Normalize: remove leading ./ or /
const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, '');
if (!normalized) return;
// Check for duplicates
const currentFiles = getWorktreeCopyFiles(project.path);
if (currentFiles.includes(normalized)) {
toast.error('File already in list', {
description: `"${normalized}" is already configured for copying.`,
});
return;
}
const updatedFiles = [...currentFiles, normalized];
setWorktreeCopyFiles(project.path, updatedFiles);
setNewCopyFilePath('');
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file added', {
description: `"${normalized}" will be copied to new worktrees.`,
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
}, [project.path, newCopyFilePath, getWorktreeCopyFiles, setWorktreeCopyFiles]);
// Remove a file path from copy list
const handleRemoveCopyFile = useCallback(
async (filePath: string) => {
const currentFiles = getWorktreeCopyFiles(project.path);
const updatedFiles = currentFiles.filter((f) => f !== filePath);
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file removed');
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
// Handle files selected from the file selector dialog
const handleFileSelectorSelect = useCallback(
async (paths: string[]) => {
const currentFiles = getWorktreeCopyFiles(project.path);
// Filter out duplicates
const newPaths = paths.filter((p) => !currentFiles.includes(p));
if (newPaths.length === 0) {
toast.info('All selected files are already in the list');
return;
}
const updatedFiles = [...currentFiles, ...newPaths];
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, {
description: newPaths.map((p) => `"${p}"`).join(', '),
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
return (
<div
className={cn(
@@ -387,6 +494,92 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Copy Files to Worktrees</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Specify files or directories (relative to project root) to automatically copy into new
worktrees. Useful for untracked files like{' '}
<code className="font-mono text-foreground/60">.env</code>,{' '}
<code className="font-mono text-foreground/60">.env.local</code>, or local config files
that aren&apos;t committed to git.
</p>
{/* Current file list */}
{copyFiles.length > 0 && (
<div className="space-y-1.5">
{copyFiles.map((filePath) => (
<div
key={filePath}
className="flex items-center gap-2 group/item px-3 py-1.5 rounded-lg bg-accent/20 hover:bg-accent/40 transition-colors"
>
<FileCode className="w-3.5 h-3.5 text-muted-foreground/60 flex-shrink-0" />
<code className="font-mono text-sm text-foreground/80 flex-1 truncate">
{filePath}
</code>
<button
onClick={() => handleRemoveCopyFile(filePath)}
className="p-0.5 rounded text-muted-foreground/50 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
title={`Remove ${filePath}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Add new file input */}
<div className="flex items-center gap-2">
<Input
value={newCopyFilePath}
onChange={(e) => setNewCopyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCopyFile();
}
}}
placeholder=".env, config/local.json, etc."
className="flex-1 h-8 text-sm font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddCopyFile}
disabled={!newCopyFilePath.trim()}
className="gap-1.5 h-8"
>
<Plus className="w-3.5 h-3.5" />
Add
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFileSelectorOpen(true)}
className="gap-1.5 h-8"
>
<FolderOpen className="w-3.5 h-3.5" />
Browse
</Button>
</div>
{/* File selector dialog */}
<ProjectFileSelectorDialog
open={fileSelectorOpen}
onOpenChange={setFileSelectorOpen}
onSelect={handleFileSelectorSelect}
projectPath={project.path}
existingFiles={copyFiles}
/>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -1,4 +1,5 @@
export { DeleteProjectDialog } from './delete-project-dialog';
export { RemoveFromAutomakerDialog } from './remove-from-automaker-dialog';
export { KeyboardMapDialog } from './keyboard-map-dialog';
export { SettingsHeader } from './settings-header';
export { SettingsNavigation } from './settings-navigation';

View File

@@ -0,0 +1,49 @@
import { Folder, LogOut } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
interface RemoveFromAutomakerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onConfirm: (projectId: string) => void;
}
export function RemoveFromAutomakerDialog({
open,
onOpenChange,
project,
onConfirm,
}: RemoveFromAutomakerDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
}
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
onConfirm={handleConfirm}
title="Remove from Automaker"
description="Remove this project from Automaker? The folder will remain on disk and can be re-added later."
icon={LogOut}
iconClassName="text-muted-foreground"
confirmText="Remove from Automaker"
confirmVariant="secondary"
>
{project && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
</div>
</div>
)}
</ConfirmDialog>
);
}

View File

@@ -1,14 +1,19 @@
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { Trash2, Folder, AlertTriangle, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
onRemoveFromAutomakerClick?: () => void;
}
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
export function DangerZoneSection({
project,
onDeleteClick,
onRemoveFromAutomakerClick,
}: DangerZoneSectionProps) {
return (
<div
className={cn(
@@ -28,33 +33,57 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
<p className="text-sm text-muted-foreground/80 ml-12">Destructive project actions.</p>
</div>
<div className="p-6 space-y-4">
{/* Project Delete */}
{project ? (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
<>
{/* Remove from Automaker */}
{onRemoveFromAutomakerClick && (
<div className="flex items-start justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border">
<div className="min-w-0">
<p className="font-medium text-foreground">Remove from Automaker</p>
<p className="text-xs text-muted-foreground mt-0.5">
Remove this project from Automaker without deleting any files from disk. You can
re-add it later by opening the folder.
</p>
</div>
<Button
variant="secondary"
onClick={onRemoveFromAutomakerClick}
data-testid="remove-from-automaker-button"
className="shrink-0 transition-all duration-200 ease-out hover:scale-[1.02] active:scale-[0.98]"
>
<LogOut className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
)}
{/* Project Delete / Move to Trash */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
</div>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Move to Trash
</Button>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground/60 text-center py-4">No project selected.</p>
)}

View File

@@ -88,6 +88,9 @@ export function MobileTerminalShortcuts({
/** Handles arrow key press with long-press repeat support. */
const handleArrowPress = useCallback(
(data: string) => {
// Cancel any in-flight timeout/interval before starting a new one
// to prevent timer leaks when multiple touches occur.
clearRepeat();
sendKey(data);
// Start repeat after 400ms hold, then every 80ms
repeatTimeoutRef.current = setTimeout(() => {
@@ -96,7 +99,7 @@ export function MobileTerminalShortcuts({
}, 80);
}, 400);
},
[sendKey]
[clearRepeat, sendKey]
);
const handleArrowRelease = useCallback(() => {

View File

@@ -9,6 +9,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { toast } from 'sonner';
import type { Feature } from '@/store/app-store';
/**
* Start running a feature in auto mode
@@ -159,9 +160,26 @@ export function useVerifyFeature(projectPath: string) {
if (!result.success) {
throw new Error(result.error || 'Failed to verify feature');
}
return result;
return { ...result, featureId };
},
onSuccess: () => {
onSuccess: (data) => {
// If verification passed, optimistically update React Query cache
// to move the feature to 'verified' status immediately
if (data.passes) {
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(projectPath)
);
if (previousFeatures) {
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(projectPath),
previousFeatures.map((f) =>
f.id === data.featureId
? { ...f, status: 'verified' as const, justFinishedAt: undefined }
: f
)
);
}
}
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {

View File

@@ -126,10 +126,18 @@ export function usePushWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => {
mutationFn: async ({
worktreePath,
force,
remote,
}: {
worktreePath: string;
force?: boolean;
remote?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.push(worktreePath, force);
const result = await api.worktree.push(worktreePath, force, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to push changes');
}
@@ -156,10 +164,10 @@ export function usePullWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (worktreePath: string) => {
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.pull(worktreePath);
const result = await api.worktree.pull(worktreePath, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to pull changes');
}
@@ -283,17 +291,6 @@ export function useMergeWorktree(projectPath: string) {
});
}
/**
* Result from the switch branch API call
*/
interface SwitchBranchResult {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts?: boolean;
stashedChanges?: boolean;
}
/**
* Switch to a different branch
*
@@ -316,14 +313,17 @@ export function useSwitchBranch(options?: {
}: {
worktreePath: string;
branchName: string;
}): Promise<SwitchBranchResult> => {
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.switchBranch(worktreePath, branchName);
if (!result.success) {
throw new Error(result.error || 'Failed to switch branch');
}
return result.result as SwitchBranchResult;
if (!result.result) {
throw new Error('Switch branch returned no result');
}
return result.result;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
@@ -388,6 +388,36 @@ export function useCheckoutBranch() {
});
}
/**
* Generate a PR title and description from branch diff
*
* @returns Mutation for generating a PR description
*/
export function useGeneratePRDescription() {
return useMutation({
mutationFn: async ({
worktreePath,
baseBranch,
}: {
worktreePath: string;
baseBranch?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.generatePRDescription(worktreePath, baseBranch);
if (!result.success) {
throw new Error(result.error || 'Failed to generate PR description');
}
return { title: result.title ?? '', body: result.body ?? '' };
},
onError: (error: Error) => {
toast.error('Failed to generate PR description', {
description: error.message,
});
},
});
}
/**
* Generate a commit message from git diff
*

View File

@@ -144,8 +144,10 @@ export function useGeminiUsage(enabled = true) {
throw new Error('Gemini API bridge unavailable');
}
const result = await api.gemini.getUsage();
// Server always returns a response with 'authenticated' field, even on error
// So we can safely cast to GeminiUsage
// Check if result is an error-only response (no 'authenticated' field means it's the error variant)
if (!('authenticated' in result) && 'error' in result) {
throw new Error(result.message || result.error);
}
return result as GeminiUsage;
},
enabled,

View File

@@ -86,6 +86,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
globalMaxConcurrency,
} = useAppStore(
useShallow((state) => ({
autoModeByWorktree: state.autoModeByWorktree,
@@ -100,6 +101,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
globalMaxConcurrency: state.maxConcurrency,
}))
);
@@ -143,11 +145,13 @@ export function useAutoMode(worktree?: WorktreeInfo) {
const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = worktreeAutoModeState.runningTasks;
// Use getMaxConcurrencyForWorktree which properly falls back to the global
// maxConcurrency setting, instead of DEFAULT_MAX_CONCURRENCY (1) which would
// incorrectly block agents when the user has set a higher global limit
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive
// autoModeByWorktree store slice) so canStartNewTask stays reactive when
// refreshStatus updates worktree state or when the global setting changes.
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project.
const maxConcurrency = projectId
? getMaxConcurrencyForWorktree(projectId, branchName)
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency)
: DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit

View File

@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
@@ -95,6 +96,11 @@ export function useProjectSettingsLoader() {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
}
// Apply worktreeCopyFiles if present
if (settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
}
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
// These are stored directly on the project, so we need to update both
// currentProject AND the projects array to keep them in sync
@@ -152,6 +158,7 @@ export function useProjectSettingsLoader() {
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setCurrentProject,
]);
}

View File

@@ -4,8 +4,8 @@ import {
type ClaudeAuthMethod,
type CodexAuthMethod,
type ZaiAuthMethod,
type GeminiAuthMethod,
} from '@/store/setup-store';
import type { GeminiAuthStatus } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
@@ -159,11 +159,16 @@ export function useProviderAuthInit() {
// Set Auth status - always set a status to mark initialization as complete
if (result.auth) {
const auth = result.auth;
const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none'];
const validMethods: GeminiAuthStatus['method'][] = [
'google_login',
'api_key',
'vertex_ai',
'none',
];
const method = validMethods.includes(auth.method as GeminiAuthMethod)
? (auth.method as GeminiAuthMethod)
: ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod);
const method = validMethods.includes(auth.method as GeminiAuthStatus['method'])
? (auth.method as GeminiAuthStatus['method'])
: ((auth.authenticated ? 'google_login' : 'none') as GeminiAuthStatus['method']);
setGeminiAuthStatus({
authenticated: auth.authenticated,

View File

@@ -202,6 +202,7 @@ export interface CreatePROptions {
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
}
// Re-export types from electron.d.ts for external use
@@ -2195,6 +2196,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
generatePRDescription: async (worktreePath: string, baseBranch?: string) => {
console.log('[Mock] Generating PR description for:', { worktreePath, baseBranch });
return {
success: true,
title: 'Add new feature implementation',
body: '## Summary\n- Added new feature\n\n## Changes\n- Implementation details here',
};
},
push: async (worktreePath: string, force?: boolean, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
@@ -2249,22 +2259,24 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
pull: async (worktreePath: string) => {
console.log('[Mock] Pulling latest changes for:', worktreePath);
pull: async (worktreePath: string, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pulling latest changes for:', { worktreePath, remote: targetRemote });
return {
success: true,
result: {
branch: 'main',
pulled: true,
message: 'Pulled latest changes',
message: `Pulled latest changes from ${targetRemote}`,
},
};
},
checkoutBranch: async (worktreePath: string, branchName: string) => {
checkoutBranch: async (worktreePath: string, branchName: string, baseBranch?: string) => {
console.log('[Mock] Creating and checking out branch:', {
worktreePath,
branchName,
baseBranch,
});
return {
success: true,
@@ -2303,6 +2315,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
previousBranch: 'main',
currentBranch: branchName,
message: `Switched to branch '${branchName}'`,
hasConflicts: false,
stashedChanges: false,
},
};
},
@@ -2631,6 +2645,101 @@ function createMockWorktreeAPI(): WorktreeAPI {
console.log('[Mock] Unsubscribing from test runner events');
};
},
getCommitLog: async (worktreePath: string, limit?: number) => {
console.log('[Mock] Getting commit log:', { worktreePath, limit });
return {
success: true,
result: {
branch: 'main',
commits: [
{
hash: 'abc1234567890',
shortHash: 'abc1234',
author: 'Mock User',
authorEmail: 'mock@example.com',
date: new Date().toISOString(),
subject: 'Mock commit message',
body: '',
files: ['src/index.ts', 'package.json'],
},
],
total: 1,
},
};
},
stashPush: async (worktreePath: string, message?: string, files?: string[]) => {
console.log('[Mock] Stash push:', { worktreePath, message, files });
return {
success: true,
result: {
stashed: true,
branch: 'main',
message: message || 'WIP on main',
},
};
},
stashList: async (worktreePath: string) => {
console.log('[Mock] Stash list:', { worktreePath });
return {
success: true,
result: {
stashes: [],
total: 0,
},
};
},
stashApply: async (worktreePath: string, stashIndex: number, pop?: boolean) => {
console.log('[Mock] Stash apply:', { worktreePath, stashIndex, pop });
return {
success: true,
result: {
applied: true,
hasConflicts: false,
operation: pop ? ('pop' as const) : ('apply' as const),
stashIndex,
message: `Stash ${pop ? 'popped' : 'applied'} successfully`,
},
};
},
stashDrop: async (worktreePath: string, stashIndex: number) => {
console.log('[Mock] Stash drop:', { worktreePath, stashIndex });
return {
success: true,
result: {
dropped: true,
stashIndex,
message: `Stash stash@{${stashIndex}} dropped successfully`,
},
};
},
cherryPick: async (
worktreePath: string,
commitHashes: string[],
options?: { noCommit?: boolean }
) => {
console.log('[Mock] Cherry-pick:', { worktreePath, commitHashes, options });
return {
success: true,
result: {
cherryPicked: true,
commitHashes,
branch: 'main',
message: `Cherry-picked ${commitHashes.length} commit(s) successfully`,
},
};
},
getBranchCommitLog: async (worktreePath: string, branchName?: string, limit?: number) => {
console.log('[Mock] Get branch commit log:', { worktreePath, branchName, limit });
return {
success: true,
result: {
branch: branchName || 'main',
commits: [],
total: 0,
},
};
},
};
}

View File

@@ -2121,6 +2121,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/commit', { worktreePath, message, files }),
generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }),
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
push: (worktreePath: string, force?: boolean, remote?: string) =>
this.post('/api/worktree/push', { worktreePath, force, remote }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
@@ -2133,9 +2135,10 @@ 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 }),
pull: (worktreePath: string, remote?: string) =>
this.post('/api/worktree/pull', { worktreePath, remote }),
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
listBranches: (worktreePath: string, includeRemote?: boolean) =>
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) =>
@@ -2216,6 +2219,19 @@ export class HttpApiClient implements ElectronAPI {
startTests: (worktreePath: string, options?: { projectPath?: string; testFile?: string }) =>
this.post('/api/worktree/start-tests', { worktreePath, ...options }),
stopTests: (sessionId: string) => this.post('/api/worktree/stop-tests', { sessionId }),
getCommitLog: (worktreePath: string, limit?: number) =>
this.post('/api/worktree/commit-log', { worktreePath, limit }),
stashPush: (worktreePath: string, message?: string, files?: string[]) =>
this.post('/api/worktree/stash-push', { worktreePath, message, files }),
stashList: (worktreePath: string) => this.post('/api/worktree/stash-list', { worktreePath }),
stashApply: (worktreePath: string, stashIndex: number, pop?: boolean) =>
this.post('/api/worktree/stash-apply', { worktreePath, stashIndex, pop }),
stashDrop: (worktreePath: string, stashIndex: number) =>
this.post('/api/worktree/stash-drop', { worktreePath, stashIndex }),
cherryPick: (worktreePath: string, commitHashes: string[], options?: { noCommit?: boolean }) =>
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
const params = new URLSearchParams();
if (worktreePath) params.append('worktreePath', worktreePath);
@@ -2582,6 +2598,7 @@ export class HttpApiClient implements ElectronAPI {
showInitScriptIndicator?: boolean;
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
worktreeCopyFiles?: string[];
lastSelectedSessionId?: string;
testCommand?: string;
};

View File

@@ -335,6 +335,7 @@ const initialState: AppState = {
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreeCopyFilesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
@@ -359,10 +360,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}
},
removeProject: (projectId) =>
removeProject: (projectId: string) => {
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
})),
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to storage
saveProjects(get().projects);
},
moveProjectToTrash: (projectId: string) => {
const project = get().projects.find((p) => p.id === projectId);
@@ -2394,6 +2400,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return projectOverride !== undefined ? projectOverride : get().useWorktrees;
},
// Worktree Copy Files actions
setWorktreeCopyFiles: (projectPath, files) =>
set((state) => ({
worktreeCopyFilesByProject: {
...state.worktreeCopyFilesByProject,
[projectPath]: files,
},
})),
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { GeminiAuthStatus } from '@automaker/types';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
// CLI Installation Status
@@ -127,21 +128,8 @@ export interface ZaiAuthStatus {
error?: string;
}
// Gemini Auth Method
export type GeminiAuthMethod =
| 'cli_login' // Gemini CLI is installed and authenticated
| 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable
| 'api_key' // Manually stored API key
| 'none';
// Gemini Auth Status
export interface GeminiAuthStatus {
authenticated: boolean;
method: GeminiAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
error?: string;
}
// GeminiAuthStatus is imported from @automaker/types (method: 'google_login' | 'api_key' | 'vertex_ai' | 'none')
export type { GeminiAuthStatus };
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =

View File

@@ -341,6 +341,10 @@ export interface AppState {
// undefined = use global setting, true/false = project-specific override
useWorktreesByProject: Record<string, boolean | undefined>;
// Worktree Copy Files (per-project, keyed by project path)
// List of relative file paths to copy from project root into new worktrees
worktreeCopyFilesByProject: Record<string, string[]>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -756,6 +760,10 @@ export interface AppActions {
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
// Worktree Copy Files actions (per-project)
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
getWorktreeCopyFiles: (projectPath: string) => string[];
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;

View File

@@ -22,6 +22,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useAppStore } from '@/store/app-store';
interface UICacheState {
/** ID of the currently selected project */
@@ -82,13 +83,27 @@ export function syncUICache(appState: {
worktreePanelCollapsed?: boolean;
collapsedNavSections?: Record<string, boolean>;
}): void {
useUICacheStore.getState().updateFromAppStore({
cachedProjectId: appState.currentProject?.id ?? null,
cachedSidebarOpen: appState.sidebarOpen ?? true,
cachedSidebarStyle: appState.sidebarStyle ?? 'unified',
cachedWorktreePanelCollapsed: appState.worktreePanelCollapsed ?? false,
cachedCollapsedNavSections: appState.collapsedNavSections ?? {},
});
const update: Partial<UICacheState> = {};
if ('currentProject' in appState) {
update.cachedProjectId = appState.currentProject?.id ?? null;
}
if ('sidebarOpen' in appState) {
update.cachedSidebarOpen = appState.sidebarOpen;
}
if ('sidebarStyle' in appState) {
update.cachedSidebarStyle = appState.sidebarStyle;
}
if ('worktreePanelCollapsed' in appState) {
update.cachedWorktreePanelCollapsed = appState.worktreePanelCollapsed;
}
if ('collapsedNavSections' in appState) {
update.cachedCollapsedNavSections = appState.collapsedNavSections;
}
if (Object.keys(update).length > 0) {
useUICacheStore.getState().updateFromAppStore(update);
}
}
/**
@@ -100,7 +115,7 @@ export function syncUICache(appState: {
* This is reconciled later when hydrateStoreFromSettings() overwrites
* the app store with authoritative server data.
*
* @param appStoreSetState - The setState function from the app store (avoids circular import)
* @param appStoreSetState - The setState function from the app store
*/
export function restoreFromUICache(
appStoreSetState: (state: Record<string, unknown>) => void
@@ -112,12 +127,29 @@ export function restoreFromUICache(
return false;
}
appStoreSetState({
// Attempt to resolve the cached project ID to a full project object.
// At early startup the projects array may be empty (server data not yet loaded),
// but if projects are already in the store (e.g. optimistic hydration has run)
// this will restore the project context immediately so tab-discard recovery
// does not lose the selected project when cached settings are missing.
const existingProjects = useAppStore.getState().projects;
const cachedProject = existingProjects.find((p) => p.id === cache.cachedProjectId) ?? null;
const stateUpdate: Record<string, unknown> = {
sidebarOpen: cache.cachedSidebarOpen,
sidebarStyle: cache.cachedSidebarStyle,
worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed,
collapsedNavSections: cache.cachedCollapsedNavSections,
});
};
// Restore the project context when the project object is available.
// When projects are not yet loaded (empty array), currentProject remains
// null and will be properly set later by hydrateStoreFromSettings().
if (cachedProject !== null) {
stateUpdate.currentProject = cachedProject;
}
appStoreSetState(stateUpdate);
return true;
}

View File

@@ -396,30 +396,19 @@
/* iOS Safari: position:fixed on body prevents pull-to-refresh and overscroll bounce.
Scoped to touch devices only to avoid breaking desktop browser behaviours. */
`@media` (hover: none) and (pointer: coarse) {
@media (hover: none) and (pointer: coarse) {
html,
body {
position: fixed;
}
}
/* App container: full viewport, no scroll, safe-area insets for notched devices */
#app {
height: 100%;
height: 100dvh;
overflow: hidden;
overscroll-behavior: none;
}
/* Prevent pull-to-refresh and rubber-band scrolling on mobile */
@supports (-webkit-touch-callout: none) {
body {
/* iOS Safari specific: prevent overscroll bounce */
-webkit-touch-callout: none;
}
}
/* Safe area insets for devices with notches/home indicators (viewport-fit=cover) */
#app {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
@@ -559,6 +548,11 @@
@apply backdrop-blur-md border-white/10;
}
/* Disable iOS long-press context menu - apply only to non-interactive chrome elements */
.no-touch-callout {
-webkit-touch-callout: none;
}
.glass-subtle {
@apply backdrop-blur-sm border-white/5;
}

View File

@@ -2,7 +2,12 @@
* Electron API type definitions
*/
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type {
ClaudeUsageResponse,
CodexUsageResponse,
ZaiUsageResponse,
GeminiUsageResponse,
} from '@/store/app-store';
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
export interface ImageAttachment {
@@ -710,6 +715,16 @@ export interface ElectronAPI {
getUsage: () => Promise<CodexUsageResponse>;
};
// z.ai Usage API
zai: {
getUsage: () => Promise<ZaiUsageResponse>;
};
// Gemini Usage API
gemini: {
getUsage: () => Promise<GeminiUsageResponse>;
};
// Worktree Management APIs
worktree: WorktreeAPI;
@@ -884,6 +899,17 @@ export interface WorktreeAPI {
error?: string;
}>;
// Generate an AI PR title and description from branch diff
generatePRDescription: (
worktreePath: string,
baseBranch?: string
) => Promise<{
success: boolean;
title?: string;
body?: string;
error?: string;
}>;
// Push a worktree branch to remote
push: (
worktreePath: string,
@@ -910,6 +936,7 @@ export interface WorktreeAPI {
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
}
) => Promise<{
success: boolean;
@@ -940,7 +967,10 @@ export interface WorktreeAPI {
) => Promise<FileDiffResult>;
// Pull latest changes from remote
pull: (worktreePath: string) => Promise<{
pull: (
worktreePath: string,
remote?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
@@ -954,7 +984,8 @@ export interface WorktreeAPI {
// Create and checkout a new branch
checkoutBranch: (
worktreePath: string,
branchName: string
branchName: string,
baseBranch?: string
) => Promise<{
success: boolean;
result?: {
@@ -998,6 +1029,8 @@ export interface WorktreeAPI {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts: boolean;
stashedChanges: boolean;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
@@ -1388,6 +1421,134 @@ export interface WorktreeAPI {
}
) => void
) => () => void;
// Get recent commit history for a worktree
getCommitLog: (
worktreePath: string,
limit?: number
) => Promise<{
success: boolean;
result?: {
branch: string;
commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
// Stash changes in a worktree (with optional message and optional file selection)
stashPush: (
worktreePath: string,
message?: string,
files?: string[]
) => Promise<{
success: boolean;
result?: {
stashed: boolean;
branch?: string;
message?: string;
};
error?: string;
}>;
// List all stashes in a worktree
stashList: (worktreePath: string) => Promise<{
success: boolean;
result?: {
stashes: Array<{
index: number;
message: string;
branch: string;
date: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
// Apply or pop a stash entry
stashApply: (
worktreePath: string,
stashIndex: number,
pop?: boolean
) => Promise<{
success: boolean;
result?: {
applied: boolean;
hasConflicts: boolean;
operation: 'apply' | 'pop';
stashIndex: number;
message: string;
};
error?: string;
}>;
// Drop (delete) a stash entry
stashDrop: (
worktreePath: string,
stashIndex: number
) => Promise<{
success: boolean;
result?: {
dropped: boolean;
stashIndex: number;
message: string;
};
error?: string;
}>;
// Cherry-pick one or more commits into the current branch
cherryPick: (
worktreePath: string,
commitHashes: string[],
options?: {
noCommit?: boolean;
}
) => Promise<{
success: boolean;
result?: {
cherryPicked: boolean;
commitHashes: string[];
branch: string;
message: string;
};
error?: string;
hasConflicts?: boolean;
}>;
// Get commit log for a specific branch (not just the current one)
getBranchCommitLog: (
worktreePath: string,
branchName?: string,
limit?: number
) => Promise<{
success: boolean;
result?: {
branch: string;
commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
}
// Test runner status type