mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge remote-tracking branch 'origin/main' into implement-planning/speckits
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ArrowLeft,
|
||||
HardDrive,
|
||||
CornerDownLeft,
|
||||
Clock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -45,6 +47,44 @@ interface FileBrowserDialogProps {
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
function getRecentFolders(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -61,8 +101,26 @@ export function FileBrowserDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [warning, setWarning] = useState("");
|
||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load recent folders when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecentFolders(getRecentFolders());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
}, []);
|
||||
|
||||
const handleSelectRecent = useCallback((path: string) => {
|
||||
browseDirectory(path);
|
||||
}, []);
|
||||
|
||||
const browseDirectory = async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -153,27 +211,34 @@ export function FileBrowserDialog({
|
||||
|
||||
const handleSelect = () => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get folder name from path
|
||||
const getFolderName = (path: string) => {
|
||||
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
||||
<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">
|
||||
<DialogDescription className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
|
||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||
{/* Direct path input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
type="text"
|
||||
@@ -181,7 +246,7 @@ export function FileBrowserDialog({
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathInputKeyDown}
|
||||
className="flex-1 font-mono text-sm"
|
||||
className="flex-1 font-mono text-xs h-8"
|
||||
data-testid="path-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -191,16 +256,46 @@ export function FileBrowserDialog({
|
||||
onClick={handleGoToPath}
|
||||
disabled={loading || !pathInput.trim()}
|
||||
data-testid="go-to-path-button"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<CornerDownLeft className="w-4 h-4 mr-1" />
|
||||
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recent folders */}
|
||||
{recentFolders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Recent:</span>
|
||||
</div>
|
||||
{recentFolders.map((folder) => (
|
||||
<button
|
||||
key={folder}
|
||||
onClick={() => handleSelectRecent(folder)}
|
||||
className="group flex items-center gap-1 h-6 px-2 text-xs bg-sidebar-accent/20 hover:bg-sidebar-accent/40 rounded border border-sidebar-border transition-colors"
|
||||
disabled={loading}
|
||||
title={folder}
|
||||
>
|
||||
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
|
||||
<button
|
||||
onClick={(e) => handleRemoveRecent(e, folder)}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drives selector (Windows only) */}
|
||||
{drives.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
||||
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>Drives:</span>
|
||||
</div>
|
||||
@@ -212,7 +307,7 @@ export function FileBrowserDialog({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-7 px-3 text-xs"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace("\\", "")}
|
||||
@@ -222,57 +317,57 @@ export function FileBrowserDialog({
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-7 px-2"
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-7 px-2"
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||
{currentPath || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Loading directories...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warning && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
|
||||
<div className="text-sm text-yellow-500">{warning}</div>
|
||||
<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 && !warning && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No subdirectories found
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,29 +379,29 @@ export function FileBrowserDialog({
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelectDirectory(dir)}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
>
|
||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs">{dir.name}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Paste a full path above, or click on folders to navigate. Press
|
||||
Enter or click Go to jump to a path.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border pt-4 gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
223
apps/app/src/components/ui/autocomplete.tsx
Normal file
223
apps/app/src/components/ui/autocomplete.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
badge?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface AutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: (string | AutocompleteOption)[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
icon?: LucideIcon;
|
||||
allowCreate?: boolean;
|
||||
createLabel?: (value: string) => string;
|
||||
"data-testid"?: string;
|
||||
itemTestIdPrefix?: string;
|
||||
}
|
||||
|
||||
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
|
||||
if (typeof opt === "string") {
|
||||
return { value: opt, label: opt };
|
||||
}
|
||||
return { ...opt, label: opt.label ?? opt.value };
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select an option...",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No results found.",
|
||||
className,
|
||||
disabled = false,
|
||||
icon: Icon,
|
||||
allowCreate = false,
|
||||
createLabel = (v) => `Create "${v}"`,
|
||||
"data-testid": testId,
|
||||
itemTestIdPrefix = "option",
|
||||
}: AutocompleteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const normalizedOptions = React.useMemo(
|
||||
() => options.map(normalizeOption),
|
||||
[options]
|
||||
);
|
||||
|
||||
// Update trigger width when component mounts or value changes
|
||||
React.useEffect(() => {
|
||||
if (triggerRef.current) {
|
||||
const updateWidth = () => {
|
||||
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(triggerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Filter options based on input
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!inputValue) return normalizedOptions;
|
||||
const lower = inputValue.toLowerCase();
|
||||
return normalizedOptions.filter(
|
||||
(opt) =>
|
||||
opt.value.toLowerCase().includes(lower) ||
|
||||
opt.label?.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [normalizedOptions, inputValue]);
|
||||
|
||||
// Check if user typed a new value that doesn't exist
|
||||
const isNewValue =
|
||||
allowCreate &&
|
||||
inputValue.trim() &&
|
||||
!normalizedOptions.some(
|
||||
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
|
||||
);
|
||||
|
||||
// Get display value
|
||||
const displayValue = React.useMemo(() => {
|
||||
if (!value) return null;
|
||||
const found = normalizedOptions.find((opt) => opt.value === value);
|
||||
return found?.label ?? value;
|
||||
}, [value, normalizedOptions]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
Icon && "font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{Icon && (
|
||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{
|
||||
width: Math.max(triggerWidth, 200),
|
||||
}}
|
||||
data-testid={testId ? `${testId}-list` : undefined}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-9"
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isNewValue ? (
|
||||
<div className="py-2 px-3 text-sm">
|
||||
Press enter to create{" "}
|
||||
<code className="bg-muted px-1 rounded">{inputValue}</code>
|
||||
</div>
|
||||
) : (
|
||||
emptyMessage
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* Show "Create new" option if typing a new value */}
|
||||
{isNewValue && (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
onSelect={() => {
|
||||
onChange(inputValue);
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-[var(--status-success)]"
|
||||
data-testid={`${itemTestIdPrefix}-create-new`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{createLabel(inputValue)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
(new)
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue === value ? "" : currentValue);
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{option.label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.badge && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({option.badge})
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
53
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
53
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
|
||||
interface BranchAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
branches: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function BranchAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
branches,
|
||||
placeholder = "Select a branch...",
|
||||
className,
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: BranchAutocompleteProps) {
|
||||
// Always include "main" at the top of suggestions
|
||||
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
||||
const branchSet = new Set(["main", ...branches]);
|
||||
return Array.from(branchSet).map((branch) => ({
|
||||
value: branch,
|
||||
label: branch,
|
||||
badge: branch === "main" ? "default" : undefined,
|
||||
}));
|
||||
}, [branches]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={branchOptions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search or type new branch..."
|
||||
emptyMessage="No branches found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
icon={GitBranch}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="branch-option"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
@@ -38,81 +22,18 @@ export function CategoryAutocomplete({
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Update trigger width when component mounts or value changes
|
||||
React.useEffect(() => {
|
||||
if (triggerRef.current) {
|
||||
const updateWidth = () => {
|
||||
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
// Listen for resize events to handle responsive behavior
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(triggerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{value
|
||||
? suggestions.find((s) => s === value) ?? value
|
||||
: placeholder}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{
|
||||
width: Math.max(triggerWidth, 200),
|
||||
}}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestions.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion}
|
||||
value={suggestion}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
{suggestion}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === suggestion ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Autocomplete
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={suggestions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search category..."
|
||||
emptyMessage="No category found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="category-option"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -14,13 +14,26 @@ import {
|
||||
Info,
|
||||
FileOutput,
|
||||
Brain,
|
||||
Eye,
|
||||
Pencil,
|
||||
Terminal,
|
||||
Search,
|
||||
ListTodo,
|
||||
Layers,
|
||||
X,
|
||||
Filter,
|
||||
Circle,
|
||||
Play,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
parseLogOutput,
|
||||
getLogTypeColors,
|
||||
shouldCollapseByDefault,
|
||||
type LogEntry,
|
||||
type LogEntryType,
|
||||
type ToolCategory,
|
||||
} from "@/lib/log-parser";
|
||||
|
||||
interface LogViewerProps {
|
||||
@@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a tool-specific icon based on the tool category
|
||||
*/
|
||||
const getToolCategoryIcon = (category: ToolCategory | undefined) => {
|
||||
switch (category) {
|
||||
case "read":
|
||||
return <Eye className="w-4 h-4" />;
|
||||
case "edit":
|
||||
return <Pencil className="w-4 h-4" />;
|
||||
case "write":
|
||||
return <FileOutput className="w-4 h-4" />;
|
||||
case "bash":
|
||||
return <Terminal className="w-4 h-4" />;
|
||||
case "search":
|
||||
return <Search className="w-4 h-4" />;
|
||||
case "todo":
|
||||
return <ListTodo className="w-4 h-4" />;
|
||||
case "task":
|
||||
return <Layers className="w-4 h-4" />;
|
||||
default:
|
||||
return <Wrench className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns color classes for a tool category
|
||||
*/
|
||||
const getToolCategoryColor = (category: ToolCategory | undefined): string => {
|
||||
switch (category) {
|
||||
case "read":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||
case "edit":
|
||||
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
|
||||
case "write":
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
|
||||
case "bash":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
|
||||
case "search":
|
||||
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
|
||||
case "todo":
|
||||
return "text-green-400 bg-green-500/10 border-green-500/30";
|
||||
case "task":
|
||||
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
|
||||
default:
|
||||
return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for parsed todo items from TodoWrite tool
|
||||
*/
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses TodoWrite JSON content and extracts todo items
|
||||
*/
|
||||
function parseTodoContent(content: string): TodoItem[] | null {
|
||||
try {
|
||||
// Find the JSON object in the content
|
||||
const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/);
|
||||
if (!jsonMatch) return null;
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] };
|
||||
if (!parsed.todos || !Array.isArray(parsed.todos)) return null;
|
||||
|
||||
return parsed.todos;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of todo items with status icons and colors
|
||||
*/
|
||||
function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
|
||||
const getStatusIcon = (status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
|
||||
case "in_progress":
|
||||
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
|
||||
case "pending":
|
||||
return <Circle className="w-4 h-4 text-zinc-500" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-zinc-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-emerald-300 line-through opacity-70";
|
||||
case "in_progress":
|
||||
return "text-amber-300";
|
||||
case "pending":
|
||||
return "text-zinc-400";
|
||||
default:
|
||||
return "text-zinc-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
|
||||
Done
|
||||
</span>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
|
||||
In Progress
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-start gap-2 p-2 rounded-md transition-colors",
|
||||
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
|
||||
todo.status === "completed" && "bg-emerald-500/5",
|
||||
todo.status === "pending" && "bg-zinc-800/30"
|
||||
)}
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn("text-sm", getStatusColor(todo.status))}>
|
||||
{todo.content}
|
||||
</p>
|
||||
{todo.status === "in_progress" && todo.activeForm && (
|
||||
<p className="text-xs text-amber-400/70 mt-0.5 italic">
|
||||
{todo.activeForm}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(todo.status)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LogEntryItemProps {
|
||||
entry: LogEntry;
|
||||
isExpanded: boolean;
|
||||
@@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
const colors = getLogTypeColors(entry.type);
|
||||
const hasContent = entry.content.length > 100;
|
||||
|
||||
// For tool_call entries, use tool-specific styling
|
||||
const isToolCall = entry.type === "tool_call";
|
||||
const toolCategory = entry.metadata?.toolCategory;
|
||||
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
|
||||
|
||||
// Check if this is a TodoWrite entry and parse the todos
|
||||
const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
|
||||
const parsedTodos = useMemo(() => {
|
||||
if (!isTodoWrite) return null;
|
||||
return parseTodoContent(entry.content);
|
||||
}, [isTodoWrite, entry.content]);
|
||||
|
||||
// Get the appropriate icon based on entry type and tool category
|
||||
const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type);
|
||||
|
||||
// Get collapsed preview text - prefer smart summary for tool calls
|
||||
const collapsedPreview = useMemo(() => {
|
||||
if (isExpanded) return "";
|
||||
|
||||
// Use smart summary if available
|
||||
if (entry.metadata?.summary) {
|
||||
return entry.metadata.summary;
|
||||
}
|
||||
|
||||
// Fallback to truncated content
|
||||
return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
|
||||
}, [isExpanded, entry.metadata?.summary, entry.content]);
|
||||
|
||||
// Format content - detect and highlight JSON
|
||||
const formattedContent = useMemo(() => {
|
||||
const content = entry.content;
|
||||
let content = entry.content;
|
||||
|
||||
// For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes
|
||||
// since we already show the tool name in the header badge
|
||||
if (isToolCall) {
|
||||
// Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix
|
||||
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, "");
|
||||
// Remove standalone "Input:" label (keep the JSON that follows)
|
||||
content = content.replace(/^Input:\s*\n?/i, "");
|
||||
content = content.trim();
|
||||
}
|
||||
|
||||
// For summary entries, remove the <summary> and </summary> tags
|
||||
if (entry.title === "Summary") {
|
||||
content = content.replace(/^<summary>\s*/i, "");
|
||||
content = content.replace(/\s*<\/summary>\s*$/i, "");
|
||||
content = content.trim();
|
||||
}
|
||||
|
||||
// Try to find and format JSON blocks
|
||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
||||
@@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
||||
}, [entry.content]);
|
||||
}, [entry.content, entry.title, isToolCall]);
|
||||
|
||||
// Get colors - use tool category colors for tool_call entries
|
||||
const colorParts = toolCategoryColors.split(" ");
|
||||
const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
|
||||
const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
|
||||
const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-4 transition-all duration-200",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
bgColor,
|
||||
borderColor,
|
||||
"hover:brightness-110"
|
||||
)}
|
||||
data-testid={`log-entry-${entry.type}`}
|
||||
@@ -130,14 +348,14 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
||||
{getLogIcon(entry.type)}
|
||||
<span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
||||
colors.badge
|
||||
isToolCall ? toolCategoryColors : colors.badge
|
||||
)}
|
||||
data-testid="log-entry-badge"
|
||||
>
|
||||
@@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
||||
{!isExpanded &&
|
||||
entry.content.slice(0, 80) +
|
||||
(entry.content.length > 80 ? "..." : "")}
|
||||
{collapsedPreview}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
||||
className="px-4 pb-3 pt-1"
|
||||
data-testid={`log-entry-content-${entry.id}`}
|
||||
>
|
||||
<div className="font-mono text-xs space-y-1">
|
||||
{formattedContent.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === "json" ? (
|
||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||
{part.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{part.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Render TodoWrite entries with special formatting */}
|
||||
{parsedTodos ? (
|
||||
<TodoListRenderer todos={parsedTodos} />
|
||||
) : (
|
||||
<div className="font-mono text-xs space-y-1">
|
||||
{formattedContent.map((part, index) => (
|
||||
<div key={index}>
|
||||
{part.type === "json" ? (
|
||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
||||
{part.content}
|
||||
</pre>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
textColor
|
||||
)}
|
||||
>
|
||||
{part.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCategoryStats {
|
||||
read: number;
|
||||
edit: number;
|
||||
write: number;
|
||||
bash: number;
|
||||
search: number;
|
||||
todo: number;
|
||||
task: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export function LogViewer({ output, className }: LogViewerProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
|
||||
|
||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
||||
// Parse entries and compute initial expanded state together
|
||||
const { entries, initialExpandedIds } = useMemo(() => {
|
||||
const parsedEntries = parseLogOutput(output);
|
||||
const toExpand: string[] = [];
|
||||
|
||||
parsedEntries.forEach((entry) => {
|
||||
// If entry should NOT collapse by default, mark it for expansion
|
||||
if (!shouldCollapseByDefault(entry)) {
|
||||
toExpand.push(entry.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
entries: parsedEntries,
|
||||
initialExpandedIds: new Set(toExpand),
|
||||
};
|
||||
}, [output]);
|
||||
|
||||
// Merge initial expanded IDs with user-toggled ones
|
||||
// Use a ref to track if we've applied initial state
|
||||
const appliedInitialRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Apply initial expanded state for new entries
|
||||
const effectiveExpandedIds = useMemo(() => {
|
||||
const result = new Set(expandedIds);
|
||||
initialExpandedIds.forEach((id) => {
|
||||
if (!appliedInitialRef.current.has(id)) {
|
||||
appliedInitialRef.current.add(id);
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [expandedIds, initialExpandedIds]);
|
||||
|
||||
// Calculate stats for tool categories
|
||||
const stats = useMemo(() => {
|
||||
const toolCalls = entries.filter((e) => e.type === "tool_call");
|
||||
const byCategory: ToolCategoryStats = {
|
||||
read: 0,
|
||||
edit: 0,
|
||||
write: 0,
|
||||
bash: 0,
|
||||
search: 0,
|
||||
todo: 0,
|
||||
task: 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
toolCalls.forEach((tc) => {
|
||||
const cat = tc.metadata?.toolCategory || "other";
|
||||
byCategory[cat]++;
|
||||
});
|
||||
|
||||
return {
|
||||
total: toolCalls.length,
|
||||
byCategory,
|
||||
errors: entries.filter((e) => e.type === "error").length,
|
||||
};
|
||||
}, [entries]);
|
||||
|
||||
// Filter entries based on search and hidden types/categories
|
||||
const filteredEntries = useMemo(() => {
|
||||
return entries.filter((entry) => {
|
||||
// Filter by hidden types
|
||||
if (hiddenTypes.has(entry.type)) return false;
|
||||
|
||||
// Filter by hidden tool categories (for tool_call entries)
|
||||
if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
|
||||
if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
entry.content.toLowerCase().includes(query) ||
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.metadata?.toolName?.toLowerCase().includes(query) ||
|
||||
entry.metadata?.summary?.toLowerCase().includes(query) ||
|
||||
entry.metadata?.filePath?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
|
||||
|
||||
const toggleEntry = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
||||
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleTypeFilter = (type: LogEntryType) => {
|
||||
setHiddenTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCategoryFilter = (category: ToolCategory) => {
|
||||
setHiddenCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setHiddenTypes(new Set());
|
||||
setHiddenCategories(new Set());
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Tool categories to display in stats bar
|
||||
const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
|
||||
{ key: "read", label: "Read" },
|
||||
{ key: "edit", label: "Edit" },
|
||||
{ key: "write", label: "Write" },
|
||||
{ key: "bash", label: "Bash" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "todo", label: "Todo" },
|
||||
{ key: "task", label: "Task" },
|
||||
{ key: "other", label: "Other" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{/* Header with controls */}
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
{/* Sticky header with search, stats, and filters */}
|
||||
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
|
||||
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
|
||||
{/* Search bar */}
|
||||
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search logs..."
|
||||
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
|
||||
data-testid="log-search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
data-testid="log-search-clear"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
|
||||
data-testid="log-clear-filters"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool category stats bar */}
|
||||
{stats.total > 0 && (
|
||||
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
|
||||
<span className="text-xs text-zinc-500 mr-1">
|
||||
<Wrench className="w-3 h-3 inline mr-1" />
|
||||
{stats.total} tools:
|
||||
</span>
|
||||
{toolCategoryLabels.map(({ key, label }) => {
|
||||
const count = stats.byCategory[key];
|
||||
if (count === 0) return null;
|
||||
const isHidden = hiddenCategories.has(key);
|
||||
const colorClasses = getToolCategoryColor(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggleCategoryFilter(key)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
|
||||
colorClasses,
|
||||
isHidden && "opacity-40 line-through"
|
||||
)}
|
||||
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
|
||||
data-testid={`log-category-filter-${key}`}
|
||||
>
|
||||
{getToolCategoryIcon(key)}
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{stats.errors > 0 && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{stats.errors}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with type filters and controls */}
|
||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
|
||||
{Object.entries(typeCounts).map(([type, count]) => {
|
||||
const colors = getLogTypeColors(type as LogEntryType);
|
||||
const isHidden = hiddenTypes.has(type as LogEntryType);
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleTypeFilter(type as LogEntryType)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
colors.badge
|
||||
"text-xs px-2 py-0.5 rounded-full transition-all",
|
||||
colors.badge,
|
||||
isHidden && "opacity-40 line-through"
|
||||
)}
|
||||
data-testid={`log-type-count-${type}`}
|
||||
title={isHidden ? `Show ${type}` : `Hide ${type}`}
|
||||
data-testid={`log-type-filter-${type}`}
|
||||
>
|
||||
{type}: {count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{filteredEntries.length}/{entries.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
@@ -267,17 +714,32 @@ export function LogViewer({ output, className }: LogViewerProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div className="space-y-2" data-testid="log-entries-container">
|
||||
{entries.map((entry) => (
|
||||
<LogEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={expandedIds.has(entry.id)}
|
||||
onToggle={() => toggleEntry(entry.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 mt-2" data-testid="log-entries-container">
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="text-center py-4 text-zinc-500 text-sm">
|
||||
No entries match your filters.
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="ml-2 text-primary hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredEntries.map((entry) => (
|
||||
<LogEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={effectiveExpandedIds.has(entry.id)}
|
||||
onToggle={() => toggleEntry(entry.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useAppStore, type AgentModel } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Paperclip,
|
||||
X,
|
||||
ImageIcon,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
||||
@@ -29,6 +30,13 @@ import {
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
|
||||
@@ -41,6 +49,7 @@ export function AgentView() {
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
|
||||
|
||||
// Track if initial session has been loaded
|
||||
const initialSessionLoadedRef = useRef(false);
|
||||
@@ -66,6 +75,7 @@ export function AgentView() {
|
||||
} = useElectronAgent({
|
||||
sessionId: currentSessionId || "",
|
||||
workingDirectory: currentProject?.path,
|
||||
model: selectedModel,
|
||||
onToolUse: (toolName) => {
|
||||
setCurrentTool(toolName);
|
||||
setTimeout(() => setCurrentTool(null), 2000);
|
||||
@@ -501,6 +511,43 @@ export function AgentView() {
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Model Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs font-medium"
|
||||
disabled={isProcessing}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{CLAUDE_MODELS.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selectedModel === model.id && "bg-accent"
|
||||
)}
|
||||
data-testid={`model-option-${model.id}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{model.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{currentTool && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
|
||||
<Wrench className="w-3 h-3 text-primary" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
@@ -31,6 +32,12 @@ import {
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from "./board-view/dialogs";
|
||||
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
|
||||
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
|
||||
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
||||
import { WorktreePanel } from "./board-view/worktree-panel";
|
||||
import { COLUMNS } from "./board-view/constants";
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -45,6 +52,11 @@ import {
|
||||
useSuggestionsState,
|
||||
} from "./board-view/hooks";
|
||||
|
||||
// Stable empty array to avoid infinite loop in selector
|
||||
const EMPTY_WORKTREES: ReturnType<
|
||||
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
||||
> = [];
|
||||
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
@@ -60,6 +72,10 @@ export function BoardView() {
|
||||
pendingPlanApproval,
|
||||
setPendingPlanApproval,
|
||||
updateFeature,
|
||||
getCurrentWorktree,
|
||||
setCurrentWorktree,
|
||||
getWorktrees,
|
||||
setWorktrees,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
@@ -87,6 +103,24 @@ export function BoardView() {
|
||||
// State for viewing plan in read-only mode
|
||||
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
||||
|
||||
// Worktree dialog states
|
||||
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
|
||||
useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
} | null>(null);
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
|
||||
// Follow-up state hook
|
||||
const {
|
||||
showFollowUpDialog,
|
||||
@@ -194,32 +228,62 @@ export function BoardView() {
|
||||
return [...new Set(allCategories)].sort();
|
||||
}, [hookFeatures, persistedCategories]);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
const collisionDetectionStrategy = useCallback(
|
||||
(args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
// Branch suggestions for the branch autocomplete
|
||||
// Shows all local branches as suggestions, but users can type any new branch name
|
||||
// When the feature is started, a worktree will be created if needed
|
||||
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
|
||||
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
return columnCollisions;
|
||||
// Fetch branches when project changes or worktrees are created/modified
|
||||
useEffect(() => {
|
||||
const fetchBranches = async () => {
|
||||
if (!currentProject) {
|
||||
setBranchSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
},
|
||||
[]
|
||||
);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
setBranchSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.listBranches(currentProject.path);
|
||||
if (result.success && result.result?.branches) {
|
||||
const localBranches = result.result.branches
|
||||
.filter((b) => !b.isRemote)
|
||||
.map((b) => b.name);
|
||||
setBranchSuggestions(localBranches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BoardView] Error fetching branches:", error);
|
||||
setBranchSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBranches();
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||
// First, check if pointer is within a column
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
|
||||
// If we found a column collision, use that
|
||||
if (columnCollisions.length > 0) {
|
||||
return columnCollisions;
|
||||
}
|
||||
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
}, []);
|
||||
|
||||
// Use persistence hook
|
||||
const {
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
persistFeatureDelete,
|
||||
} = useBoardPersistence({ currentProject });
|
||||
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
|
||||
useBoardPersistence({ currentProject });
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
@@ -229,6 +293,27 @@ export function BoardView() {
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Get current worktree info (path and branch) for filtering features
|
||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||
const currentWorktreeInfo = currentProject
|
||||
? getCurrentWorktree(currentProject.path)
|
||||
: null;
|
||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
currentProject
|
||||
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
|
||||
// Get the branch for the currently selected worktree (for defaulting new features)
|
||||
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
|
||||
|
||||
// Extract all action handlers into a hook
|
||||
const {
|
||||
handleAddFeature,
|
||||
@@ -242,7 +327,6 @@ export function BoardView() {
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleRevertFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
@@ -273,6 +357,9 @@ export function BoardView() {
|
||||
setShowFollowUpDialog,
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
@@ -291,6 +378,8 @@ export function BoardView() {
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
});
|
||||
|
||||
// Use column features hook
|
||||
@@ -298,6 +387,9 @@ export function BoardView() {
|
||||
features: hookFeatures,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath: currentProject?.path || null,
|
||||
});
|
||||
|
||||
// Use background hook
|
||||
@@ -473,6 +565,35 @@ export function BoardView() {
|
||||
isMounted={isMounted}
|
||||
/>
|
||||
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
worktreePath: f.worktreePath,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search Bar Row */}
|
||||
@@ -515,8 +636,6 @@ export function BoardView() {
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onCommit={handleCommitFeature}
|
||||
onRevert={handleRevertFeature}
|
||||
onMerge={handleMergeFeature}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
@@ -564,7 +683,9 @@ export function BoardView() {
|
||||
onOpenChange={setShowAddDialog}
|
||||
onAdd={handleAddFeature}
|
||||
categorySuggestions={categorySuggestions}
|
||||
branchSuggestions={branchSuggestions}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
defaultBranch={selectedWorktreeBranch}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
@@ -576,6 +697,7 @@ export function BoardView() {
|
||||
onClose={() => setEditingFeature(null)}
|
||||
onUpdate={handleUpdateFeature}
|
||||
categorySuggestions={categorySuggestions}
|
||||
branchSuggestions={branchSuggestions}
|
||||
isMaximized={isMaximized}
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
aiProfiles={aiProfiles}
|
||||
@@ -656,6 +778,101 @@ export function BoardView() {
|
||||
viewOnly={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Worktree Dialog */}
|
||||
<CreateWorktreeDialog
|
||||
open={showCreateWorktreeDialog}
|
||||
onOpenChange={setShowCreateWorktreeDialog}
|
||||
projectPath={currentProject.path}
|
||||
onCreated={(newWorktree) => {
|
||||
// Add the new worktree to the store immediately to avoid race condition
|
||||
// when deriving currentWorktreeBranch for filtering
|
||||
const currentWorktrees = getWorktrees(currentProject.path);
|
||||
const newWorktreeInfo = {
|
||||
path: newWorktree.path,
|
||||
branch: newWorktree.branch,
|
||||
isMain: false,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
};
|
||||
setWorktrees(currentProject.path, [
|
||||
...currentWorktrees,
|
||||
newWorktreeInfo,
|
||||
]);
|
||||
|
||||
// Now set the current worktree with both path and branch
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
newWorktree.path,
|
||||
newWorktree.branch
|
||||
);
|
||||
|
||||
// Trigger refresh to get full worktree details (hasChanges, etc.)
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Worktree Dialog */}
|
||||
<DeleteWorktreeDialog
|
||||
open={showDeleteWorktreeDialog}
|
||||
onOpenChange={setShowDeleteWorktreeDialog}
|
||||
projectPath={currentProject.path}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||
// Reset features that were assigned to the deleted worktree
|
||||
hookFeatures.forEach((feature) => {
|
||||
const matchesByPath =
|
||||
feature.worktreePath &&
|
||||
pathsEqual(feature.worktreePath, deletedWorktree.path);
|
||||
const matchesByBranch =
|
||||
feature.branchName === deletedWorktree.branch;
|
||||
|
||||
if (matchesByPath || matchesByBranch) {
|
||||
// Reset the feature's worktree assignment
|
||||
persistFeatureUpdate(feature.id, {
|
||||
branchName: null as unknown as string | undefined,
|
||||
worktreePath: null as unknown as string | undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
<CommitWorktreeDialog
|
||||
open={showCommitWorktreeDialog}
|
||||
onOpenChange={setShowCommitWorktreeDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onCommitted={() => {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create PR Dialog */}
|
||||
<CreatePRDialog
|
||||
open={showCreatePRDialog}
|
||||
onOpenChange={setShowCreatePRDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onCreated={() => {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create Branch Dialog */}
|
||||
<CreateBranchDialog
|
||||
open={showCreateBranchDialog}
|
||||
onOpenChange={setShowCreateBranchDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onCreated={() => {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,16 +52,16 @@ import {
|
||||
MoreVertical,
|
||||
AlertCircle,
|
||||
GitBranch,
|
||||
Undo2,
|
||||
GitMerge,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Brain,
|
||||
Wand2,
|
||||
Archive,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
import {
|
||||
parseAgentContext,
|
||||
AgentTaskInfo,
|
||||
@@ -103,8 +103,6 @@ interface KanbanCardProps {
|
||||
onMoveBackToInProgress?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onCommit?: () => void;
|
||||
onRevert?: () => void;
|
||||
onMerge?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
@@ -132,8 +130,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onCommit,
|
||||
onRevert,
|
||||
onMerge,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
@@ -150,13 +146,18 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
}: KanbanCardProps) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
||||
|
||||
const hasWorktree = !!feature.branchName;
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
||||
return [];
|
||||
}
|
||||
return getBlockingDependencies(feature, features);
|
||||
}, [enableDependencyBlocking, feature, features]);
|
||||
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
@@ -341,7 +342,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"top-2 left-2 min-w-[36px]",
|
||||
feature.priority === 1 &&
|
||||
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
||||
@@ -352,7 +353,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
P{feature.priority}
|
||||
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
@@ -360,8 +361,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{feature.priority === 1
|
||||
? "High Priority"
|
||||
: feature.priority === 2
|
||||
? "Medium Priority"
|
||||
: "Low Priority"}
|
||||
? "Medium Priority"
|
||||
: "Low Priority"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -377,23 +378,24 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip Tests (Manual) indicator badge */}
|
||||
{feature.skipTests && !feature.error && (
|
||||
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
|
||||
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
"top-2 right-2",
|
||||
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3 h-3" />
|
||||
<Hand className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -407,13 +409,14 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||
@@ -423,16 +426,42 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Blocked by dependencies badge - positioned at top right */}
|
||||
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||
"min-w-[36px]",
|
||||
"top-2 right-2",
|
||||
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies.map(depId => {
|
||||
const dep = features.find(f => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
}).join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Just Finished indicator badge */}
|
||||
{isJustFinished && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||
feature.priority
|
||||
? "top-11 left-2"
|
||||
: feature.skipTests
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2",
|
||||
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
|
||||
"animate-pulse"
|
||||
)}
|
||||
@@ -443,45 +472,13 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch badge */}
|
||||
{hasWorktree && !isCurrentAutoTask && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
|
||||
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
|
||||
feature.priority
|
||||
? "top-11 left-2"
|
||||
: feature.error || feature.skipTests || isJustFinished
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||
<p className="font-mono text-xs break-all">
|
||||
{feature.branchName}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<CardHeader
|
||||
className={cn(
|
||||
"p-3 pb-2 block",
|
||||
feature.priority && "pt-12",
|
||||
!feature.priority &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-10",
|
||||
hasWorktree &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-14"
|
||||
"pt-10"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
@@ -499,7 +496,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</div>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -518,43 +515,110 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{!isCurrentAutoTask &&
|
||||
(feature.status === "waiting_approval" ||
|
||||
feature.status === "verified") && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-${
|
||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||
}-${feature.id}`}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{onViewOutput && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`logs-${
|
||||
feature.status === "waiting_approval"
|
||||
? "waiting"
|
||||
: "verified"
|
||||
data-testid={`edit-${
|
||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||
}-${feature.id}`}
|
||||
title="Logs"
|
||||
title="Edit"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`logs-${
|
||||
feature.status === "waiting_approval"
|
||||
? "waiting"
|
||||
: "verified"
|
||||
}-${feature.id}`}
|
||||
title="Logs"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(e);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-${
|
||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||
}-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onViewOutput && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-logs-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -564,69 +628,13 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
handleDeleteClick(e);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-${
|
||||
feature.status === "waiting_approval" ? "waiting" : "verified"
|
||||
}-${feature.id}`}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onViewOutput && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-logs-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
@@ -679,6 +687,16 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-3 pt-0">
|
||||
{/* Target Branch Display */}
|
||||
{useWorktrees && feature.branchName && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono truncate" title={feature.branchName}>
|
||||
{feature.branchName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps Preview */}
|
||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
@@ -884,9 +902,9 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="default"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
@@ -898,7 +916,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20 shrink-0"
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
data-testid={`shortcut-key-${feature.id}`}
|
||||
>
|
||||
{shortcutKey}
|
||||
@@ -1007,7 +1025,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "verified" && (
|
||||
<>
|
||||
{/* Logs button - styled like Refine */}
|
||||
{/* Logs button */}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -1045,30 +1063,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||
<>
|
||||
{hasWorktree && onRevert && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRevertDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`revert-${feature.id}`}
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<p>Revert changes</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Refine prompt button */}
|
||||
{onFollowUp && (
|
||||
<Button
|
||||
@@ -1086,24 +1080,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<span className="truncate">Refine</span>
|
||||
</Button>
|
||||
)}
|
||||
{hasWorktree && onMerge && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMerge();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`merge-${feature.id}`}
|
||||
title="Merge changes into main branch"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Merge</span>
|
||||
</Button>
|
||||
)}
|
||||
{!hasWorktree && onCommit && (
|
||||
{onCommit && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -1228,54 +1205,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revert Confirmation Dialog */}
|
||||
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
|
||||
<DialogContent data-testid="revert-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
|
||||
<Undo2 className="w-5 h-5" />
|
||||
Revert Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will discard all changes made by the agent and move the
|
||||
feature back to the backlog.
|
||||
{feature.branchName && (
|
||||
<span className="block mt-2 font-medium">
|
||||
Branch{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
||||
{feature.branchName}
|
||||
</code>{" "}
|
||||
will be deleted.
|
||||
</span>
|
||||
)}
|
||||
<span className="block mt-2 text-[var(--status-error)] font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsRevertDialogOpen(false)}
|
||||
data-testid="cancel-revert-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setIsRevertDialogOpen(false);
|
||||
onRevert?.();
|
||||
}}
|
||||
data-testid="confirm-revert-button"
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
Revert Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
@@ -14,12 +14,20 @@ import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { modelSupportsThinking } from "@/lib/utils";
|
||||
@@ -58,12 +66,15 @@ interface AddFeatureDialogProps {
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
defaultSkipTests: boolean;
|
||||
defaultBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
@@ -74,7 +85,9 @@ export function AddFeatureDialog({
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
categorySuggestions,
|
||||
branchSuggestions,
|
||||
defaultSkipTests,
|
||||
defaultBranch = "main",
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
@@ -88,6 +101,7 @@ export function AddFeatureDialog({
|
||||
skipTests: false,
|
||||
model: "opus" as AgentModel,
|
||||
thinkingLevel: "none" as ThinkingLevel,
|
||||
branchName: "main",
|
||||
priority: 2 as number, // Default to medium priority
|
||||
});
|
||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||
@@ -95,12 +109,14 @@ export function AddFeatureDialog({
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [descriptionError, setDescriptionError] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
"improve" | "technical" | "simplify" | "acceptance"
|
||||
>("improve");
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
|
||||
// Get enhancement model and default planning mode from store
|
||||
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -108,11 +124,12 @@ export function AddFeatureDialog({
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch,
|
||||
}));
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
}
|
||||
}, [open, defaultSkipTests, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newFeature.description.trim()) {
|
||||
@@ -135,6 +152,7 @@ export function AddFeatureDialog({
|
||||
skipTests: newFeature.skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
branchName: newFeature.branchName,
|
||||
priority: newFeature.priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
@@ -151,6 +169,7 @@ export function AddFeatureDialog({
|
||||
model: "opus",
|
||||
priority: 2,
|
||||
thinkingLevel: "none",
|
||||
branchName: defaultBranch,
|
||||
});
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
@@ -183,7 +202,7 @@ export function AddFeatureDialog({
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setNewFeature(prev => ({ ...prev, description: enhancedText }));
|
||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
||||
toast.success("Description enhanced!");
|
||||
} else {
|
||||
toast.error(result?.error || "Failed to enhance description");
|
||||
@@ -206,7 +225,10 @@ export function AddFeatureDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||
const handleProfileSelect = (
|
||||
model: AgentModel,
|
||||
thinkingLevel: ThinkingLevel
|
||||
) => {
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
@@ -260,7 +282,10 @@ export function AddFeatureDialog({
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
@@ -285,25 +310,38 @@ export function AddFeatureDialog({
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[200px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{enhancementMode === "improve" && "Improve Clarity"}
|
||||
{enhancementMode === "technical" && "Add Technical Details"}
|
||||
{enhancementMode === "simplify" && "Simplify"}
|
||||
{enhancementMode === "acceptance" &&
|
||||
"Add Acceptance Criteria"}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("improve")}
|
||||
>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("technical")}
|
||||
>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("simplify")}
|
||||
>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("acceptance")}
|
||||
>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -333,6 +371,24 @@ export function AddFeatureDialog({
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={newFeature.branchName}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="feature-branch-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created if
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
@@ -345,7 +401,10 @@ export function AddFeatureDialog({
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<TabsContent
|
||||
value="model"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
@@ -429,9 +488,7 @@ export function AddFeatureDialog({
|
||||
setNewFeature({ ...newFeature, skipTests })
|
||||
}
|
||||
steps={newFeature.steps}
|
||||
onStepsChange={(steps) =>
|
||||
setNewFeature({ ...newFeature, steps })
|
||||
}
|
||||
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -100,24 +100,6 @@ export function AgentOutputModal({
|
||||
loadOutput();
|
||||
}, [open, featureId]);
|
||||
|
||||
// Save output to file
|
||||
const saveOutput = async (newContent: string) => {
|
||||
if (!projectPathRef.current) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
// Use features API - agent output is stored in features/{id}/agent-output.md
|
||||
// We need to write it directly since there's no updateAgentOutput method
|
||||
// The context-manager handles this on the backend, but for frontend edits we write directly
|
||||
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
|
||||
await api.writeFile(outputPath, newContent);
|
||||
} catch (error) {
|
||||
console.error("Failed to save output:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -143,7 +125,7 @@ export function AgentOutputModal({
|
||||
? JSON.stringify(event.input, null, 2)
|
||||
: "";
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||
toolInput ? `Input: ${toolInput}` : ""
|
||||
toolInput ? `Input: ${toolInput}\n` : ""
|
||||
}`;
|
||||
break;
|
||||
case "auto_mode_phase":
|
||||
@@ -261,11 +243,8 @@ export function AgentOutputModal({
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
setOutput((prev) => {
|
||||
const updated = prev + newContent;
|
||||
saveOutput(updated);
|
||||
return updated;
|
||||
});
|
||||
// Only update local state - server is the single source of truth for file writes
|
||||
setOutput((prev) => prev + newContent);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { GitCommit, Loader2 } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface CommitWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onCommitted: () => void;
|
||||
}
|
||||
|
||||
export function CommitWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onCommitted,
|
||||
}: CommitWorktreeDialogProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.commit) {
|
||||
setError("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.commit(worktree.path, message);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.committed) {
|
||||
toast.success("Changes committed", {
|
||||
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
||||
});
|
||||
onCommitted();
|
||||
onOpenChange(false);
|
||||
setMessage("");
|
||||
} else {
|
||||
toast.info("No changes to commit", {
|
||||
description: result.result.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Failed to commit changes");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to commit");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
|
||||
handleCommit();
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
Commit Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Commit changes in the{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{worktree.branch}
|
||||
</code>{" "}
|
||||
worktree.
|
||||
{worktree.changedFilesCount && (
|
||||
<span className="ml-1">
|
||||
({worktree.changedFilesCount} file
|
||||
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="commit-message">Commit Message</Label>
|
||||
<Textarea
|
||||
id="commit-message"
|
||||
placeholder="Describe your changes..."
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isLoading || !message.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
Commit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import { GitBranchPlus, Loader2 } from "lucide-react";
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface CreateBranchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateBranchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onCreated,
|
||||
}: CreateBranchDialogProps) {
|
||||
const [branchName, setBranchName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setBranchName("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree || !branchName.trim()) return;
|
||||
|
||||
// Basic validation
|
||||
const invalidChars = /[\s~^:?*[\]\\]/;
|
||||
if (invalidChars.test(branchName)) {
|
||||
setError("Branch name contains invalid characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.checkoutBranch) {
|
||||
toast.error("Branch API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
|
||||
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError(result.error || "Failed to create branch");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Create branch failed:", err);
|
||||
setError("Failed to create branch");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranchPlus className="w-5 h-5" />
|
||||
Create New Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="branch-name">Branch Name</Label>
|
||||
<Input
|
||||
id="branch-name"
|
||||
placeholder="feature/my-new-feature"
|
||||
value={branchName}
|
||||
onChange={(e) => {
|
||||
setBranchName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && branchName.trim() && !isCreating) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!branchName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Branch"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface CreatePRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreatePRDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onCreated,
|
||||
}: CreatePRDialogProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("main");
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||
|
||||
// Reset state when dialog opens or worktree changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
|
||||
// These are set by the API response and should persist until dialog closes
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
} else {
|
||||
// Reset everything when dialog closes
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
}
|
||||
}, [open, worktree?.path]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.createPR) {
|
||||
setError("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.createPR(worktree.path, {
|
||||
commitMessage: commitMessage || undefined,
|
||||
prTitle: title || worktree.branch,
|
||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||
baseBranch,
|
||||
draft: isDraft,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.prCreated && result.result.prUrl) {
|
||||
setPrUrl(result.result.prUrl);
|
||||
toast.success("Pull request created!", {
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
onCreated();
|
||||
} else {
|
||||
// Branch was pushed successfully
|
||||
const prError = result.result.prError;
|
||||
const hasBrowserUrl = !!result.result.browserUrl;
|
||||
|
||||
// Check if we should show browser fallback
|
||||
if (!result.result.prCreated && hasBrowserUrl) {
|
||||
// If gh CLI is not available, show browser fallback UI
|
||||
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
|
||||
setBrowserUrl(result.result.browserUrl ?? null);
|
||||
setShowBrowserFallback(true);
|
||||
toast.success("Branch pushed", {
|
||||
description: result.result.committed
|
||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||
: `Branch ${result.result.branch} pushed`,
|
||||
});
|
||||
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
||||
setIsLoading(false);
|
||||
return; // Don't close dialog, show browser fallback UI
|
||||
}
|
||||
|
||||
// gh CLI is available but failed - show error with browser option
|
||||
if (prError) {
|
||||
// Parse common gh CLI errors for better messages
|
||||
let errorMessage = prError;
|
||||
if (prError.includes("No commits between")) {
|
||||
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
|
||||
} else if (prError.includes("already exists")) {
|
||||
errorMessage = "A pull request already exists for this branch.";
|
||||
} else if (prError.includes("not logged in") || prError.includes("auth")) {
|
||||
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
|
||||
}
|
||||
|
||||
// Show error but also provide browser option
|
||||
setBrowserUrl(result.result.browserUrl ?? null);
|
||||
setShowBrowserFallback(true);
|
||||
toast.error("PR creation failed", {
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
});
|
||||
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast for push
|
||||
toast.success("Branch pushed", {
|
||||
description: result.result.committed
|
||||
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
|
||||
: `Branch ${result.result.branch} pushed`,
|
||||
});
|
||||
|
||||
// No browser URL available, just close
|
||||
if (!result.result.prCreated) {
|
||||
if (!hasBrowserUrl) {
|
||||
toast.info("PR not created", {
|
||||
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
}
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Failed to create pull request");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create PR");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset state after dialog closes
|
||||
setTimeout(() => {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Push changes and create a pull request from{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{worktree.branch}
|
||||
</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{prUrl ? (
|
||||
<div className="py-6 text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
|
||||
<GitPullRequest className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your PR is ready for review
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(prUrl, "_blank")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Pull Request
|
||||
</Button>
|
||||
</div>
|
||||
) : shouldShowBrowserFallback ? (
|
||||
<div className="py-6 text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
|
||||
<GitPullRequest className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your changes have been pushed to GitHub.
|
||||
<br />
|
||||
Click below to create a pull request in your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (browserUrl) {
|
||||
window.open(browserUrl, "_blank");
|
||||
}
|
||||
}}
|
||||
className="gap-2 w-full"
|
||||
size="lg"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Create PR in Browser
|
||||
</Button>
|
||||
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
|
||||
{browserUrl}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
|
||||
</p>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 py-4">
|
||||
{worktree.hasChanges && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="commit-message">
|
||||
Commit Message{" "}
|
||||
<span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="commit-message"
|
||||
placeholder="Leave empty to auto-generate"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{worktree.changedFilesCount} uncommitted file(s) will be
|
||||
committed
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-title">PR Title</Label>
|
||||
<Input
|
||||
id="pr-title"
|
||||
placeholder={worktree.branch}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
placeholder="Describe the changes in this PR..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base-branch">Base Branch</Label>
|
||||
<Input
|
||||
id="base-branch"
|
||||
placeholder="main"
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="draft"
|
||||
checked={isDraft}
|
||||
onCheckedChange={(checked) => setIsDraft(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="draft" className="cursor-pointer">
|
||||
Create as draft
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitPullRequest className="w-4 h-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { GitBranch, Loader2 } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CreatedWorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
interface CreateWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
onCreated: (worktree: CreatedWorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function CreateWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
onCreated,
|
||||
}: CreateWorktreeDialogProps) {
|
||||
const [branchName, setBranchName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
setError("Branch name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (git-compatible)
|
||||
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||
if (!validBranchRegex.test(branchName)) {
|
||||
setError(
|
||||
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
setError("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
toast.success(
|
||||
`Worktree created for branch "${result.worktree.branch}"`,
|
||||
{
|
||||
description: result.worktree.isNew
|
||||
? "New branch created"
|
||||
: "Using existing branch",
|
||||
}
|
||||
);
|
||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||
onOpenChange(false);
|
||||
setBranchName("");
|
||||
} else {
|
||||
setError(result.error || "Failed to create worktree");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create worktree");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isLoading && branchName.trim()) {
|
||||
handleCreate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
Create New Worktree
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new git worktree with its own branch. This allows you to
|
||||
work on multiple features in parallel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="branch-name">Branch Name</Label>
|
||||
<Input
|
||||
id="branch-name"
|
||||
placeholder="feature/my-new-feature"
|
||||
value={branchName}
|
||||
onChange={(e) => {
|
||||
setBranchName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>Examples:</p>
|
||||
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
||||
<li>
|
||||
<code className="bg-muted px-1 rounded">feature/user-auth</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1 rounded">fix/login-bug</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1 rounded">hotfix/security-patch</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !branchName.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitBranch className="w-4 h-4 mr-2" />
|
||||
Create Worktree
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface DeleteWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
}
|
||||
|
||||
export function DeleteWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onDeleted,
|
||||
}: DeleteWorktreeDialogProps) {
|
||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.delete) {
|
||||
toast.error("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.delete(
|
||||
projectPath,
|
||||
worktree.path,
|
||||
deleteBranch
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Worktree deleted`, {
|
||||
description: deleteBranch
|
||||
? `Branch "${worktree.branch}" was also deleted`
|
||||
: `Branch "${worktree.branch}" was kept`,
|
||||
});
|
||||
onDeleted(worktree, deleteBranch);
|
||||
onOpenChange(false);
|
||||
setDeleteBranch(false);
|
||||
} else {
|
||||
toast.error("Failed to delete worktree", {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete worktree", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-destructive" />
|
||||
Delete Worktree
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<span>
|
||||
Are you sure you want to delete the worktree for branch{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{worktree.branch}
|
||||
</code>
|
||||
?
|
||||
</span>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted
|
||||
change(s). These will be lost if you proceed.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-2 py-4">
|
||||
<Checkbox
|
||||
id="delete-branch"
|
||||
checked={deleteBranch}
|
||||
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
|
||||
Also delete the branch{" "}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{worktree.branch}
|
||||
</code>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,21 @@ import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown, GitBranch } from "lucide-react";
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { modelSupportsThinking } from "@/lib/utils";
|
||||
@@ -60,12 +69,14 @@ interface EditFeatureDialogProps {
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
@@ -77,6 +88,7 @@ export function EditFeatureDialog({
|
||||
onClose,
|
||||
onUpdate,
|
||||
categorySuggestions,
|
||||
branchSuggestions,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
@@ -87,13 +99,15 @@ export function EditFeatureDialog({
|
||||
useState<ImagePreviewMap>(() => new Map());
|
||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
||||
const [enhancementMode, setEnhancementMode] = useState<
|
||||
"improve" | "technical" | "simplify" | "acceptance"
|
||||
>("improve");
|
||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
|
||||
|
||||
// Get enhancement model from store
|
||||
const { enhancementModel } = useAppStore();
|
||||
// Get enhancement model and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
@@ -110,8 +124,10 @@ export function EditFeatureDialog({
|
||||
if (!editingFeature) return;
|
||||
|
||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||
? (editingFeature.thinkingLevel ?? "none")
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||
selectedModel
|
||||
)
|
||||
? editingFeature.thinkingLevel ?? "none"
|
||||
: "none";
|
||||
|
||||
const updates = {
|
||||
@@ -122,6 +138,7 @@ export function EditFeatureDialog({
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
branchName: editingFeature.branchName ?? "main",
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
@@ -150,7 +167,10 @@ export function EditFeatureDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||
const handleProfileSelect = (
|
||||
model: AgentModel,
|
||||
thinkingLevel: ThinkingLevel
|
||||
) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
@@ -173,7 +193,9 @@ export function EditFeatureDialog({
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setEditingFeature(prev => prev ? { ...prev, description: enhancedText } : prev);
|
||||
setEditingFeature((prev) =>
|
||||
prev ? { ...prev, description: enhancedText } : prev
|
||||
);
|
||||
toast.success("Description enhanced!");
|
||||
} else {
|
||||
toast.error(result?.error || "Failed to enhance description");
|
||||
@@ -234,7 +256,10 @@ export function EditFeatureDialog({
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
@@ -261,25 +286,38 @@ export function EditFeatureDialog({
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[180px] justify-between">
|
||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||
{enhancementMode === 'simplify' && 'Simplify'}
|
||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[180px] justify-between"
|
||||
>
|
||||
{enhancementMode === "improve" && "Improve Clarity"}
|
||||
{enhancementMode === "technical" && "Add Technical Details"}
|
||||
{enhancementMode === "simplify" && "Simplify"}
|
||||
{enhancementMode === "acceptance" &&
|
||||
"Add Acceptance Criteria"}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("improve")}
|
||||
>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("technical")}
|
||||
>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("simplify")}
|
||||
>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("acceptance")}
|
||||
>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -312,6 +350,35 @@ export function EditFeatureDialog({
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-branch">Target Branch</Label>
|
||||
<BranchAutocomplete
|
||||
value={editingFeature.branchName ?? "main"}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branches={branchSuggestions}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid="edit-feature-branch"
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
/>
|
||||
{editingFeature.status !== "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
)}
|
||||
{editingFeature.status === "backlog" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Work will be done in this branch. A worktree will be created
|
||||
if needed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
@@ -327,7 +394,10 @@ export function EditFeatureDialog({
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||
<TabsContent
|
||||
value="model"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
{/* Show Advanced Options Toggle */}
|
||||
{showProfilesOnly && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Feature, FeatureImage, AgentModel, ThinkingLevel, PlanningMode, useAppStore } from "@/store/app-store";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
PlanningMode,
|
||||
useAppStore,
|
||||
} from "@/store/app-store";
|
||||
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||
import { truncateDescription } from "@/lib/utils";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
|
||||
interface UseBoardActionsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -12,7 +20,10 @@ interface UseBoardActionsProps {
|
||||
runningAutoTasks: string[];
|
||||
loadFeatures: () => Promise<void>;
|
||||
persistFeatureCreate: (feature: Feature) => Promise<void>;
|
||||
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
|
||||
persistFeatureUpdate: (
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
persistFeatureDelete: (featureId: string) => Promise<void>;
|
||||
saveCategory: (category: string) => Promise<void>;
|
||||
setEditingFeature: (feature: Feature | null) => void;
|
||||
@@ -28,6 +39,9 @@ interface UseBoardActionsProps {
|
||||
setShowFollowUpDialog: (show: boolean) => void;
|
||||
inProgressFeaturesForShortcuts: Feature[];
|
||||
outputFeature: Feature | null;
|
||||
projectPath: string | null;
|
||||
onWorktreeCreated?: () => void;
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
||||
}
|
||||
|
||||
export function useBoardActions({
|
||||
@@ -52,12 +66,81 @@ export function useBoardActions({
|
||||
setShowFollowUpDialog,
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
|
||||
const {
|
||||
addFeature,
|
||||
updateFeature,
|
||||
removeFeature,
|
||||
moveFeature,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
/**
|
||||
* Get or create the worktree path for a feature based on its branchName.
|
||||
* - If branchName is "main" or empty, returns the project path
|
||||
* - Otherwise, creates a worktree for that branch if needed
|
||||
*/
|
||||
const getOrCreateWorktreeForFeature = useCallback(
|
||||
async (feature: Feature): Promise<string | null> => {
|
||||
if (!projectPath) return null;
|
||||
|
||||
const branchName = feature.branchName || "main";
|
||||
|
||||
// If targeting main branch, use the project path directly
|
||||
if (branchName === "main" || branchName === "master") {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// For other branches, create a worktree if it doesn't exist
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
console.error("[BoardActions] Worktree API not available");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Try to create the worktree (will return existing if already exists)
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||
);
|
||||
if (result.worktree.isNew) {
|
||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||
description: "A new worktree was created for this feature.",
|
||||
});
|
||||
}
|
||||
return result.worktree.path;
|
||||
} else {
|
||||
console.error(
|
||||
"[BoardActions] Failed to create worktree:",
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
result.error || "Could not create worktree for this branch.",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BoardActions] Error creating worktree:", error);
|
||||
toast.error("Error creating worktree", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleAddFeature = useCallback(
|
||||
(featureData: {
|
||||
async (featureData: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -66,23 +149,43 @@ export function useBoardActions({
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => {
|
||||
let worktreePath: string | undefined;
|
||||
|
||||
// If worktrees are enabled and a non-main branch is selected, create the worktree
|
||||
if (useWorktrees && featureData.branchName) {
|
||||
const branchName = featureData.branchName;
|
||||
if (branchName !== "main" && branchName !== "master") {
|
||||
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
|
||||
const tempFeature = { branchName } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
status: "backlog" as const,
|
||||
worktreePath,
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
persistFeatureCreate(createdFeature);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
},
|
||||
[addFeature, persistFeatureCreate, saveCategory]
|
||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
(
|
||||
async (
|
||||
featureId: string,
|
||||
updates: {
|
||||
category: string;
|
||||
@@ -92,19 +195,59 @@ export function useBoardActions({
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
}
|
||||
) => {
|
||||
updateFeature(featureId, updates);
|
||||
persistFeatureUpdate(featureId, updates);
|
||||
// Get the current feature to check if branch is changing
|
||||
const currentFeature = features.find((f) => f.id === featureId);
|
||||
const currentBranch = currentFeature?.branchName || "main";
|
||||
const newBranch = updates.branchName || "main";
|
||||
const branchIsChanging = currentBranch !== newBranch;
|
||||
|
||||
let worktreePath: string | undefined;
|
||||
let shouldClearWorktreePath = false;
|
||||
|
||||
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
|
||||
if (useWorktrees && branchIsChanging) {
|
||||
if (newBranch === "main" || newBranch === "master") {
|
||||
// Changing to main - clear the worktreePath
|
||||
shouldClearWorktreePath = true;
|
||||
} else {
|
||||
// Changing to a feature branch - create worktree if needed
|
||||
const tempFeature = { branchName: newBranch } as Feature;
|
||||
const path = await getOrCreateWorktreeForFeature(tempFeature);
|
||||
if (path && path !== projectPath) {
|
||||
worktreePath = path;
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final updates with worktreePath if it was changed
|
||||
let finalUpdates: typeof updates & { worktreePath?: string };
|
||||
if (branchIsChanging && useWorktrees) {
|
||||
if (shouldClearWorktreePath) {
|
||||
// Use null to clear the value in persistence (cast to work around type system)
|
||||
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
|
||||
} else {
|
||||
finalUpdates = { ...updates, worktreePath };
|
||||
}
|
||||
} else {
|
||||
finalUpdates = updates;
|
||||
}
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates);
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -118,7 +261,9 @@ export function useBoardActions({
|
||||
try {
|
||||
await autoMode.stopFeature(featureId);
|
||||
toast.success("Agent stopped", {
|
||||
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
|
||||
description: `Stopped and deleted: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature before delete:", error);
|
||||
@@ -136,11 +281,17 @@ export function useBoardActions({
|
||||
await api.deleteFile(imagePathObj.path);
|
||||
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
||||
} catch (error) {
|
||||
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
|
||||
console.error(
|
||||
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
|
||||
console.error(
|
||||
`[Board] Error deleting images for feature ${featureId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,14 +312,22 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the feature's assigned worktreePath (set when moving to in_progress)
|
||||
// This ensures work happens in the correct worktree based on the feature's branchName
|
||||
const featureWorktreePath = feature.worktreePath;
|
||||
|
||||
const result = await api.autoMode.runFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
useWorktrees,
|
||||
featureWorktreePath || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature run started successfully");
|
||||
console.log(
|
||||
"[Board] Feature run started successfully in worktree:",
|
||||
featureWorktreePath || "main"
|
||||
);
|
||||
} else {
|
||||
console.error("[Board] Failed to run feature:", result.error);
|
||||
await loadFeatures();
|
||||
@@ -192,17 +351,33 @@ export function useBoardActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for blocking dependencies and show warning if enabled
|
||||
if (enableDependencyBlocking) {
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
if (blockingDeps.length > 0) {
|
||||
const depDescriptions = blockingDeps.map(depId => {
|
||||
const dep = features.find(f => f.id === depId);
|
||||
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||
}).join(", ");
|
||||
|
||||
toast.warning("Starting feature with incomplete dependencies", {
|
||||
description: `This feature depends on: ${depDescriptions}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
// Must await to ensure feature status is persisted before starting agent
|
||||
await persistFeatureUpdate(feature.id, updates);
|
||||
console.log("[Board] Feature moved to in_progress, starting agent...");
|
||||
await handleRunFeature(feature);
|
||||
return true;
|
||||
},
|
||||
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
@@ -216,7 +391,10 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
|
||||
const result = await api.autoMode.verifyFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature verification started successfully");
|
||||
@@ -243,7 +421,11 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id);
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature resume started successfully");
|
||||
@@ -256,7 +438,7 @@ export function useBoardActions({
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures]
|
||||
[currentProject, loadFeatures, useWorktrees]
|
||||
);
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
@@ -267,7 +449,9 @@ export function useBoardActions({
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${truncateDescription(feature.description)}`,
|
||||
description: `Marked as verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[moveFeature, persistFeatureUpdate]
|
||||
@@ -282,7 +466,9 @@ export function useBoardActions({
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
|
||||
description: `Moved back to In Progress: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
@@ -295,7 +481,12 @@ export function useBoardActions({
|
||||
setFollowUpImagePaths([]);
|
||||
setShowFollowUpDialog(true);
|
||||
},
|
||||
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
|
||||
[
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setShowFollowUpDialog,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSendFollowUp = useCallback(async () => {
|
||||
@@ -328,17 +519,28 @@ export function useBoardActions({
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
|
||||
toast.success("Follow-up started", {
|
||||
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
|
||||
});
|
||||
toast.success("Follow-up started", {
|
||||
description: `Continuing work on: ${truncateDescription(
|
||||
featureDescription
|
||||
)}`,
|
||||
});
|
||||
|
||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||
// Use the feature's worktreePath to ensure work happens in the correct branch
|
||||
const featureWorktreePath = followUpFeature.worktreePath;
|
||||
api.autoMode
|
||||
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths)
|
||||
.followUpFeature(
|
||||
currentProject.path,
|
||||
followUpFeature.id,
|
||||
followUpPrompt,
|
||||
imagePaths,
|
||||
featureWorktreePath
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[Board] Error sending follow-up:", error);
|
||||
toast.error("Failed to send follow-up", {
|
||||
description: error instanceof Error ? error.message : "An error occurred",
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
loadFeatures();
|
||||
});
|
||||
@@ -366,19 +568,29 @@ export function useBoardActions({
|
||||
if (!api?.autoMode?.commitFeature) {
|
||||
console.error("Commit feature API not available");
|
||||
toast.error("Commit not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
description:
|
||||
"This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.commitFeature(currentProject.path, feature.id);
|
||||
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
|
||||
const result = await api.autoMode.commitFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
feature.worktreePath
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
moveFeature(feature.id, "verified");
|
||||
persistFeatureUpdate(feature.id, { status: "verified" });
|
||||
toast.success("Feature committed", {
|
||||
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||
description: `Committed and verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
// Refresh worktree selector to update commit counts
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
console.error("[Board] Failed to commit feature:", result.error);
|
||||
toast.error("Failed to commit feature", {
|
||||
@@ -389,49 +601,19 @@ export function useBoardActions({
|
||||
} catch (error) {
|
||||
console.error("[Board] Error committing feature:", error);
|
||||
toast.error("Failed to commit feature", {
|
||||
description: error instanceof Error ? error.message : "An error occurred",
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures]
|
||||
);
|
||||
|
||||
const handleRevertFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.revertFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Revert not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.revertFeature(currentProject.path, feature.id);
|
||||
|
||||
if (result.success) {
|
||||
await loadFeatures();
|
||||
toast.success("Feature reverted", {
|
||||
description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to revert feature:", result.error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error reverting feature:", error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description: error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures]
|
||||
[
|
||||
currentProject,
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
loadFeatures,
|
||||
onWorktreeCreated,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
@@ -443,17 +625,23 @@ export function useBoardActions({
|
||||
if (!api?.worktree?.mergeFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Merge not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
description:
|
||||
"This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
|
||||
const result = await api.worktree.mergeFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
await loadFeatures();
|
||||
toast.success("Feature merged", {
|
||||
description: `Changes merged to main branch: ${truncateDescription(feature.description)}`,
|
||||
description: `Changes merged to main branch: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to merge feature:", result.error);
|
||||
@@ -464,7 +652,8 @@ export function useBoardActions({
|
||||
} catch (error) {
|
||||
console.error("[Board] Error merging feature:", error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description: error instanceof Error ? error.message : "An error occurred",
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -495,7 +684,9 @@ export function useBoardActions({
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success("Feature restored", {
|
||||
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||
description: `Moved back to verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
@@ -524,7 +715,12 @@ export function useBoardActions({
|
||||
setOutputFeature(targetFeature);
|
||||
}
|
||||
},
|
||||
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
|
||||
[
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature?.id,
|
||||
setShowOutputModal,
|
||||
setOutputFeature,
|
||||
]
|
||||
);
|
||||
|
||||
const handleForceStopFeature = useCallback(
|
||||
@@ -539,19 +735,25 @@ export function useBoardActions({
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
// Must await to ensure file is written before user can restart
|
||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
toast.success("Agent stopped", {
|
||||
description:
|
||||
targetStatus === "waiting_approval"
|
||||
? `Stopped commit - returned to waiting approval: ${truncateDescription(feature.description)}`
|
||||
: `Stopped working on: ${truncateDescription(feature.description)}`,
|
||||
? `Stopped commit - returned to waiting approval: ${truncateDescription(
|
||||
feature.description
|
||||
)}`
|
||||
: `Stopped working on: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature:", error);
|
||||
toast.error("Failed to stop agent", {
|
||||
description: error instanceof Error ? error.message : "An error occurred",
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -559,7 +761,32 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
const backlogFeatures = features.filter((f) => f.status === "backlog");
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This ensures "G" only starts features from the filtered list
|
||||
const backlogFeatures = features.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
|
||||
// Determine the feature's branch (default to "main" if not set)
|
||||
const featureBranch = f.branchName || "main";
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or main-like),
|
||||
// show features with no branch or "main"/"master" branch
|
||||
if (
|
||||
!currentWorktreeBranch ||
|
||||
currentWorktreeBranch === "main" ||
|
||||
currentWorktreeBranch === "master"
|
||||
) {
|
||||
return (
|
||||
!f.branchName ||
|
||||
featureBranch === "main" ||
|
||||
featureBranch === "master"
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, only show features matching the selected worktree branch
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const availableSlots =
|
||||
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||
|
||||
@@ -571,12 +798,56 @@ export function useBoardActions({
|
||||
return;
|
||||
}
|
||||
|
||||
const featuresToStart = backlogFeatures.slice(0, availableSlots);
|
||||
if (backlogFeatures.length === 0) {
|
||||
toast.info("Backlog empty", {
|
||||
description:
|
||||
currentWorktreeBranch &&
|
||||
currentWorktreeBranch !== "main" &&
|
||||
currentWorktreeBranch !== "master"
|
||||
? `No features in backlog for branch "${currentWorktreeBranch}".`
|
||||
: "No features in backlog to start.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
// This matches the auto mode service behavior for consistency
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Start only one feature per keypress (user must press again for next)
|
||||
const featuresToStart = sortedBacklog.slice(0, 1);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
await handleStartImplementation(feature);
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
|
||||
worktreePath = await getOrCreateWorktreeForFeature(feature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(feature.id, { worktreePath });
|
||||
}
|
||||
// Refresh worktree selector after creating worktree
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
// Start the implementation
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({
|
||||
...feature,
|
||||
worktreePath: worktreePath || undefined,
|
||||
});
|
||||
}
|
||||
}, [features, runningAutoTasks, handleStartImplementation]);
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
persistFeatureUpdate,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
useWorktrees,
|
||||
]);
|
||||
|
||||
const handleDeleteAllVerified = useCallback(async () => {
|
||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||
@@ -587,10 +858,7 @@ export function useBoardActions({
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Board] Error stopping feature before delete:",
|
||||
error
|
||||
);
|
||||
console.error("[Board] Error stopping feature before delete:", error);
|
||||
}
|
||||
}
|
||||
removeFeature(feature.id);
|
||||
@@ -600,7 +868,13 @@ export function useBoardActions({
|
||||
toast.success("All verified features deleted", {
|
||||
description: `Deleted ${verifiedFeatures.length} feature(s).`,
|
||||
});
|
||||
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]);
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
autoMode,
|
||||
removeFeature,
|
||||
persistFeatureDelete,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleAddFeature,
|
||||
@@ -614,7 +888,6 @@ export function useBoardActions({
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleRevertFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
@@ -7,12 +9,18 @@ interface UseBoardColumnFeaturesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
searchQuery: string;
|
||||
currentWorktreePath: string | null; // Currently selected worktree path
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||
projectPath: string | null; // Main project path (for main worktree)
|
||||
}
|
||||
|
||||
export function useBoardColumnFeatures({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
}: UseBoardColumnFeaturesProps) {
|
||||
// Memoize column features to prevent unnecessary re-renders
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
@@ -34,16 +42,63 @@ export function useBoardColumnFeatures({
|
||||
)
|
||||
: features;
|
||||
|
||||
// Determine the effective worktree path and branch for filtering
|
||||
// If currentWorktreePath is null, we're on the main worktree
|
||||
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
||||
// Use the branch name from the selected worktree
|
||||
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
||||
// should contain the main branch's actual name, defaulting to "main"
|
||||
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
|
||||
// In that case, we can't do branch-based filtering, so we'll handle it specially below
|
||||
const effectiveBranch = currentWorktreeBranch;
|
||||
|
||||
filteredFeatures.forEach((f) => {
|
||||
// If feature has a running agent, always show it in "in_progress"
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
|
||||
// Check if feature matches the current worktree
|
||||
// Match by worktreePath if set, OR by branchName if set
|
||||
// Features with neither are considered unassigned (show on ALL worktrees)
|
||||
const featureBranch = f.branchName || "main";
|
||||
const hasWorktreeAssigned = f.worktreePath || f.branchName;
|
||||
|
||||
let matchesWorktree: boolean;
|
||||
if (!hasWorktreeAssigned) {
|
||||
// No worktree or branch assigned - show on ALL worktrees (unassigned)
|
||||
matchesWorktree = true;
|
||||
} else if (f.worktreePath) {
|
||||
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
|
||||
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
|
||||
} else if (effectiveBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// (worktrees disabled or haven't loaded yet).
|
||||
// Show features assigned to main/master branch since we're on the main worktree.
|
||||
matchesWorktree = featureBranch === "main" || featureBranch === "master";
|
||||
} else {
|
||||
// Has branchName but no worktreePath - match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
map.in_progress.push(f);
|
||||
// Only show running tasks if they match the current worktree
|
||||
if (matchesWorktree) {
|
||||
map.in_progress.push(f);
|
||||
}
|
||||
} else {
|
||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||
const status = f.status as ColumnId;
|
||||
if (map[status]) {
|
||||
map[status].push(f);
|
||||
|
||||
// Filter all items by worktree, including backlog
|
||||
// This ensures backlog items with a branch assigned only show in that branch
|
||||
if (status === "backlog") {
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
} else if (map[status]) {
|
||||
// Only show if matches current worktree or has no worktree assigned
|
||||
if (matchesWorktree) {
|
||||
map[status].push(f);
|
||||
}
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
map.backlog.push(f);
|
||||
@@ -51,15 +106,16 @@ export function useBoardColumnFeatures({
|
||||
}
|
||||
});
|
||||
|
||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||
map.backlog.sort((a, b) => {
|
||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||
const bPriority = b.priority ?? 999;
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
// Apply dependency-aware sorting to backlog
|
||||
// This ensures features appear in dependency order (dependencies before dependents)
|
||||
// Within the same dependency level, features are sorted by priority
|
||||
if (map.backlog.length > 0) {
|
||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||
map.backlog = orderedFeatures;
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery]);
|
||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Feature } from "@/store/app-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { toast } from "sonner";
|
||||
import { COLUMNS, ColumnId } from "../constants";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
@@ -14,6 +15,8 @@ interface UseBoardDragDropProps {
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||
projectPath: string | null; // Main project path
|
||||
onWorktreeCreated?: () => void; // Callback when a new worktree is created
|
||||
}
|
||||
|
||||
export function useBoardDragDrop({
|
||||
@@ -22,9 +25,66 @@ export function useBoardDragDrop({
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const { moveFeature } = useAppStore();
|
||||
const { moveFeature, useWorktrees } = useAppStore();
|
||||
|
||||
/**
|
||||
* Get or create the worktree path for a feature based on its branchName.
|
||||
* - If branchName is "main" or empty, returns the project path
|
||||
* - Otherwise, creates a worktree for that branch if needed
|
||||
*/
|
||||
const getOrCreateWorktreeForFeature = useCallback(
|
||||
async (feature: Feature): Promise<string | null> => {
|
||||
if (!projectPath) return null;
|
||||
|
||||
const branchName = feature.branchName || "main";
|
||||
|
||||
// If targeting main branch, use the project path directly
|
||||
if (branchName === "main" || branchName === "master") {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// For other branches, create a worktree if it doesn't exist
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.create) {
|
||||
console.error("[DragDrop] Worktree API not available");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Try to create the worktree (will return existing if already exists)
|
||||
const result = await api.worktree.create(projectPath, branchName);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
|
||||
);
|
||||
if (result.worktree.isNew) {
|
||||
toast.success(`Worktree created for branch "${branchName}"`, {
|
||||
description: "A new worktree was created for this feature.",
|
||||
});
|
||||
}
|
||||
return result.worktree.path;
|
||||
} else {
|
||||
console.error("[DragDrop] Failed to create worktree:", result.error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "Could not create worktree for this branch.",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DragDrop] Error creating worktree:", error);
|
||||
toast.error("Error creating worktree", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return projectPath; // Fall back to project path
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
@@ -97,8 +157,20 @@ export function useBoardDragDrop({
|
||||
if (draggedFeature.status === "backlog") {
|
||||
// From backlog
|
||||
if (targetStatus === "in_progress") {
|
||||
// Only create worktrees if the feature is enabled
|
||||
let worktreePath: string | null = null;
|
||||
if (useWorktrees) {
|
||||
// Get or create worktree based on the feature's assigned branch
|
||||
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
|
||||
if (worktreePath) {
|
||||
await persistFeatureUpdate(featureId, { worktreePath });
|
||||
}
|
||||
// Refresh worktree selector after moving to in_progress
|
||||
onWorktreeCreated?.();
|
||||
}
|
||||
// Use helper function to handle concurrency check and start implementation
|
||||
await handleStartImplementation(draggedFeature);
|
||||
// Pass feature with worktreePath so handleRunFeature uses the correct path
|
||||
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||
@@ -123,10 +195,11 @@ export function useBoardDragDrop({
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear justFinishedAt timestamp when moving back to backlog
|
||||
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "backlog",
|
||||
justFinishedAt: undefined,
|
||||
worktreePath: undefined,
|
||||
});
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
@@ -166,7 +239,8 @@ export function useBoardDragDrop({
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving skipTests cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -189,7 +263,8 @@ export function useBoardDragDrop({
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving verified cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
// Clear worktreePath when moving back to backlog
|
||||
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
@@ -205,6 +280,9 @@ export function useBoardDragDrop({
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
getOrCreateWorktreeForFeature,
|
||||
onWorktreeCreated,
|
||||
useWorktrees,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -45,8 +45,6 @@ interface KanbanBoardProps {
|
||||
onMoveBackToInProgress: (feature: Feature) => void;
|
||||
onFollowUp: (feature: Feature) => void;
|
||||
onCommit: (feature: Feature) => void;
|
||||
onRevert: (feature: Feature) => void;
|
||||
onMerge: (feature: Feature) => void;
|
||||
onComplete: (feature: Feature) => void;
|
||||
onImplement: (feature: Feature) => void;
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
@@ -79,8 +77,6 @@ export function KanbanBoard({
|
||||
onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onCommit,
|
||||
onRevert,
|
||||
onMerge,
|
||||
onComplete,
|
||||
onImplement,
|
||||
onViewPlan,
|
||||
@@ -195,8 +191,6 @@ export function KanbanBoard({
|
||||
}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onCommit={() => onCommit(feature)}
|
||||
onRevert={() => onRevert(feature)}
|
||||
onMerge={() => onMerge(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
GitBranchPlus,
|
||||
Check,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo } from "../types";
|
||||
|
||||
interface BranchSwitchDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function BranchSwitchDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
onOpenChange,
|
||||
onFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
}: BranchSwitchDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
title="Switch branch"
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter branches..."
|
||||
value={branchFilter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
className="h-7 pl-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : filteredBranches.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
{branchFilter ? "No matching branches" : "No branches found"}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || branch.name === worktree.branch}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{branch.name === worktree.branch ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onCreateBranch(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||
Create New Branch...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
export { WorktreeTab } from "./worktree-tab";
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
Square,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, DevServerInfo } from "../types";
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
defaultEditorName: string;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeActionsDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
defaultEditorName,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-l-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{isDevServerRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Dev Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartDevServer(worktree)}
|
||||
disabled={isStartingDevServer}
|
||||
className="text-xs"
|
||||
>
|
||||
<Play
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 mr-2",
|
||||
isStartingDevServer && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPull(worktree)}
|
||||
disabled={isPulling}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||
/>
|
||||
{isPulling ? "Pulling..." : "Pull"}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||
/>
|
||||
{isPushing ? "Pushing..." : "Push"}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInEditor(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
isRunning: boolean;
|
||||
isActivating: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
defaultEditorName: string;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
onBranchFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
worktree,
|
||||
isSelected,
|
||||
isRunning,
|
||||
isActivating,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
defaultEditorName,
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
onBranchFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={onBranchDropdownOpenChange}
|
||||
onFilterChange={onBranchFilterChange}
|
||||
onSwitchBranch={onSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={
|
||||
worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{worktree.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{worktree.changedFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isDevServerRunning && (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
"text-green-500"
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
defaultEditorName={defaultEditorName}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useWorktrees } from "./use-worktrees";
|
||||
export { useDevServers } from "./use-dev-servers";
|
||||
export { useBranches } from "./use-branches";
|
||||
export { useWorktreeActions } from "./use-worktree-actions";
|
||||
export { useDefaultEditor } from "./use-default-editor";
|
||||
export { useRunningFeatures } from "./use-running-features";
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { BranchInfo } from "../types";
|
||||
|
||||
export function useBranches() {
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [aheadCount, setAheadCount] = useState(0);
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
|
||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn("List branches API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch branches:", error);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetBranchFilter = useCallback(() => {
|
||||
setBranchFilter("");
|
||||
}, []);
|
||||
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
export function useDefaultEditor() {
|
||||
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
|
||||
|
||||
const fetchDefaultEditor = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getDefaultEditor) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getDefaultEditor();
|
||||
if (result.success && result.result?.editorName) {
|
||||
setDefaultEditorName(result.result.editorName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch default editor:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefaultEditor();
|
||||
}, [fetchDefaultEditor]);
|
||||
|
||||
return {
|
||||
defaultEditorName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { normalizePath } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { DevServerInfo, WorktreeInfo } from "../types";
|
||||
|
||||
interface UseDevServersOptions {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const fetchDevServers = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listDevServers) {
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listDevServers();
|
||||
if (result.success && result.result?.servers) {
|
||||
const serversMap = new Map<string, DevServerInfo>();
|
||||
for (const server of result.result.servers) {
|
||||
serversMap.set(server.worktreePath, server);
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dev servers:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
const getWorktreeKey = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const path = worktree.isMain ? projectPath : worktree.path;
|
||||
return path ? normalizePath(path) : path;
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleStartDevServer = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isStartingDevServer) return;
|
||||
setIsStartingDevServer(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.startDevServer) {
|
||||
toast.error("Start dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalizePath(targetPath), {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
toast.success(`Dev server started on port ${result.result.port}`);
|
||||
} else {
|
||||
toast.error(result.error || "Failed to start dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Start dev server failed:", error);
|
||||
toast.error("Failed to start dev server");
|
||||
} finally {
|
||||
setIsStartingDevServer(false);
|
||||
}
|
||||
},
|
||||
[isStartingDevServer, projectPath]
|
||||
);
|
||||
|
||||
const handleStopDevServer = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.stopDevServer) {
|
||||
toast.error("Stop dev server API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const result = await api.worktree.stopDevServer(targetPath);
|
||||
|
||||
if (result.success) {
|
||||
setRunningDevServers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(normalizePath(targetPath));
|
||||
return next;
|
||||
});
|
||||
toast.success(result.result?.message || "Dev server stopped");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to stop dev server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stop dev server failed:", error);
|
||||
toast.error("Failed to stop dev server");
|
||||
}
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleOpenDevServerUrl = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const serverInfo = runningDevServers.get(targetPath);
|
||||
if (serverInfo) {
|
||||
window.open(serverInfo.url, "_blank");
|
||||
}
|
||||
},
|
||||
[projectPath, runningDevServers]
|
||||
);
|
||||
|
||||
const isDevServerRunning = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
return runningDevServers.has(getWorktreeKey(worktree));
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
);
|
||||
|
||||
const getDevServerInfo = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
return runningDevServers.get(getWorktreeKey(worktree));
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
);
|
||||
|
||||
return {
|
||||
isStartingDevServer,
|
||||
runningDevServers,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
handleStopDevServer,
|
||||
handleOpenDevServerUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||
|
||||
interface UseRunningFeaturesOptions {
|
||||
projectPath: string;
|
||||
runningFeatureIds: string[];
|
||||
features: FeatureInfo[];
|
||||
getWorktreeKey: (worktree: WorktreeInfo) => string;
|
||||
}
|
||||
|
||||
export function useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
}: UseRunningFeaturesOptions) {
|
||||
const hasRunningFeatures = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
if (runningFeatureIds.length === 0) return false;
|
||||
|
||||
const worktreeKey = getWorktreeKey(worktree);
|
||||
|
||||
return runningFeatureIds.some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
if (feature.worktreePath) {
|
||||
if (worktree.isMain) {
|
||||
return pathsEqual(feature.worktreePath, projectPath);
|
||||
}
|
||||
return pathsEqual(feature.worktreePath, worktreeKey);
|
||||
}
|
||||
|
||||
if (feature.branchName) {
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
return worktree.isMain;
|
||||
});
|
||||
},
|
||||
[runningFeatureIds, features, projectPath, getWorktreeKey]
|
||||
);
|
||||
|
||||
return {
|
||||
hasRunningFeatures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<void>;
|
||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
}: UseWorktreeActionsOptions) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (worktree: WorktreeInfo, branchName: string) => {
|
||||
if (isSwitching || branchName === worktree.branch) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.switchBranch) {
|
||||
toast.error("Switch branch API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to switch branch");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Switch branch failed:", error);
|
||||
toast.error("Failed to switch branch");
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[isSwitching, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePull = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isPulling) return;
|
||||
setIsPulling(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
toast.error("Pull API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.pull(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to pull latest changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Pull failed:", error);
|
||||
toast.error("Failed to pull latest changes");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
},
|
||||
[isPulling, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePush = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (isPushing) return;
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error("Push API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to push changes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Push failed:", error);
|
||||
toast.error("Failed to push changes");
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
},
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
console.warn("Open in editor API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Open in editor failed:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
setIsActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInEditor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWorktrees();
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
},
|
||||
[projectPath, setCurrentWorktree]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
selectedWorktree,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { WorktreePanel } from "./worktree-panel";
|
||||
export type {
|
||||
WorktreeInfo,
|
||||
BranchInfo,
|
||||
DevServerInfo,
|
||||
FeatureInfo,
|
||||
WorktreePanelProps,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FeatureInfo {
|
||||
id: string;
|
||||
worktreePath?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitBranch, Plus, RefreshCw } from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
useBranches,
|
||||
useWorktreeActions,
|
||||
useDefaultEditor,
|
||||
useRunningFeatures,
|
||||
} from "./hooks";
|
||||
import { WorktreeTab } from "./components";
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
onDeleteWorktree,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
refreshTrigger = 0,
|
||||
}: WorktreePanelProps) {
|
||||
const {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
} = useWorktrees({ projectPath, refreshTrigger });
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
handleStopDevServer,
|
||||
handleOpenDevServerUrl,
|
||||
} = useDevServers({ projectPath });
|
||||
|
||||
const {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
} = useBranches();
|
||||
|
||||
const {
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInEditor,
|
||||
} = useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
});
|
||||
|
||||
const { defaultEditorName } = useDefaultEditor();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
features,
|
||||
getWorktreeKey,
|
||||
});
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
};
|
||||
|
||||
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
resetBranchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
};
|
||||
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{worktrees.map((worktree) => (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchWorktrees}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BookOpen,
|
||||
EditIcon,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
@@ -56,6 +57,8 @@ export function ContextView() {
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameFileName, setRenameFileName] = useState("");
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
||||
@@ -240,6 +243,60 @@ export function ContextView() {
|
||||
}
|
||||
};
|
||||
|
||||
// Rename selected file
|
||||
const handleRenameFile = async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
||||
|
||||
const newName = renameFileName.trim();
|
||||
if (newName === selectedFile.name) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const newPath = `${contextPath}/${newName}`;
|
||||
|
||||
// Check if file with new name already exists
|
||||
const exists = await api.exists(newPath);
|
||||
if (exists) {
|
||||
console.error("A file with this name already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current file content
|
||||
const result = await api.readFile(selectedFile.path);
|
||||
if (!result.success || result.content === undefined) {
|
||||
console.error("Failed to read file for rename");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to new path
|
||||
await api.writeFile(newPath, result.content);
|
||||
|
||||
// Delete old file
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
|
||||
// Reload files and select the renamed file
|
||||
await loadContextFiles();
|
||||
|
||||
// Update selected file with new name and path
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? "image" : "text",
|
||||
path: newPath,
|
||||
content: result.content,
|
||||
};
|
||||
setSelectedFile(renamedFile);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image upload
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -418,24 +475,40 @@ export function ContextView() {
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{contextFiles.map((file) => (
|
||||
<button
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => handleSelectFile(file)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
|
||||
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
|
||||
selectedFile?.path === file.path
|
||||
? "bg-primary/20 text-foreground border border-primary/30"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
data-testid={`context-file-${file.name}`}
|
||||
>
|
||||
{file.type === "image" ? (
|
||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm">{file.name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectFile(file)}
|
||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||
data-testid={`context-file-${file.name}`}
|
||||
>
|
||||
{file.type === "image" ? (
|
||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm">{file.name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameFileName(file.name);
|
||||
setSelectedFile(file);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||
data-testid={`rename-context-file-${file.name}`}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -730,6 +803,53 @@ export function ContextView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent data-testid="rename-context-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for "{selectedFile?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-filename">File Name</Label>
|
||||
<Input
|
||||
id="rename-filename"
|
||||
value={renameFileName}
|
||||
onChange={(e) => setRenameFileName(e.target.value)}
|
||||
placeholder="Enter new filename"
|
||||
data-testid="rename-file-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && renameFileName.trim()) {
|
||||
handleRenameFile();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRenameFile}
|
||||
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
||||
data-testid="confirm-rename-file"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export function SettingsView() {
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
setEnableDependencyBlocking,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
showProfilesOnly,
|
||||
@@ -122,11 +124,13 @@ export function SettingsView() {
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
enableDependencyBlocking={enableDependencyBlocking}
|
||||
useWorktrees={useWorktrees}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
FlaskConical, Settings2, TestTube, GitBranch,
|
||||
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
|
||||
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -18,11 +18,13 @@ type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
interface FeatureDefaultsSectionProps {
|
||||
showProfilesOnly: boolean;
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
useWorktrees: boolean;
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
@@ -31,11 +33,13 @@ interface FeatureDefaultsSectionProps {
|
||||
export function FeatureDefaultsSection({
|
||||
showProfilesOnly,
|
||||
defaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
useWorktrees,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onUseWorktreesChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
@@ -224,22 +228,51 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Dependency Blocking Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="enable-dependency-blocking"
|
||||
checked={enableDependencyBlocking}
|
||||
onCheckedChange={(checked) =>
|
||||
onEnableDependencyBlockingChange(checked === true)
|
||||
}
|
||||
className="mt-1"
|
||||
data-testid="enable-dependency-blocking-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="enable-dependency-blocking"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-brand-500" />
|
||||
Enable Dependency Blocking
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
When enabled, features with incomplete dependencies will show blocked badges
|
||||
and warnings. Auto mode and backlog ordering always respect dependencies
|
||||
regardless of this setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Worktree Isolation Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60">
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="use-worktrees"
|
||||
checked={useWorktrees}
|
||||
onCheckedChange={(checked) =>
|
||||
onUseWorktreesChange(checked === true)
|
||||
}
|
||||
disabled={true}
|
||||
className="mt-1"
|
||||
data-testid="use-worktrees-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="use-worktrees"
|
||||
className="text-foreground font-medium flex items-center gap-2"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
Enable Git Worktree Isolation
|
||||
@@ -251,9 +284,6 @@ export function FeatureDefaultsSection({
|
||||
Creates isolated git branches for each feature. When disabled,
|
||||
agents work directly in the main project directory.
|
||||
</p>
|
||||
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
|
||||
⚠️ This feature is still under development and temporarily disabled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,10 @@ export type Theme =
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red";
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WelcomeStep,
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
GitHubSetupStep,
|
||||
} from "./setup-view/steps";
|
||||
|
||||
// Main Setup View
|
||||
@@ -19,12 +20,13 @@ export function SetupView() {
|
||||
} = useSetupStore();
|
||||
const { setCurrentView } = useAppStore();
|
||||
|
||||
const steps = ["welcome", "claude", "complete"] as const;
|
||||
const steps = ["welcome", "claude", "github", "complete"] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||
return "claude";
|
||||
if (currentStep === "welcome") return "welcome";
|
||||
if (currentStep === "github") return "github";
|
||||
return "complete";
|
||||
};
|
||||
const currentIndex = steps.indexOf(getStepName());
|
||||
@@ -42,6 +44,10 @@ export function SetupView() {
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
case "claude":
|
||||
console.log("[Setup Flow] Moving to github step");
|
||||
setCurrentStep("github");
|
||||
break;
|
||||
case "github":
|
||||
console.log("[Setup Flow] Moving to complete step");
|
||||
setCurrentStep("complete");
|
||||
break;
|
||||
@@ -54,12 +60,20 @@ export function SetupView() {
|
||||
case "claude":
|
||||
setCurrentStep("welcome");
|
||||
break;
|
||||
case "github":
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipClaude = () => {
|
||||
console.log("[Setup Flow] Skipping Claude setup");
|
||||
setSkipClaudeSetup(true);
|
||||
setCurrentStep("github");
|
||||
};
|
||||
|
||||
const handleSkipGithub = () => {
|
||||
console.log("[Setup Flow] Skipping GitHub setup");
|
||||
setCurrentStep("complete");
|
||||
};
|
||||
|
||||
@@ -110,6 +124,14 @@ export function SetupView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "github" && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext("github")}
|
||||
onBack={() => handleBack("github")}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Github,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "../components";
|
||||
|
||||
interface GitHubSetupStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function GitHubSetupStep({
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: GitHubSetupStepProps) {
|
||||
const { ghCliStatus, setGhCliStatus } = useSetupStore();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.setup?.getGhStatus) {
|
||||
return;
|
||||
}
|
||||
const result = await api.setup.getGhStatus();
|
||||
if (result.success) {
|
||||
setGhCliStatus({
|
||||
installed: result.installed,
|
||||
authenticated: result.authenticated,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
user: result.user,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check gh status:", error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [setGhCliStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const copyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
const isReady = ghCliStatus?.installed && ghCliStatus?.authenticated;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (isChecking) {
|
||||
return <StatusBadge status="checking" label="Checking..." />;
|
||||
}
|
||||
if (ghCliStatus?.authenticated) {
|
||||
return <StatusBadge status="authenticated" label="Ready" />;
|
||||
}
|
||||
if (ghCliStatus?.installed) {
|
||||
return <StatusBadge status="unverified" label="Not Logged In" />;
|
||||
}
|
||||
return <StatusBadge status="not_installed" label="Not Installed" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Github className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
GitHub CLI Setup
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Optional - Used for creating pull requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card className="bg-amber-500/10 border-amber-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
This step is optional
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The GitHub CLI allows you to create pull requests directly from
|
||||
the app. Without it, you can still create PRs manually in your
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Github className="w-5 h-5" />
|
||||
GitHub CLI Status
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{ghCliStatus?.installed
|
||||
? ghCliStatus.authenticated
|
||||
? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}`
|
||||
: "Installed but not logged in"
|
||||
: "Not installed on your system"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Success State */}
|
||||
{isReady && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
GitHub CLI is ready!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can create pull requests directly from the app.
|
||||
{ghCliStatus?.version && (
|
||||
<span className="ml-1">Version: {ghCliStatus.version}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not Installed */}
|
||||
{!ghCliStatus?.installed && !isChecking && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
GitHub CLI not found
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Install the GitHub CLI to enable PR creation from the app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<p className="font-medium text-foreground text-sm">
|
||||
Installation Commands:
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">macOS (Homebrew)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
brew install gh
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("brew install gh")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Windows (winget)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
winget install GitHub.cli
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("winget install GitHub.cli")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Linux (apt)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
|
||||
sudo apt install gh
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("sudo apt install gh")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://cli.github.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
|
||||
>
|
||||
View all installation options
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installed but not authenticated */}
|
||||
{ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
GitHub CLI not logged in
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Run the login command to authenticate with GitHub.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run this command in your terminal:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
gh auth login
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand("gh auth login")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isChecking && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
Checking GitHub CLI status...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{isReady ? "Skip" : "Skip for now"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid="github-next-button"
|
||||
>
|
||||
{isReady ? "Continue" : "Continue without GitHub CLI"}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
export { WelcomeStep } from "./welcome-step";
|
||||
export { CompleteStep } from "./complete-step";
|
||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||
export { GitHubSetupStep } from "./github-setup-step";
|
||||
|
||||
Reference in New Issue
Block a user