apply the patches

This commit is contained in:
webdevcody
2026-01-20 10:24:38 -05:00
parent 179c5ae9c2
commit 76eb3a2ac2
42 changed files with 2679 additions and 757 deletions

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
@@ -123,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
(feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
feature.status.startsWith('pipeline_') ||
(feature.status === 'in_progress' && !isCurrentAutoTask));
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
} = useDraggable({
id: feature.id,
disabled: !isDraggable || isOverlay || isSelectionMode,
});
// Make the card a drop target for creating dependency links
// Only backlog cards can be link targets (to avoid complexity with running features)
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: `card-drop-${feature.id}`,
disabled: !isDroppable,
data: {
type: 'card',
featureId: feature.id,
},
});
// Combine refs for both draggable and droppable
const setNodeRef = useCallback(
(node: HTMLElement | null) => {
setDraggableRef(node);
setDroppableRef(node);
},
[setDraggableRef, setDroppableRef]
);
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
};
@@ -141,7 +168,9 @@ export const KanbanCard = memo(function KanbanCard({
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable, isSelectable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
// Visual feedback when another card is being dragged over this one
isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
);
const isInteractive = !isDragging && !isOverlay;

View File

@@ -23,7 +23,6 @@ interface ColumnDef {
/**
* Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/
export const LIST_COLUMNS: ColumnDef[] = [
{
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
minWidth: 'min-w-0',
align: 'left',
},
{
id: 'priority',
label: '',
sortable: true,
width: 'w-18',
minWidth: 'min-w-[16px]',
align: 'center',
},
];
export interface ListHeaderProps {
@@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width,
column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
isSorted && 'text-foreground',
@@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width,
column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className

View File

@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 gap-2',
'flex items-center pl-3 pr-0 py-3 gap-0',
getColumnWidth('title'),
getColumnAlign('title')
)}
@@ -315,6 +315,42 @@ export const ListRow = memo(function ListRow({
</div>
</div>
{/* Priority column */}
<div
role="cell"
className={cn(
'flex items-center pl-0 pr-3 py-3 shrink-0',
getColumnWidth('priority'),
getColumnAlign('priority')
)}
data-testid={`list-row-priority-${feature.id}`}
>
{feature.priority ? (
<span
className={cn(
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px] font-bold text-xs',
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
title={
feature.priority === 1
? 'High Priority'
: feature.priority === 2
? 'Medium Priority'
: 'Low Priority'
}
>
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />

View File

@@ -0,0 +1,135 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
export type DependencyLinkType = 'parent' | 'child';
interface DependencyLinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
draggedFeature: Feature | null;
targetFeature: Feature | null;
onLink: (linkType: DependencyLinkType) => void;
}
export function DependencyLinkDialog({
open,
onOpenChange,
draggedFeature,
targetFeature,
onLink,
}: DependencyLinkDialogProps) {
if (!draggedFeature || !targetFeature) return null;
// Check if a dependency relationship already exists
const draggedDependsOnTarget =
Array.isArray(draggedFeature.dependencies) &&
draggedFeature.dependencies.includes(targetFeature.id);
const targetDependsOnDragged =
Array.isArray(targetFeature.dependencies) &&
targetFeature.dependencies.includes(draggedFeature.id);
const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="dependency-link-dialog" className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="w-5 h-5" />
Link Features
</DialogTitle>
<DialogDescription>
Create a dependency relationship between these features.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Dragged feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{draggedFeature.description}
</div>
<div className="text-xs text-muted-foreground/70 mt-1">{draggedFeature.category}</div>
</div>
{/* Arrow indicating direction */}
<div className="flex justify-center">
<ArrowDown className="w-5 h-5 text-muted-foreground" />
</div>
{/* Target feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{targetFeature.description}
</div>
<div className="text-xs text-muted-foreground/70 mt-1">{targetFeature.category}</div>
</div>
{/* Existing link warning */}
{existingLink && (
<div className="p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10 text-sm text-yellow-600 dark:text-yellow-400">
{draggedDependsOnTarget
? 'The dragged feature already depends on the target feature.'
: 'The target feature already depends on the dragged feature.'}
</div>
)}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:!justify-start">
{/* Set as Parent - top */}
<Button
variant="default"
onClick={() => onLink('child')}
disabled={draggedDependsOnTarget}
className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
title={
draggedDependsOnTarget
? 'This would create a circular dependency'
: 'Make target feature depend on dragged (dragged is parent)'
}
data-testid="link-as-parent"
>
<ArrowUp className="w-4 h-4 mr-2" />
Set as Parent
<span className="text-xs ml-1 opacity-70">(target depends on this)</span>
</Button>
{/* Set as Child - middle */}
<Button
variant="default"
onClick={() => onLink('parent')}
disabled={targetDependsOnDragged}
className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
title={
targetDependsOnDragged
? 'This would create a circular dependency'
: 'Make dragged feature depend on target (target is parent)'
}
data-testid="link-as-child"
>
<ArrowDown className="w-4 h-4 mr-2" />
Set as Child
<span className="text-xs ml-1 opacity-70">(depends on target)</span>
</Button>
{/* Cancel - bottom */}
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full">
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,8 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
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 { 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 { PushToRemoteDialog } from './push-to-remote-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';

View File

@@ -8,58 +8,81 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export type { MergeConflictInfo } from '../worktree-panel/types';
interface MergeWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onMerged: (mergedWorktree: WorktreeInfo) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
type DialogStep = 'confirm' | 'verify';
export function MergeWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onMerged,
affectedFeatureCount = 0,
onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<DialogStep>('confirm');
const [confirmText, setConfirmText] = useState('');
const [targetBranch, setTargetBranch] = useState('main');
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
const [loadingBranches, setLoadingBranches] = useState(false);
const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
const [mergeConflict, setMergeConflict] = useState<MergeConflictInfo | null>(null);
// Fetch available branches when dialog opens
useEffect(() => {
if (open && worktree && projectPath) {
setLoadingBranches(true);
const api = getElectronAPI();
if (api?.worktree?.listBranches) {
api.worktree
.listBranches(projectPath, false)
.then((result) => {
if (result.success && result.result?.branches) {
// Filter out the source branch (can't merge into itself) and remote branches
const branches = result.result.branches
.filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
.map((b: BranchInfo) => b.name);
setAvailableBranches(branches);
}
})
.catch((err) => {
console.error('Failed to fetch branches:', err);
})
.finally(() => {
setLoadingBranches(false);
});
} else {
setLoadingBranches(false);
}
}
}, [open, worktree, projectPath]);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setIsLoading(false);
setStep('confirm');
setConfirmText('');
setTargetBranch('main');
setDeleteWorktreeAndBranch(false);
setMergeConflict(null);
}
}, [open]);
const handleProceedToVerify = () => {
setStep('verify');
};
const handleMerge = async () => {
if (!worktree) return;
@@ -71,96 +94,151 @@ export function MergeWorktreeDialog({
return;
}
// Pass branchName and worktreePath directly to the API
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
// Pass branchName, worktreePath, targetBranch, and options to the API
const result = await api.worktree.mergeFeature(
projectPath,
worktree.branch,
worktree.path,
targetBranch,
{ deleteWorktreeAndBranch }
);
if (result.success) {
toast.success('Branch merged to main', {
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
});
onMerged(worktree);
const description = deleteWorktreeAndBranch
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
toast.success(`Branch merged to ${targetBranch}`, { description });
onMerged(worktree, deleteWorktreeAndBranch);
onOpenChange(false);
} else {
toast.error('Failed to merge branch', {
description: result.error,
});
// Check if the error indicates merge conflicts
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
// Set merge conflict state to show the conflict resolution UI
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
});
} else {
toast.error('Failed to merge branch', {
description: result.error,
});
}
}
} catch (err) {
toast.error('Failed to merge branch', {
description: err instanceof Error ? err.message : 'Unknown error',
});
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
// Check if the error indicates merge conflicts
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath,
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
});
} else {
toast.error('Failed to merge branch', {
description: errorMessage,
});
}
} finally {
setIsLoading(false);
}
};
const handleCreateConflictResolutionFeature = () => {
if (mergeConflict && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature(mergeConflict);
onOpenChange(false);
}
};
if (!worktree) return null;
const confirmationWord = 'merge';
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
// First step: Show what will happen and ask for confirmation
if (step === 'confirm') {
// Show conflict resolution UI if there are merge conflicts
if (mergeConflict) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" />
Merge to Main
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<div className="space-y-4">
<span className="block">
Merge branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
main?
There are conflicts when merging{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.sourceBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.targetBranch}
</code>
.
</span>
<div className="text-sm text-muted-foreground mt-2">
This will:
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Merge the branch into the main branch</li>
<li>Remove the worktree directory</li>
<li>Delete the branch</li>
</ul>
<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 merge 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">
{mergeConflict.targetBranch}
</code>{' '}
branch.
</span>
</div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
be unassigned after merge.
</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>
Resolve merge conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch
</li>
<li>Ensure the code compiles and tests pass</li>
<li>Complete the merge automatically</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleProceedToVerify}
disabled={worktree.hasChanges}
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Continue
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
</Button>
</DialogFooter>
</DialogContent>
@@ -168,52 +246,86 @@ export function MergeWorktreeDialog({
);
}
// Second step: Type confirmation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Confirm Merge
<GitMerge className="w-5 h-5 text-green-600" />
Merge Branch
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<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-600 dark:text-orange-400 text-sm">
This action cannot be undone. The branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
permanently deleted after merging.
</span>
</div>
<span className="block">
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
into:
</span>
<div className="space-y-2">
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
confirm:
<Label htmlFor="target-branch" className="text-sm text-foreground">
Target Branch
</Label>
<Input
id="confirm-merge"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={confirmationWord}
disabled={isLoading}
className="font-mono"
autoComplete="off"
/>
{loadingBranches ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
Loading branches...
</div>
) : (
<BranchAutocomplete
value={targetBranch}
onChange={setTargetBranch}
branches={availableBranches}
placeholder="Select target branch..."
data-testid="merge-target-branch"
/>
)}
</div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-2">
<Checkbox
id="delete-worktree-branch"
checked={deleteWorktreeAndBranch}
onCheckedChange={(checked) => setDeleteWorktreeAndBranch(checked === true)}
/>
<Label
htmlFor="delete-worktree-branch"
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
Delete worktree and branch after merging
</Label>
</div>
{deleteWorktreeAndBranch && (
<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 worktree and branch will be permanently deleted. Any features assigned to this
branch will be unassigned.
</span>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
Back
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleMerge}
disabled={isLoading || !isConfirmValid}
disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
@@ -223,8 +335,8 @@ export function MergeWorktreeDialog({
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Merge to Main
<GitMerge className="w-4 h-4 mr-2" />
Merge
</>
)}
</Button>

View File

@@ -0,0 +1,303 @@
import { useState, useEffect } 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,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface RemoteBranch {
name: string;
fullRef: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: RemoteBranch[];
}
const logger = createLogger('PullResolveConflictsDialog');
interface PullResolveConflictsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
}
export function PullResolveConflictsDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PullResolveConflictsDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setError(null);
}
}, [open]);
// Auto-select default remote and branch 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);
// Try to select a matching branch name or default to main/master
if (defaultRemote.branches.length > 0 && worktree) {
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
const mainBranch = defaultRemote.branches.find(
(b) => b.name === 'main' || b.name === 'master'
);
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
}
}
}, [remotes, selectedRemote, worktree]);
// Update selected branch when remote changes
useEffect(() => {
if (selectedRemote && remotes.length > 0 && worktree) {
const remote = remotes.find((r) => r.name === selectedRemote);
if (remote && remote.branches.length > 0) {
// Try to select a matching branch name or default to main/master
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
} else {
setSelectedBranch('');
}
}
}, [selectedRemote, remotes, worktree]);
const fetchRemotes = 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) {
setRemotes(result.result.remotes);
if (result.result.remotes.length === 0) {
setError('No remotes found in this repository');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
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) {
setRemotes(result.result.remotes);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch);
onOpenChange(false);
};
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
const branches = selectedRemoteData?.branches || [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Pull & Resolve Conflicts
</DialogTitle>
<DialogDescription>
Select a remote branch to pull from and resolve conflicts with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</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">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}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="branch-select">Branch</Label>
<Select
value={selectedBranch}
onValueChange={setSelectedBranch}
disabled={!selectedRemote || branches.length === 0}
>
<SelectTrigger id="branch-select">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{selectedRemote} branches</SelectLabel>
{branches.map((branch) => (
<SelectItem key={branch.fullRef} value={branch.fullRef}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedRemote && branches.length === 0 && (
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
)}
</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.
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedBranch || isLoading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Pull & Resolve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } 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 { toast } from 'sonner';
import { Upload, RefreshCw, AlertTriangle, Sparkles } 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('PushToRemoteDialog');
interface PushToRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
}
export function PushToRemoteDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PushToRemoteDialogProps) {
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);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
// 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 fetchRemotes = 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) {
// Extract just the remote info (name and URL), not the branches
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
if (remoteInfos.length === 0) {
setError('No remotes found in this repository. Please add a remote first.');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
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: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
</DialogTitle>
<DialogDescription>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</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}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</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">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -92,6 +92,7 @@ export function useBoardActions({
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getAutoModeState,
} = useAppStore();
const autoMode = useAutoMode();
@@ -485,10 +486,22 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
// Check capacity for the feature's specific worktree, not the current view
const featureBranchName = feature.branchName ?? null;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
if (!canStartInWorktree) {
const worktreeDesc = featureBranchName
? `worktree "${featureBranchName}"`
: 'main worktree';
toast.error('Concurrency limit reached', {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? 's' : ''
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -552,6 +565,8 @@ export function useBoardActions({
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
]
);

View File

@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop');
export interface PendingDependencyLink {
draggedFeature: Feature;
targetFeature: Feature;
}
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
handleStartImplementation,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore();
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
null
);
const { moveFeature, updateFeature } = useAppStore();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
[features]
);
// Clear pending dependency link
const clearPendingDependencyLink = useCallback(() => {
setPendingDependencyLink(null);
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
@@ -57,6 +70,85 @@ export function useBoardDragDrop({
// Check if this is a running task (non-skipTests, TDD)
const isRunningTask = runningAutoTasks.includes(featureId);
// Check if dropped on another card (for creating dependency links)
if (overId.startsWith('card-drop-')) {
const cardData = over.data.current as {
type: string;
featureId: string;
};
if (cardData?.type === 'card') {
const targetFeatureId = cardData.featureId;
// Don't link to self
if (targetFeatureId === featureId) {
return;
}
const targetFeature = features.find((f) => f.id === targetFeatureId);
if (!targetFeature) return;
// Only allow linking backlog features (both must be in backlog)
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
toast.error('Cannot link features', {
description: 'Both features must be in the backlog to create a dependency link.',
});
return;
}
// Set pending dependency link to trigger dialog
setPendingDependencyLink({
draggedFeature,
targetFeature,
});
return;
}
}
// Check if dropped on a worktree tab
if (overId.startsWith('worktree-drop-')) {
// Handle dropping on a worktree - change the feature's branchName
const worktreeData = over.data.current as {
type: string;
branch: string;
path: string;
isMain: boolean;
};
if (worktreeData?.type === 'worktree') {
// Don't allow moving running tasks to a different worktree
if (isRunningTask) {
logger.debug('Cannot move running feature to different worktree');
toast.error('Cannot move feature', {
description: 'This feature is currently running and cannot be moved.',
});
return;
}
const targetBranch = worktreeData.branch;
const currentBranch = draggedFeature.branchName;
// If already on the same branch, nothing to do
if (currentBranch === targetBranch) {
return;
}
// For main worktree, set branchName to undefined/null to indicate it should use main
// For other worktrees, set branchName to the target branch
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
// Update feature's branchName
updateFeature(featureId, { branchName: newBranchName });
await persistFeatureUpdate(featureId, { branchName: newBranchName });
const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
toast.success('Feature moved to branch', {
description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
});
return;
}
}
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
@@ -205,12 +297,21 @@ export function useBoardDragDrop({
}
}
},
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
[
features,
runningAutoTasks,
moveFeature,
updateFeature,
persistFeatureUpdate,
handleStartImplementation,
]
);
return {
activeFeature,
handleDragStart,
handleDragEnd,
pendingDependencyLink,
clearPendingDependencyLink,
};
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
@@ -65,37 +64,8 @@ export function useBoardEffects({
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Sync running tasks from electron backend on mount
useEffect(() => {
if (!currentProject) return;
const syncRunningTasks = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
logger.info('Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
}
}
} catch (error) {
logger.error('Failed to sync running tasks:', error);
}
};
syncRunningTasks();
}, [currentProject]);
// Note: Running tasks sync is now handled by useAutoMode hook in BoardView
// which correctly handles worktree/branch scoping.
// Check which features have context files
useEffect(() => {

View File

@@ -123,7 +123,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
} else if (event.type === 'auto_mode_error') {
// Remove from running tasks
if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId);
const eventBranchName =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
// Show error toast

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, UIEvent, RefObject } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { useMemo } from 'react';
import { DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
@@ -11,10 +10,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
onDragStart: (event: any) => void;
onDragEnd: (event: any) => void;
activeFeature: Feature | null;
getColumnFeatures: (columnId: ColumnId) => Feature[];
backgroundImageStyle: React.CSSProperties;
@@ -259,10 +254,6 @@ function VirtualizedList<Item extends VirtualListItem>({
}
export function KanbanBoard({
sensors,
collisionDetectionStrategy,
onDragStart,
onDragEnd,
activeFeature,
getColumnFeatures,
backgroundImageStyle,
@@ -319,131 +310,99 @@ export function KanbanBoard({
)}
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<VirtualizedList
key={column.id}
items={columnFeatures}
isDragging={isDragging}
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
itemGap={KANBAN_CARD_GAP_PX}
overscan={KANBAN_OVERSCAN_COUNT}
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
>
{({
contentRef,
onScroll,
itemIds,
visibleItems,
totalHeight,
offsetTop,
startIndex,
shouldVirtualize,
registerItem,
}) => (
<KanbanColumn
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<div className="h-full py-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<VirtualizedList
key={column.id}
items={columnFeatures}
isDragging={isDragging}
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
itemGap={KANBAN_CARD_GAP_PX}
overscan={KANBAN_OVERSCAN_COUNT}
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
>
{({
contentRef,
onScroll,
itemIds,
visibleItems,
totalHeight,
offsetTop,
startIndex,
shouldVirtualize,
registerItem,
}) => (
<KanbanColumn
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
)}
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'waiting_approval'
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
data-testid="selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
@@ -455,221 +414,242 @@ export function KanbanBoard({
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{shouldVirtualize ? (
<div className="relative" style={{ height: totalHeight }}>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{visibleItems.map((feature, index) => {
const absoluteIndex = startIndex + index;
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && absoluteIndex < 10) {
shortcutKey =
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
return (
<div
key={feature.id}
ref={registerItem(feature.id)}
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
>
<KanbanCard
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() =>
onToggleFeatureSelection?.(feature.id)
}
/>
</div>
);
})}
</div>
: undefined
}
/>
)}
{shouldVirtualize ? (
<div className="relative" style={{ height: totalHeight }}>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{visibleItems.map((feature, index) => {
const absoluteIndex = startIndex + index;
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && absoluteIndex < 10) {
shortcutKey =
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
}
return (
<div
key={feature.id}
ref={registerItem(feature.id)}
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
>
<KanbanCard
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
</div>
);
})}
</div>
) : (
columnFeatures.map((feature, index) => {
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>
</div>
) : (
columnFeatures.map((feature, index) => {
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => onEdit(feature)}
onDelete={() => onDelete(feature.id)}
onViewOutput={() => onViewOutput(feature)}
onVerify={() => onVerify(feature)}
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
reduceEffects={reduceEffects}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</div>
);
}

View File

@@ -27,11 +27,12 @@ import {
Copy,
Eye,
ScrollText,
Sparkles,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
Undo2,
Zap,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps {
isSelected: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
@@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps {
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps {
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({
isSelected,
aheadCount,
behindCount,
hasRemoteBranch,
isPulling,
isPushing,
isStartingDevServer,
@@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({
onOpenChange,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
@@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
@@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onMerge,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPush(worktree)}
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
onClick={() => {
if (!canPerformGitOps) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && aheadCount > 0 && (
{canPerformGitOps && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
<Sparkles className="w-2.5 h-2.5" />
new
</span>
)}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
@@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onMerge(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge to Main
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
@@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({
)}
{!worktree.isMain && (
<>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onMerge(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge Branch
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"

View File

@@ -4,6 +4,7 @@ import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useDroppable } from '@dnd-kit/core';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -28,6 +29,7 @@ interface WorktreeTabProps {
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
@@ -39,6 +41,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -79,6 +82,7 @@ export function WorktreeTab({
isStartingDevServer,
aheadCount,
behindCount,
hasRemoteBranch,
gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree,
@@ -89,6 +93,7 @@ export function WorktreeTab({
onCreateBranch,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
@@ -108,6 +113,16 @@ export function WorktreeTab({
onToggleAutoMode,
hasInitScript,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
id: `worktree-drop-${worktree.branch}`,
data: {
type: 'worktree',
branch: worktree.branch,
path: worktree.path,
isMain: worktree.isMain,
},
});
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
@@ -194,7 +209,13 @@ export function WorktreeTab({
}
return (
<div className="flex items-center rounded-md">
<div
ref={setNodeRef}
className={cn(
'flex items-center rounded-md transition-all duration-150',
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-105'
)}
>
{worktree.isMain ? (
<>
<Button
@@ -366,6 +387,7 @@ export function WorktreeTab({
isSelected={isSelected}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -376,6 +398,7 @@ export function WorktreeTab({
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}

View File

@@ -22,6 +22,7 @@ export function useBranches() {
const branches = branchData?.branches ?? [];
const aheadCount = branchData?.aheadCount ?? 0;
const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
// Use conservative defaults (false) until data is confirmed
// This prevents the UI from assuming git capabilities before the query completes
const gitRepoStatus: GitRepoStatus = {
@@ -55,6 +56,7 @@ export function useBranches() {
filteredBranches,
aheadCount,
behindCount,
hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,

View File

@@ -17,6 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
// Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
// Special case: if feature is on 'main' branch, it belongs to main worktree
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
if (worktree.isMain && feature.branchName === 'main') {
return true;
}
return worktree.branch === feature.branchName;
}

View File

@@ -61,6 +61,12 @@ export interface PRInfo {
}>;
}
export interface MergeConflictInfo {
sourceBranch: string;
targetBranch: string;
targetWorktreePath: string;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -70,7 +76,9 @@ export interface WorktreePanelProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when a branch is deleted during merge - features should be reassigned to main */
onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];

View File

@@ -23,9 +23,10 @@ import {
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
export function WorktreePanel({
projectPath,
@@ -36,7 +37,8 @@ export function WorktreePanel({
onCreateBranch,
onAddressPRComments,
onResolveConflicts,
onMerge,
onCreateMergeConflictResolutionFeature,
onBranchDeletedDuringMerge,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
@@ -67,6 +69,7 @@ export function WorktreePanel({
filteredBranches,
aheadCount,
behindCount,
hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,
@@ -170,6 +173,14 @@ export function WorktreePanel({
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
// Push to remote dialog state
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
// Merge branch dialog state
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk
@@ -280,6 +291,54 @@ export function WorktreePanel({
// Keep logPanelWorktree set for smooth close animation
}, []);
// Handle opening the push to remote dialog
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
setPushToRemoteWorktree(worktree);
setPushToRemoteDialogOpen(true);
}, []);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path, false, remote);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
toast.error('Failed to push changes');
}
},
[fetchBranches, fetchWorktrees]
);
// Handle opening the merge dialog
const handleMerge = useCallback((worktree: WorktreeInfo) => {
setMergeWorktree(worktree);
setMergeDialogOpen(true);
}, []);
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
const handleMerged = useCallback(
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
fetchWorktrees();
// If the branch was deleted, notify parent to reassign features to main
if (deletedBranch && onBranchDeletedDuringMerge) {
onBranchDeletedDuringMerge(mergedWorktree.branch);
}
},
[fetchWorktrees, onBranchDeletedDuringMerge]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -325,6 +384,7 @@ export function WorktreePanel({
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -335,6 +395,7 @@ export function WorktreePanel({
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -344,7 +405,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -415,6 +476,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
{/* Push to Remote Dialog */}
<PushToRemoteDialog
open={pushToRemoteDialogOpen}
onOpenChange={setPushToRemoteDialogOpen}
worktree={pushToRemoteWorktree}
onConfirm={handleConfirmPushToRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}
@@ -448,6 +527,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
@@ -458,6 +538,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -467,7 +548,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -512,6 +593,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree}
@@ -522,6 +604,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -531,7 +614,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -602,6 +685,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
{/* Push to Remote Dialog */}
<PushToRemoteDialog
open={pushToRemoteDialogOpen}
onOpenChange={setPushToRemoteDialogOpen}
worktree={pushToRemoteWorktree}
onConfirm={handleConfirmPushToRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}