mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge remote-tracking branch 'origin/main' into terminals-mpve
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DeleteAllArchivedSessionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
archivedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteAllArchivedSessionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
archivedCount,
|
||||
onConfirm,
|
||||
}: DeleteAllArchivedSessionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="delete-all-archived-sessions-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Archived Sessions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete all archived sessions? This action
|
||||
cannot be undone.
|
||||
{archivedCount > 0 && (
|
||||
<span className="block mt-2 text-yellow-500">
|
||||
{archivedCount} session(s) will be deleted.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
data-testid="confirm-delete-all-archived-sessions"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete All
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
52
apps/ui/src/components/delete-session-dialog.tsx
Normal file
52
apps/ui/src/components/delete-session-dialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
|
||||
interface DeleteSessionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
session: SessionListItem | null;
|
||||
onConfirm: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function DeleteSessionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
session,
|
||||
onConfirm,
|
||||
}: DeleteSessionDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
if (session) {
|
||||
onConfirm(session.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={handleConfirm}
|
||||
title="Delete Session"
|
||||
description="Are you sure you want to delete this session? This action cannot be undone."
|
||||
confirmText="Delete Session"
|
||||
testId="delete-session-dialog"
|
||||
confirmTestId="confirm-delete-session"
|
||||
>
|
||||
{session && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
||||
<MessageSquare className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{session.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{session.messageCount} messages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DeleteConfirmDialog>
|
||||
);
|
||||
}
|
||||
519
apps/ui/src/components/dialogs/board-background-modal.tsx
Normal file
519
apps/ui/src/components/dialogs/board-background-modal.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
interface BoardBackgroundModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function BoardBackgroundModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BoardBackgroundModalProps) {
|
||||
const {
|
||||
currentProject,
|
||||
boardBackgroundByProject,
|
||||
setBoardBackground,
|
||||
setCardOpacity,
|
||||
setColumnOpacity,
|
||||
setColumnBorderEnabled,
|
||||
setCardGlassmorphism,
|
||||
setCardBorderEnabled,
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
} = useAppStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// Get current background settings (live from store)
|
||||
const backgroundSettings =
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||
defaultBackgroundSettings;
|
||||
|
||||
const cardOpacity = backgroundSettings.cardOpacity;
|
||||
const columnOpacity = backgroundSettings.columnOpacity;
|
||||
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||
const imageVersion = backgroundSettings.imageVersion;
|
||||
|
||||
// Update preview image when background settings change
|
||||
useEffect(() => {
|
||||
if (currentProject && backgroundSettings.imagePath) {
|
||||
const serverUrl =
|
||||
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
||||
// Add cache-busting query parameter to force browser to reload image
|
||||
const cacheBuster = imageVersion
|
||||
? `&v=${imageVersion}`
|
||||
: `&v=${Date.now()}`;
|
||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
|
||||
setPreviewImage(imagePath);
|
||||
} else {
|
||||
setPreviewImage(null);
|
||||
}
|
||||
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!currentProject) {
|
||||
toast.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
toast.error(
|
||||
"Unsupported file type. Please use JPG, PNG, GIF, or WebP."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > DEFAULT_MAX_FILE_SIZE) {
|
||||
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
|
||||
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
|
||||
// Set preview immediately
|
||||
setPreviewImage(base64);
|
||||
|
||||
// Save to server
|
||||
const httpClient = getHttpApiClient();
|
||||
const result = await httpClient.saveBoardBackground(
|
||||
base64,
|
||||
file.name,
|
||||
file.type,
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (result.success && result.path) {
|
||||
// Update store with the relative path (live update)
|
||||
setBoardBackground(currentProject.path, result.path);
|
||||
toast.success("Background image saved");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save background image");
|
||||
setPreviewImage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process image:", error);
|
||||
toast.error("Failed to process image");
|
||||
setPreviewImage(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[currentProject, setBoardBackground]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
},
|
||||
[processFile]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFile]
|
||||
);
|
||||
|
||||
const handleBrowseClick = useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
const result = await httpClient.deleteBoardBackground(
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
clearBoardBackground(currentProject.path);
|
||||
setPreviewImage(null);
|
||||
toast.success("Background image cleared");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to clear background image");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear background:", error);
|
||||
toast.error("Failed to clear background");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// Live update opacity when sliders change
|
||||
const handleCardOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardOpacity]
|
||||
);
|
||||
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setColumnOpacity]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setColumnBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setColumnBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardGlassmorphismToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setCardGlassmorphism(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardGlassmorphism]
|
||||
);
|
||||
|
||||
const handleCardBorderToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setCardBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardBorderOpacity]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setHideScrollbar(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setHideScrollbar]
|
||||
);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5 text-brand-500" />
|
||||
Board Background Settings
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-muted-foreground">
|
||||
Set a custom background image for your kanban board and adjust
|
||||
card/column opacity
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
{/* Image Upload Section */}
|
||||
<div className="space-y-3">
|
||||
<Label>Background Image</Label>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
||||
{
|
||||
"border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10":
|
||||
isDragOver && !isProcessing,
|
||||
"border-muted-foreground/25": !isDragOver && !isProcessing,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed":
|
||||
isProcessing,
|
||||
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
|
||||
!isProcessing && !isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{previewImage ? (
|
||||
<div className="relative p-4">
|
||||
<div className="relative w-full h-48 rounded-md overflow-hidden border border-border bg-muted">
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Background preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowseClick}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Change Image
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleBrowseClick}
|
||||
className="flex flex-col items-center justify-center p-8 text-center cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-3 mb-3",
|
||||
isDragOver && !isProcessing
|
||||
? "bg-brand-500/10 dark:bg-brand-500/20"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragOver && !isProcessing
|
||||
? "Drop image here"
|
||||
: "Click to upload or drag and drop"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
JPG, PNG, GIF, or WebP (max{" "}
|
||||
{Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opacity Controls */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{cardOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cardOpacity]}
|
||||
onValueChange={handleCardOpacityChange}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Column Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{columnOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[columnOpacity]}
|
||||
onValueChange={handleColumnOpacityChange}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column Border Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="column-border-toggle"
|
||||
checked={columnBorderEnabled}
|
||||
onCheckedChange={handleColumnBorderToggle}
|
||||
/>
|
||||
<Label htmlFor="column-border-toggle" className="cursor-pointer">
|
||||
Show Column Borders
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Card Glassmorphism Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="card-glassmorphism-toggle"
|
||||
checked={cardGlassmorphism}
|
||||
onCheckedChange={handleCardGlassmorphismToggle}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="card-glassmorphism-toggle"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Card Glassmorphism (blur effect)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Card Border Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="card-border-toggle"
|
||||
checked={cardBorderEnabled}
|
||||
onCheckedChange={handleCardBorderToggle}
|
||||
/>
|
||||
<Label htmlFor="card-border-toggle" className="cursor-pointer">
|
||||
Show Card Borders
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Card Border Opacity - only show when border is enabled */}
|
||||
{cardBorderEnabled && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Border Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{cardBorderOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cardBorderOpacity]}
|
||||
onValueChange={handleCardBorderOpacityChange}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide Scrollbar Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="hide-scrollbar-toggle"
|
||||
checked={hideScrollbar}
|
||||
onCheckedChange={handleHideScrollbarToggle}
|
||||
/>
|
||||
<Label htmlFor="hide-scrollbar-toggle" className="cursor-pointer">
|
||||
Hide Board Scrollbar
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
410
apps/ui/src/components/dialogs/file-browser-dialog.tsx
Normal file
410
apps/ui/src/components/dialogs/file-browser-dialog.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
ChevronRight,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
HardDrive,
|
||||
CornerDownLeft,
|
||||
Clock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface BrowseResult {
|
||||
success: boolean;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
directories: DirectoryEntry[];
|
||||
drives?: string[];
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface FileBrowserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (path: string) => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
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,
|
||||
onSelect,
|
||||
title = "Select Project Directory",
|
||||
description = "Navigate to your project folder or paste a path directly",
|
||||
initialPath,
|
||||
}: FileBrowserDialogProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [pathInput, setPathInput] = useState<string>("");
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
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("");
|
||||
setWarning("");
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl =
|
||||
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
setPathInput(result.currentPath);
|
||||
setParentPath(result.parentPath);
|
||||
setDirectories(result.directories);
|
||||
setDrives(result.drives || []);
|
||||
setWarning(result.warning || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to browse directory");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load directories"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset current path when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentPath("");
|
||||
setPathInput("");
|
||||
setParentPath(null);
|
||||
setDirectories([]);
|
||||
setError("");
|
||||
setWarning("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load initial path or home directory when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !currentPath) {
|
||||
browseDirectory(initialPath);
|
||||
}
|
||||
}, [open, initialPath]);
|
||||
|
||||
const handleSelectDirectory = (dir: DirectoryEntry) => {
|
||||
browseDirectory(dir.path);
|
||||
};
|
||||
|
||||
const handleGoToParent = () => {
|
||||
if (parentPath) {
|
||||
browseDirectory(parentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
browseDirectory();
|
||||
};
|
||||
|
||||
const handleSelectDrive = (drivePath: string) => {
|
||||
browseDirectory(drivePath);
|
||||
};
|
||||
|
||||
const handleGoToPath = () => {
|
||||
const trimmedPath = pathInput.trim();
|
||||
if (trimmedPath) {
|
||||
browseDirectory(trimmedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleGoToPath();
|
||||
}
|
||||
};
|
||||
|
||||
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-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 text-xs">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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-1.5">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
type="text"
|
||||
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathInputKeyDown}
|
||||
className="flex-1 font-mono text-xs h-8"
|
||||
data-testid="path-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGoToPath}
|
||||
disabled={loading || !pathInput.trim()}
|
||||
data-testid="go-to-path-button"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<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-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>
|
||||
{drives.map((drive) => (
|
||||
<Button
|
||||
key={drive}
|
||||
variant={
|
||||
currentPath.startsWith(drive) ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace("\\", "")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<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-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<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-md">
|
||||
{loading && (
|
||||
<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-4">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warning && (
|
||||
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
|
||||
<div className="text-xs text-yellow-500">{warning}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !warning && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No subdirectories found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && directories.length > 0 && (
|
||||
<div className="divide-y divide-sidebar-border">
|
||||
{directories.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelectDirectory(dir)}
|
||||
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-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-[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-3 gap-2 mt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2413
apps/ui/src/components/layout/sidebar.tsx
Normal file
2413
apps/ui/src/components/layout/sidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
514
apps/ui/src/components/new-project-modal.tsx
Normal file
514
apps/ui/src/components/new-project-modal.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
FolderPlus,
|
||||
FolderOpen,
|
||||
Rocket,
|
||||
ExternalLink,
|
||||
Check,
|
||||
Loader2,
|
||||
Link,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
workspaceDir?: boolean;
|
||||
templateSelection?: boolean;
|
||||
customUrl?: boolean;
|
||||
}
|
||||
|
||||
interface NewProjectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBlankProject: (
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
onCreateFromTemplate: (
|
||||
template: StarterTemplate,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
onCreateFromCustomUrl: (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export function NewProjectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateBlankProject,
|
||||
onCreateFromTemplate,
|
||||
onCreateFromCustomUrl,
|
||||
isCreating,
|
||||
}: NewProjectModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [workspaceDir, setWorkspaceDir] = useState<string>("");
|
||||
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<StarterTemplate | null>(null);
|
||||
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// First, check localStorage for last used directory
|
||||
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
|
||||
if (lastUsedDir) {
|
||||
setWorkspaceDir(lastUsedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to server config if no saved directory
|
||||
setIsLoadingWorkspace(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace
|
||||
.getConfig()
|
||||
.then((result) => {
|
||||
if (result.success && result.workspaceDir) {
|
||||
setWorkspaceDir(result.workspaceDir);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get workspace config:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingWorkspace(false);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setProjectName("");
|
||||
setSelectedTemplate(null);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl("");
|
||||
setActiveTab("blank");
|
||||
setErrors({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Clear specific errors when user fixes them
|
||||
useEffect(() => {
|
||||
if (projectName && errors.projectName) {
|
||||
setErrors((prev) => ({ ...prev, projectName: false }));
|
||||
}
|
||||
}, [projectName, errors.projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(selectedTemplate || (useCustomUrl && customUrl)) &&
|
||||
errors.templateSelection
|
||||
) {
|
||||
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
||||
}
|
||||
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customUrl && errors.customUrl) {
|
||||
setErrors((prev) => ({ ...prev, customUrl: false }));
|
||||
}
|
||||
}, [customUrl, errors.customUrl]);
|
||||
|
||||
const validateAndCreate = async () => {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
// Check project name
|
||||
if (!projectName.trim()) {
|
||||
newErrors.projectName = true;
|
||||
}
|
||||
|
||||
// Check workspace dir
|
||||
if (!workspaceDir) {
|
||||
newErrors.workspaceDir = true;
|
||||
}
|
||||
|
||||
// Check template selection (only for template tab)
|
||||
if (activeTab === "template") {
|
||||
if (useCustomUrl) {
|
||||
if (!customUrl.trim()) {
|
||||
newErrors.customUrl = true;
|
||||
}
|
||||
} else if (!selectedTemplate) {
|
||||
newErrors.templateSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are errors, show them and don't proceed
|
||||
if (Object.values(newErrors).some(Boolean)) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear errors and proceed
|
||||
setErrors({});
|
||||
|
||||
if (activeTab === "blank") {
|
||||
await onCreateBlankProject(projectName, workspaceDir);
|
||||
} else if (useCustomUrl && customUrl) {
|
||||
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
||||
} else if (selectedTemplate) {
|
||||
await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRepo = (url: string) => {
|
||||
const api = getElectronAPI();
|
||||
api.openExternalLink(url);
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (template: StarterTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl("");
|
||||
};
|
||||
|
||||
const handleToggleCustomUrl = () => {
|
||||
setUseCustomUrl(!useCustomUrl);
|
||||
if (!useCustomUrl) {
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseDirectory = async () => {
|
||||
const selectedPath = await openFileBrowser({
|
||||
title: "Select Base Project Directory",
|
||||
description:
|
||||
"Choose the parent directory where your project will be created",
|
||||
initialPath: workspaceDir || undefined,
|
||||
});
|
||||
if (selectedPath) {
|
||||
setWorkspaceDir(selectedPath);
|
||||
// Save to localStorage for next time
|
||||
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
|
||||
// Clear any workspace error when a valid directory is selected
|
||||
if (errors.workspaceDir) {
|
||||
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use platform-specific path separator
|
||||
const pathSep =
|
||||
typeof window !== "undefined" && (window as any).electronAPI
|
||||
? navigator.platform.indexOf("Win") !== -1
|
||||
? "\\"
|
||||
: "/"
|
||||
: "/";
|
||||
const projectPath =
|
||||
workspaceDir && projectName
|
||||
? `${workspaceDir}${pathSep}${projectName}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="bg-card border-border max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
data-testid="new-project-modal"
|
||||
>
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-foreground">
|
||||
Create New Project
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Start with a blank project or choose from a starter template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Project Name Input - Always visible at top */}
|
||||
<div className="space-y-3 pb-4 border-b border-border">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="project-name"
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
errors.projectName && "text-red-500"
|
||||
)}
|
||||
>
|
||||
Project Name{" "}
|
||||
{errors.projectName && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className={cn(
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.projectName
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="project-name-input"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-xs text-red-500">Project name is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace Directory Display */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Folder className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{isLoadingWorkspace ? (
|
||||
"Loading workspace..."
|
||||
) : workspaceDir ? (
|
||||
<>
|
||||
Will be created at:{" "}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
||||
{projectPath || workspaceDir}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-500">No workspace configured</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBrowseDirectory}
|
||||
disabled={isLoadingWorkspace}
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
data-testid="browse-directory-button"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="blank" className="gap-2">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Blank Project
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="template" className="gap-2">
|
||||
<Rocket className="w-4 h-4" />
|
||||
Starter Kit
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<TabsContent value="blank" className="mt-0">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an empty project with the standard .automaker directory
|
||||
structure. Perfect for starting from scratch or importing an
|
||||
existing codebase.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="template" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Error message for template selection */}
|
||||
{errors.templateSelection && (
|
||||
<p className="text-sm text-red-500">
|
||||
Please select a template or enter a custom GitHub URL
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Preset Templates */}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg p-1 -m-1",
|
||||
errors.templateSelection && "ring-2 ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
{starterTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
selectedTemplate?.id === template.id && !useCustomUrl
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
data-testid={`template-${template.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-foreground">
|
||||
{template.name}
|
||||
</h4>
|
||||
{selectedTemplate?.id === template.id &&
|
||||
!useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{template.techStack.slice(0, 6).map((tech) => (
|
||||
<Badge
|
||||
key={tech}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
{template.techStack.length > 6 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{template.techStack.length - 6} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Features */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Features: </span>
|
||||
{template.features.slice(0, 3).join(" · ")}
|
||||
{template.features.length > 3 &&
|
||||
` · +${template.features.length - 3} more`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenRepo(template.repoUrl);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom URL Option */}
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
useCustomUrl
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleToggleCustomUrl}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="font-medium text-foreground">
|
||||
Custom GitHub URL
|
||||
</h4>
|
||||
{useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Clone any public GitHub repository as a starting point.
|
||||
</p>
|
||||
|
||||
{useCustomUrl && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="space-y-1"
|
||||
>
|
||||
<Input
|
||||
placeholder="https://github.com/username/repository"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
className={cn(
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.customUrl
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="custom-url-input"
|
||||
/>
|
||||
{errors.customUrl && (
|
||||
<p className="text-xs text-red-500">
|
||||
GitHub URL is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="border-t border-border pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={validateAndCreate}
|
||||
disabled={isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{activeTab === "template" ? "Cloning..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>Create Project</>
|
||||
)}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
611
apps/ui/src/components/session-manager.tsx
Normal file
611
apps/ui/src/components/session-manager.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Archive,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Check,
|
||||
X,
|
||||
ArchiveRestore,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
||||
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
|
||||
|
||||
// Random session name generator
|
||||
const adjectives = [
|
||||
"Swift",
|
||||
"Bright",
|
||||
"Clever",
|
||||
"Dynamic",
|
||||
"Eager",
|
||||
"Focused",
|
||||
"Gentle",
|
||||
"Happy",
|
||||
"Inventive",
|
||||
"Jolly",
|
||||
"Keen",
|
||||
"Lively",
|
||||
"Mighty",
|
||||
"Noble",
|
||||
"Optimal",
|
||||
"Peaceful",
|
||||
"Quick",
|
||||
"Radiant",
|
||||
"Smart",
|
||||
"Tranquil",
|
||||
"Unique",
|
||||
"Vibrant",
|
||||
"Wise",
|
||||
"Zealous",
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
"Agent",
|
||||
"Builder",
|
||||
"Coder",
|
||||
"Developer",
|
||||
"Explorer",
|
||||
"Forge",
|
||||
"Garden",
|
||||
"Helper",
|
||||
"Innovator",
|
||||
"Journey",
|
||||
"Kernel",
|
||||
"Lighthouse",
|
||||
"Mission",
|
||||
"Navigator",
|
||||
"Oracle",
|
||||
"Project",
|
||||
"Quest",
|
||||
"Runner",
|
||||
"Spark",
|
||||
"Task",
|
||||
"Unicorn",
|
||||
"Voyage",
|
||||
"Workshop",
|
||||
];
|
||||
|
||||
function generateRandomSessionName(): string {
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const number = Math.floor(Math.random() * 100);
|
||||
return `${adjective} ${noun} ${number}`;
|
||||
}
|
||||
|
||||
interface SessionManagerProps {
|
||||
currentSessionId: string | null;
|
||||
onSelectSession: (sessionId: string | null) => void;
|
||||
projectPath: string;
|
||||
isCurrentSessionThinking?: boolean;
|
||||
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
export function SessionManager({
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
projectPath,
|
||||
isCurrentSessionThinking = false,
|
||||
onQuickCreateRef,
|
||||
}: SessionManagerProps) {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState("");
|
||||
const [runningSessions, setRunningSessions] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] =
|
||||
useState<SessionListItem | null>(null);
|
||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
// Check running state for all sessions
|
||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
|
||||
const runningIds = new Set<string>();
|
||||
|
||||
// Check each session's running state
|
||||
for (const session of sessionList) {
|
||||
try {
|
||||
const result = await api.agent.getHistory(session.id);
|
||||
if (result.success && result.isRunning) {
|
||||
runningIds.add(session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors for individual session checks
|
||||
console.warn(
|
||||
`[SessionManager] Failed to check running state for ${session.id}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setRunningSessions(runningIds);
|
||||
};
|
||||
|
||||
// Load sessions
|
||||
const loadSessions = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
// Always load all sessions and filter client-side
|
||||
const result = await api.sessions.list(true);
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions);
|
||||
// Check running state for all sessions
|
||||
await checkRunningSessions(result.sessions);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, []);
|
||||
|
||||
// Periodically check running state for sessions (useful for detecting when agents finish)
|
||||
useEffect(() => {
|
||||
// Only poll if there are running sessions
|
||||
if (runningSessions.size === 0 && !isCurrentSessionThinking) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (sessions.length > 0) {
|
||||
await checkRunningSessions(sessions);
|
||||
}
|
||||
}, 3000); // Check every 3 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
|
||||
|
||||
// Create new session with random name
|
||||
const handleCreateSession = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName("");
|
||||
setIsCreating(false);
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Create new session directly with a random name (one-click)
|
||||
const handleQuickCreateSession = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const sessionName = generateRandomSessionName();
|
||||
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the quick create function via ref for keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (onQuickCreateRef) {
|
||||
onQuickCreateRef.current = handleQuickCreateSession;
|
||||
}
|
||||
return () => {
|
||||
if (onQuickCreateRef) {
|
||||
onQuickCreateRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onQuickCreateRef, projectPath]);
|
||||
|
||||
// Rename session
|
||||
const handleRenameSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!editingName.trim() || !api?.sessions) return;
|
||||
|
||||
const result = await api.sessions.update(sessionId, editingName, undefined);
|
||||
|
||||
if (result.success) {
|
||||
setEditingSessionId(null);
|
||||
setEditingName("");
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
// Archive session
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
console.error("[SessionManager] Sessions API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.sessions.archive(sessionId);
|
||||
if (result.success) {
|
||||
// If the archived session was currently selected, deselect it
|
||||
if (currentSessionId === sessionId) {
|
||||
onSelectSession(null);
|
||||
}
|
||||
await loadSessions();
|
||||
} else {
|
||||
console.error("[SessionManager] Archive failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SessionManager] Archive error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Unarchive session
|
||||
const handleUnarchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
console.error("[SessionManager] Sessions API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.sessions.unarchive(sessionId);
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
} else {
|
||||
console.error("[SessionManager] Unarchive failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SessionManager] Unarchive error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Open delete session dialog
|
||||
const handleDeleteSession = (session: SessionListItem) => {
|
||||
setSessionToDelete(session);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete session
|
||||
const confirmDeleteSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
const result = await api.sessions.delete(sessionId);
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
if (currentSessionId === sessionId) {
|
||||
// Switch to another session or create a new one
|
||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
||||
if (activeSessionsList.length > 0) {
|
||||
onSelectSession(activeSessionsList[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
setSessionToDelete(null);
|
||||
};
|
||||
|
||||
// Delete all archived sessions
|
||||
const handleDeleteAllArchivedSessions = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
// Delete each archived session
|
||||
for (const session of archivedSessions) {
|
||||
await api.sessions.delete(session.id);
|
||||
}
|
||||
|
||||
await loadSessions();
|
||||
setIsDeleteAllArchivedDialogOpen(false);
|
||||
};
|
||||
|
||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||
const displayedSessions =
|
||||
activeTab === "active" ? activeSessions : archivedSessions;
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col rounded-none">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<CardTitle>Agent Sessions</CardTitle>
|
||||
<HotkeyButton
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Switch to active tab if on archived tab
|
||||
if (activeTab === "archived") {
|
||||
setActiveTab("active");
|
||||
}
|
||||
handleQuickCreateSession();
|
||||
}}
|
||||
hotkey={shortcuts.newSession}
|
||||
hotkeyActive={false}
|
||||
data-testid="new-session-button"
|
||||
title={`New Session (${shortcuts.newSession})`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
New
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as "active" | "archived")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="active" className="flex-1">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Active ({activeSessions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived" className="flex-1">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archived ({archivedSessions.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className="flex-1 overflow-y-auto space-y-2"
|
||||
data-testid="session-list"
|
||||
>
|
||||
{/* Create new session */}
|
||||
{isCreating && (
|
||||
<div className="p-3 border rounded-lg bg-muted/50">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Session name..."
|
||||
value={newSessionName}
|
||||
onChange={(e) => setNewSessionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateSession();
|
||||
if (e.key === "Escape") {
|
||||
setIsCreating(false);
|
||||
setNewSessionName("");
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateSession}>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewSessionName("");
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete All Archived button - shown at the top of archived sessions */}
|
||||
{activeTab === "archived" && archivedSessions.length > 0 && (
|
||||
<div className="pb-2 border-b mb-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setIsDeleteAllArchivedDialogOpen(true)}
|
||||
data-testid="delete-all-archived-sessions-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete All Archived Sessions
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session list */}
|
||||
{displayedSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
|
||||
currentSessionId === session.id && "bg-primary/10 border-primary",
|
||||
session.isArchived && "opacity-60"
|
||||
)}
|
||||
onClick={() => !session.isArchived && onSelectSession(session.id)}
|
||||
data-testid={`session-item-${session.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingSessionId === session.id ? (
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRenameSession(session.id);
|
||||
if (e.key === "Escape") {
|
||||
setEditingSessionId(null);
|
||||
setEditingName("");
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
className="h-7"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRenameSession(session.id);
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSessionId(null);
|
||||
setEditingName("");
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||
{(currentSessionId === session.id &&
|
||||
isCurrentSessionThinking) ||
|
||||
runningSessions.has(session.id) ? (
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium truncate">{session.name}</h3>
|
||||
{((currentSessionId === session.id &&
|
||||
isCurrentSessionThinking) ||
|
||||
runningSessions.has(session.id)) && (
|
||||
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||
thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{session.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{session.preview}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.messageCount} messages
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(session.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!session.isArchived && (
|
||||
<div
|
||||
className="flex gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingSessionId(session.id);
|
||||
setEditingName(session.name);
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleArchiveSession(session.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
data-testid={`archive-session-${session.id}`}
|
||||
>
|
||||
<Archive className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.isArchived && (
|
||||
<div
|
||||
className="flex gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleUnarchiveSession(session.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ArchiveRestore className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteSession(session)}
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
data-testid={`delete-session-${session.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{displayedSessions.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{activeTab === "active"
|
||||
? "No active sessions"
|
||||
: "No archived sessions"}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{activeTab === "active"
|
||||
? "Create your first session to get started"
|
||||
: "Archive sessions to see them here"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Delete Session Confirmation Dialog */}
|
||||
<DeleteSessionDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
session={sessionToDelete}
|
||||
onConfirm={confirmDeleteSession}
|
||||
/>
|
||||
|
||||
{/* Delete All Archived Sessions Confirmation Dialog */}
|
||||
<DeleteAllArchivedSessionsDialog
|
||||
open={isDeleteAllArchivedDialogOpen}
|
||||
onOpenChange={setIsDeleteAllArchivedDialogOpen}
|
||||
archivedCount={archivedSessions.length}
|
||||
onConfirm={handleDeleteAllArchivedSessions}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
242
apps/ui/src/components/ui/accordion.tsx
Normal file
242
apps/ui/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AccordionType = "single" | "multiple";
|
||||
|
||||
interface AccordionContextValue {
|
||||
type: AccordionType;
|
||||
value: string | string[];
|
||||
onValueChange: (value: string) => void;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
type?: "single" | "multiple";
|
||||
value?: string | string[];
|
||||
defaultValue?: string | string[];
|
||||
onValueChange?: (value: string | string[]) => void;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
(
|
||||
{
|
||||
type = "single",
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
collapsible = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [internalValue, setInternalValue] = React.useState<string | string[]>(
|
||||
() => {
|
||||
if (value !== undefined) return value;
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
return type === "single" ? "" : [];
|
||||
}
|
||||
);
|
||||
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(itemValue: string) => {
|
||||
let newValue: string | string[];
|
||||
|
||||
if (type === "single") {
|
||||
if (currentValue === itemValue && collapsible) {
|
||||
newValue = "";
|
||||
} else if (currentValue === itemValue && !collapsible) {
|
||||
return;
|
||||
} else {
|
||||
newValue = itemValue;
|
||||
}
|
||||
} else {
|
||||
const currentArray = Array.isArray(currentValue)
|
||||
? currentValue
|
||||
: [currentValue].filter(Boolean);
|
||||
if (currentArray.includes(itemValue)) {
|
||||
newValue = currentArray.filter((v) => v !== itemValue);
|
||||
} else {
|
||||
newValue = [...currentArray, itemValue];
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
},
|
||||
[type, currentValue, collapsible, value, onValueChange]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
type,
|
||||
value: currentValue,
|
||||
onValueChange: handleValueChange,
|
||||
collapsible,
|
||||
}),
|
||||
[type, currentValue, handleValueChange, collapsible]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="accordion"
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Accordion.displayName = "Accordion";
|
||||
|
||||
interface AccordionItemContextValue {
|
||||
value: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const AccordionItemContext =
|
||||
React.createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
({ className, value, children, ...props }, ref) => {
|
||||
const accordionContext = React.useContext(AccordionContext);
|
||||
|
||||
if (!accordionContext) {
|
||||
throw new Error("AccordionItem must be used within an Accordion");
|
||||
}
|
||||
|
||||
const isOpen = Array.isArray(accordionContext.value)
|
||||
? accordionContext.value.includes(value)
|
||||
: accordionContext.value === value;
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ value, isOpen }),
|
||||
[value, isOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="accordion-item"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
className={cn("border-b border-border", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
interface AccordionTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
AccordionTriggerProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const accordionContext = React.useContext(AccordionContext);
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
|
||||
if (!accordionContext || !itemContext) {
|
||||
throw new Error("AccordionTrigger must be used within an AccordionItem");
|
||||
}
|
||||
|
||||
const { onValueChange } = accordionContext;
|
||||
const { value, isOpen } = itemContext;
|
||||
|
||||
return (
|
||||
<div data-slot="accordion-header" className="flex">
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot="accordion-trigger"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionTrigger.displayName = "AccordionTrigger";
|
||||
|
||||
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = React.useState<number | undefined>(undefined);
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("AccordionContent must be used within an AccordionItem");
|
||||
}
|
||||
|
||||
const { isOpen } = itemContext;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(contentRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="accordion-content"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
className="overflow-hidden text-sm transition-all duration-200 ease-out"
|
||||
style={{
|
||||
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div ref={ref} className={cn("pb-4 pt-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionContent.displayName = "AccordionContent";
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
225
apps/ui/src/components/ui/autocomplete.tsx
Normal file
225
apps/ui/src/components/ui/autocomplete.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
|
||||
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;
|
||||
error?: 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,
|
||||
error = 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",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
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>
|
||||
);
|
||||
}
|
||||
57
apps/ui/src/components/ui/badge.tsx
Normal file
57
apps/ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white hover:bg-destructive/90",
|
||||
outline:
|
||||
"text-foreground border-border bg-background/50 backdrop-blur-sm",
|
||||
// Semantic status variants using CSS variables
|
||||
success:
|
||||
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
|
||||
warning:
|
||||
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
|
||||
error:
|
||||
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
|
||||
info:
|
||||
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
|
||||
// Muted variants for subtle indication
|
||||
muted:
|
||||
"border-border/50 bg-muted/50 text-muted-foreground",
|
||||
brand:
|
||||
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
|
||||
},
|
||||
size: {
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
sm: "px-2 py-0.5 text-[10px]",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
67
apps/ui/src/components/ui/branch-autocomplete.tsx
Normal file
67
apps/ui/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
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[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function BranchAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
branches,
|
||||
branchCardCounts,
|
||||
placeholder = "Select a branch...",
|
||||
className,
|
||||
disabled = false,
|
||||
error = 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) => {
|
||||
const cardCount = branchCardCounts?.[branch];
|
||||
// Show card count if available, otherwise show "default" for main branch only
|
||||
const badge = branchCardCounts !== undefined
|
||||
? String(cardCount ?? 0)
|
||||
: branch === "main"
|
||||
? "default"
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
value: branch,
|
||||
label: branch,
|
||||
badge,
|
||||
};
|
||||
});
|
||||
}, [branches, branchCardCounts]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={branchOptions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search or type new branch..."
|
||||
emptyMessage="No branches found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
icon={GitBranch}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="branch-option"
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
apps/ui/src/components/ui/button.tsx
Normal file
116
apps/ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline active:scale-100",
|
||||
"animated-outline":
|
||||
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Special handling for animated-outline variant
|
||||
if (variant === "animated-outline" && !asChild) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
"p-[1px]", // Force 1px padding for the gradient border
|
||||
className
|
||||
)}
|
||||
data-slot="button"
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{/* Animated rotating gradient border - smoother animation */}
|
||||
<span className="absolute inset-[-1000%] animate-[spin_3s_linear_infinite] animated-outline-gradient opacity-75 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* Inner content container */}
|
||||
<span
|
||||
className={cn(
|
||||
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200",
|
||||
size === "sm" && "px-3 text-xs gap-1.5",
|
||||
size === "lg" && "px-8",
|
||||
size === "icon" && "p-0 gap-0"
|
||||
)}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
100
apps/ui/src/components/ui/card.tsx
Normal file
100
apps/ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardProps extends React.ComponentProps<"div"> {
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
function Card({ className, gradient = false, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-md py-6",
|
||||
// Premium layered shadow
|
||||
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
|
||||
// Gradient border option
|
||||
gradient && "relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center gap-3 px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
38
apps/ui/src/components/ui/category-autocomplete.tsx
Normal file
38
apps/ui/src/components/ui/category-autocomplete.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CategoryAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder = "Select or type a category...",
|
||||
className,
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
apps/ui/src/components/ui/checkbox.tsx
Normal file
55
apps/ui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
|
||||
checked?: boolean | "indeterminate";
|
||||
defaultChecked?: boolean | "indeterminate";
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const CheckboxRoot = CheckboxPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Indicator> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, children: _children, ...props }, ref) => (
|
||||
<CheckboxRoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||
className
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
// Handle indeterminate state by treating it as false for consumers expecting boolean
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(checked === true);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxIndicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
)
|
||||
);
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
183
apps/ui/src/components/ui/command.tsx
Normal file
183
apps/ui/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
62
apps/ui/src/components/ui/count-up-timer.tsx
Normal file
62
apps/ui/src/components/ui/count-up-timer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
interface CountUpTimerProps {
|
||||
startedAt: string; // ISO timestamp string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats elapsed time in MM:SS format
|
||||
* @param seconds - Total elapsed seconds
|
||||
* @returns Formatted string like "00:00", "01:30", "59:59", etc.
|
||||
*/
|
||||
function formatElapsedTime(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
|
||||
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CountUpTimer component that displays elapsed time since a given start time
|
||||
* Updates every second to show the current elapsed time in MM:SS format
|
||||
*/
|
||||
export function CountUpTimer({ startedAt, className = "" }: CountUpTimerProps) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Calculate initial elapsed time
|
||||
const startTime = new Date(startedAt).getTime();
|
||||
|
||||
const calculateElapsed = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - startTime) / 1000);
|
||||
return Math.max(0, elapsed); // Ensure non-negative
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
setElapsedSeconds(calculateElapsed());
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(() => {
|
||||
setElapsedSeconds(calculateElapsed());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startedAt]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs text-muted-foreground ${className}`}
|
||||
data-testid="count-up-timer"
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span data-testid="timer-display">{formatElapsedTime(elapsedSeconds)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/ui/src/components/ui/delete-confirm-dialog.tsx
Normal file
88
apps/ui/src/components/ui/delete-confirm-dialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional content to show between description and buttons (e.g., item preview card) */
|
||||
children?: ReactNode;
|
||||
/** Text for the confirm button. Defaults to "Delete" */
|
||||
confirmText?: string;
|
||||
/** Test ID for the dialog */
|
||||
testId?: string;
|
||||
/** Test ID for the confirm button */
|
||||
confirmTestId?: string;
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
confirmText = "Delete",
|
||||
testId = "delete-confirm-dialog",
|
||||
confirmTestId = "confirm-delete-button",
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-md"
|
||||
data-testid={testId}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-destructive" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{children}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-4"
|
||||
data-testid="cancel-delete-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
data-testid={confirmTestId}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
className="px-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{confirmText}
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
468
apps/ui/src/components/ui/description-image-dropzone.tsx
Normal file
468
apps/ui/src/components/ui/description-image-dropzone.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
export interface FeatureImagePath {
|
||||
id: string;
|
||||
path: string; // Path to the temp file
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||
export type ImagePreviewMap = Map<string, string>;
|
||||
|
||||
interface DescriptionImageDropZoneProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
images: FeatureImagePath[];
|
||||
onImagesChange: (images: FeatureImagePath[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // in bytes, default 10MB
|
||||
// Optional: pass preview map from parent to persist across tab switches
|
||||
previewMap?: ImagePreviewMap;
|
||||
onPreviewMapChange?: (map: ImagePreviewMap) => void;
|
||||
autoFocus?: boolean;
|
||||
error?: boolean; // Show error state with red border
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function DescriptionImageDropZone({
|
||||
value,
|
||||
onChange,
|
||||
images,
|
||||
onImagesChange,
|
||||
placeholder = "Describe the feature...",
|
||||
className,
|
||||
disabled = false,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
previewMap,
|
||||
onPreviewMapChange,
|
||||
autoFocus = false,
|
||||
error = false,
|
||||
}: DescriptionImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
// Use parent-provided preview map if available, otherwise use local state
|
||||
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
// Determine which preview map to use - prefer parent-controlled state
|
||||
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
||||
if (onPreviewMapChange) {
|
||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
||||
onPreviewMapChange(newMap);
|
||||
} else {
|
||||
setLocalPreviewImages((prev) => {
|
||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [onPreviewMapChange, previewMap, localPreviewImages]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
||||
const projectPath = currentProject?.path || "";
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const saveImageToTemp = useCallback(async (
|
||||
base64Data: string,
|
||||
filename: string,
|
||||
mimeType: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
// Check if saveImageToTemp method exists
|
||||
if (!api.saveImageToTemp) {
|
||||
// Fallback path when saveImageToTemp is not available
|
||||
console.log("[DescriptionImageDropZone] Using fallback path for image");
|
||||
return `.automaker/images/${Date.now()}_${filename}`;
|
||||
}
|
||||
|
||||
// Get projectPath from the store if available
|
||||
const projectPath = currentProject?.path;
|
||||
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
||||
if (result.success && result.path) {
|
||||
return result.path;
|
||||
}
|
||||
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[DescriptionImageDropZone] Error saving image:", error);
|
||||
return null;
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: FeatureImagePath[] = [];
|
||||
const newPreviews = new Map(previewImages);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + images.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tempPath = await saveImageToTemp(base64, file.name, file.type);
|
||||
|
||||
if (tempPath) {
|
||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const imagePathRef: FeatureImagePath = {
|
||||
id: imageId,
|
||||
path: tempPath,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
};
|
||||
newImages.push(imagePathRef);
|
||||
// Store preview for display
|
||||
newPreviews.set(imageId, base64);
|
||||
} else {
|
||||
errors.push(`${file.name}: Failed to save image.`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
onImagesChange([...images, ...newImages]);
|
||||
setPreviewImages(newPreviews);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
},
|
||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
},
|
||||
[disabled, processFiles]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFiles]
|
||||
);
|
||||
|
||||
const handleBrowseClick = useCallback(() => {
|
||||
if (!disabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const removeImage = useCallback(
|
||||
(imageId: string) => {
|
||||
onImagesChange(images.filter((img) => img.id !== imageId));
|
||||
setPreviewImages((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(imageId);
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[images, onImagesChange]
|
||||
);
|
||||
|
||||
// Handle paste events to detect and process images from clipboard
|
||||
// Works across all OS (Windows, Linux, macOS)
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
if (!clipboardItems) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
// Iterate through clipboard items to find images
|
||||
for (let i = 0; i < clipboardItems.length; i++) {
|
||||
const item = clipboardItems[i];
|
||||
|
||||
// Check if the item is an image
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
// Generate a filename for pasted images since they don't have one
|
||||
const extension = item.type.split("/")[1] || "png";
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const renamedFile = new File(
|
||||
[file],
|
||||
`pasted-image-${timestamp}.${extension}`,
|
||||
{ type: file.type }
|
||||
);
|
||||
imageFiles.push(renamedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found images, process them and prevent default paste behavior
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
// Create a FileList-like object from the array
|
||||
const dataTransfer = new DataTransfer();
|
||||
imageFiles.forEach((file) => dataTransfer.items.add(file));
|
||||
processFiles(dataTransfer.files);
|
||||
}
|
||||
// If no images found, let the default paste behavior happen (paste text)
|
||||
},
|
||||
[disabled, isProcessing, processFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
data-testid="description-image-input"
|
||||
/>
|
||||
|
||||
{/* Drop zone wrapper */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"relative rounded-md transition-all duration-200",
|
||||
{
|
||||
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
|
||||
isDragOver && !disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && !disabled && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-blue-500/20 border-2 border-dashed border-blue-400 pointer-events-none"
|
||||
data-testid="drop-overlay"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-blue-400">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-sm font-medium">Drop images here</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={error}
|
||||
className={cn(
|
||||
"min-h-[120px]",
|
||||
isProcessing && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
data-testid="feature-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Paste, drag and drop images, or{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseClick}
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
browse
|
||||
</button>{" "}
|
||||
to attach context images
|
||||
</p>
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Saving images...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{images.length} image{images.length > 1 ? "s" : ""} attached
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onImagesChange([]);
|
||||
setPreviewImages(new Map());
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
|
||||
data-testid={`description-image-preview-${image.id}`}
|
||||
>
|
||||
{/* Image thumbnail or placeholder */}
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
|
||||
{previewImages.has(image.id) ? (
|
||||
<img
|
||||
src={previewImages.get(image.id)}
|
||||
alt={image.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={getImageServerUrl(image.path)}
|
||||
alt={image.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onError={(e) => {
|
||||
// If image fails to load, hide it
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-testid={`remove-description-image-${image.id}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{/* Filename tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
apps/ui/src/components/ui/dialog.tsx
Normal file
232
apps/ui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLHeadingElement>
|
||||
>;
|
||||
|
||||
const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
} & React.RefAttributes<HTMLParagraphElement>
|
||||
>;
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogOverlayPrimitive
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
"ref"
|
||||
> & {
|
||||
showCloseButton?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DialogContentProps
|
||||
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
||||
// Check if className contains a custom max-width
|
||||
const hasCustomMaxWidth =
|
||||
typeof className === "string" && className.includes("max-w-");
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogContentPrimitive
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
|
||||
"bg-card border border-border rounded-xl shadow-2xl",
|
||||
// Premium shadow
|
||||
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
|
||||
// Animations - smoother with scale
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200",
|
||||
compact
|
||||
? "max-w-4xl p-4"
|
||||
: !hasCustomMaxWidth
|
||||
? "sm:max-w-2xl p-6"
|
||||
: "p-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogClosePrimitive
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
|
||||
"hover:opacity-100 hover:bg-muted",
|
||||
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
|
||||
"p-1.5",
|
||||
compact ? "top-2 right-3" : "top-4 right-4"
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClosePrimitive>
|
||||
)}
|
||||
</DialogContentPrimitive>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogTitlePrimitive
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogTitlePrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogDescriptionPrimitive
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogDescriptionPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
302
apps/ui/src/components/ui/dropdown-menu.tsx
Normal file
302
apps/ui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
|
||||
{children}
|
||||
</DropdownMenuTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
|
||||
return (
|
||||
<DropdownMenuRadioGroupPrimitive {...props}>
|
||||
{children}
|
||||
</DropdownMenuRadioGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuSubTriggerPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuSubTriggerPrimitive>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItemPrimitive>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuCheckboxItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicatorPrimitive>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItemPrimitive>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuRadioItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicatorPrimitive>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuRadioItemPrimitive>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuLabelPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuLabelPrimitive>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuSeparatorPrimitive
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
309
apps/ui/src/components/ui/feature-image-upload.tsx
Normal file
309
apps/ui/src/components/ui/feature-image-upload.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Upload } from "lucide-react";
|
||||
|
||||
export interface FeatureImage {
|
||||
id: string;
|
||||
data: string; // base64 encoded
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface FeatureImageUploadProps {
|
||||
images: FeatureImage[];
|
||||
onImagesChange: (images: FeatureImage[]) => void;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // in bytes, default 10MB
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function FeatureImageUpload({
|
||||
images,
|
||||
onImagesChange,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
disabled = false,
|
||||
}: FeatureImageUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: FeatureImage[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + images.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: FeatureImage = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
onImagesChange([...images, ...newImages]);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
},
|
||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
},
|
||||
[disabled, processFiles]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFiles]
|
||||
);
|
||||
|
||||
const handleBrowseClick = useCallback(() => {
|
||||
if (!disabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const removeImage = useCallback(
|
||||
(imageId: string) => {
|
||||
onImagesChange(images.filter((img) => img.id !== imageId));
|
||||
},
|
||||
[images, onImagesChange]
|
||||
);
|
||||
|
||||
const clearAllImages = useCallback(() => {
|
||||
onImagesChange([]);
|
||||
}, [onImagesChange]);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
data-testid="feature-image-input"
|
||||
/>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleBrowseClick}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
|
||||
{
|
||||
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
|
||||
isDragOver && !disabled,
|
||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
|
||||
!disabled && !isDragOver,
|
||||
}
|
||||
)}
|
||||
data-testid="feature-image-dropzone"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-2 mb-2",
|
||||
isDragOver && !disabled
|
||||
? "bg-blue-100 dark:bg-blue-900/30"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragOver && !disabled
|
||||
? "Drop images here"
|
||||
: "Click or drag images here"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Up to {maxFiles} images, max{" "}
|
||||
{Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{images.length} image{images.length > 1 ? "s" : ""} selected
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllImages}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
|
||||
data-testid={`feature-image-preview-${image.id}`}
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-16 h-16 flex items-center justify-center">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-testid={`remove-image-${image.id}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{/* Filename tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
664
apps/ui/src/components/ui/git-diff-panel.tsx
Normal file
664
apps/ui/src/components/ui/git-diff-panel.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import type { FileStatus } from "@/types/electron";
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
className?: string;
|
||||
/** Whether to show the panel in a compact/minimized state initially */
|
||||
compact?: boolean;
|
||||
/** Whether worktrees are enabled - if false, shows diffs from main project */
|
||||
useWorktrees?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return <FilePlus className="w-4 h-4 text-green-500" />;
|
||||
case "D":
|
||||
return <FileX className="w-4 h-4 text-red-500" />;
|
||||
case "M":
|
||||
case "U":
|
||||
return <FilePen className="w-4 h-4 text-amber-500" />;
|
||||
case "R":
|
||||
case "C":
|
||||
return <File className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "D":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "M":
|
||||
case "U":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "R":
|
||||
case "C":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDisplayName = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
return "Added";
|
||||
case "?":
|
||||
return "Untracked";
|
||||
case "D":
|
||||
return "Deleted";
|
||||
case "M":
|
||||
return "Modified";
|
||||
case "U":
|
||||
return "Updated";
|
||||
case "R":
|
||||
return "Renamed";
|
||||
case "C":
|
||||
return "Copied";
|
||||
default:
|
||||
return "Changed";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split("\n");
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// New file diff
|
||||
if (line.startsWith("diff --git")) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
// Extract file path from diff header
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : "unknown",
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file indicator
|
||||
if (line.startsWith("new file mode")) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file indicator
|
||||
if (line.startsWith("deleted file mode")) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Renamed file indicator
|
||||
if (line.startsWith("rename from") || line.startsWith("rename to")) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip index, ---/+++ lines
|
||||
if (
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith("@@")) {
|
||||
if (currentHunk && currentFile) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
// Parse line numbers from @@ -old,count +new,count @@
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: "header", content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diff content lines
|
||||
if (currentHunk) {
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({
|
||||
type: "addition",
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({
|
||||
type: "deletion",
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
currentHunk.lines.push({
|
||||
type: "context",
|
||||
content: line.substring(1) || "",
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last file and hunk
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: "bg-transparent",
|
||||
addition: "bg-green-500/10",
|
||||
deletion: "bg-red-500/10",
|
||||
header: "bg-blue-500/10",
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: "text-foreground-secondary",
|
||||
addition: "text-green-400",
|
||||
deletion: "text-red-400",
|
||||
header: "text-blue-400",
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: " ",
|
||||
addition: "+",
|
||||
deletion: "-",
|
||||
header: "",
|
||||
};
|
||||
|
||||
if (type === "header") {
|
||||
return (
|
||||
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex font-mono text-xs", bgClass[type])}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.old ?? ""}
|
||||
</span>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.new ?? ""}
|
||||
</span>
|
||||
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
|
||||
{content || "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
fileDiff: ParsedFileDiff;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
);
|
||||
const deletions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
||||
{fileDiff.filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{fileDiff.isNew && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isDeleted && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
deleted
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isRenamed && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
|
||||
renamed
|
||||
</span>
|
||||
)}
|
||||
{additions > 0 && (
|
||||
<span className="text-xs text-green-400">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-xs text-red-400">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitDiffPanel({
|
||||
projectPath,
|
||||
featureId,
|
||||
className,
|
||||
compact = true,
|
||||
useWorktrees = false,
|
||||
}: GitDiffPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState<string>("");
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadDiffs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Use worktree API if worktrees are enabled, otherwise use git API for main project
|
||||
if (useWorktrees) {
|
||||
if (!api?.worktree?.getDiffs) {
|
||||
throw new Error("Worktree API not available");
|
||||
}
|
||||
const result = await api.worktree.getDiffs(projectPath, featureId);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
} else {
|
||||
// Use git API for main project diffs
|
||||
if (!api?.git?.getDiffs) {
|
||||
throw new Error("Git API not available");
|
||||
}
|
||||
const result = await api.git.getDiffs(projectPath);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load diffs");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, featureId, useWorktrees]);
|
||||
|
||||
// Load diffs when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
loadDiffs();
|
||||
}
|
||||
}, [isExpanded, loadDiffs]);
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAllFiles = () => {
|
||||
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
|
||||
};
|
||||
|
||||
const collapseAllFiles = () => {
|
||||
setExpandedFiles(new Set());
|
||||
};
|
||||
|
||||
// Total stats
|
||||
const totalAdditions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
const totalDeletions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
data-testid="git-diff-panel"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left flex-shrink-0"
|
||||
data-testid="git-diff-panel-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
<span className="font-medium text-sm text-foreground">Git Changes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{!isExpanded && files.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">+{totalAdditions}</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<span className="text-sm">No changes detected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Summary bar */}
|
||||
<div className="p-4 pb-2 border-b border-border-glass">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{(() => {
|
||||
// Group files by status
|
||||
const statusGroups = files.reduce((acc, file) => {
|
||||
const status = file.status;
|
||||
if (!acc[status]) {
|
||||
acc[status] = {
|
||||
count: 0,
|
||||
statusText: getStatusDisplayName(status),
|
||||
files: []
|
||||
};
|
||||
}
|
||||
acc[status].count += 1;
|
||||
acc[status].files.push(file.path);
|
||||
return acc;
|
||||
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
|
||||
|
||||
return Object.entries(statusGroups).map(([status, group]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center gap-1.5"
|
||||
title={group.files.join('\n')}
|
||||
data-testid={`git-status-group-${status.toLowerCase()}`}
|
||||
>
|
||||
{getFileIcon(status)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||
getStatusBadgeColor(status)
|
||||
)}
|
||||
>
|
||||
{group.count} {group.statusText}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={expandAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={collapseAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"} changed
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">
|
||||
+{totalAdditions} additions
|
||||
</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">
|
||||
-{totalDeletions} deletions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File diffs */}
|
||||
<div className="p-4 space-y-3">
|
||||
{parsedDiffs.map((fileDiff) => (
|
||||
<FileDiffSection
|
||||
key={fileDiff.filePath}
|
||||
fileDiff={fileDiff}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
/>
|
||||
))}
|
||||
{/* Fallback for files that have no diff content (shouldn't happen after fix, but safety net) */}
|
||||
{files.length > 0 && parsedDiffs.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
||||
{getFileIcon(file.status)}
|
||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
||||
{file.path}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||
getStatusBadgeColor(file.status)
|
||||
)}
|
||||
>
|
||||
{getStatusDisplayName(file.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
||||
{file.status === "?" ? (
|
||||
<span>New file - content preview not available</span>
|
||||
) : file.status === "D" ? (
|
||||
<span>File deleted</span>
|
||||
) : (
|
||||
<span>Diff content not available</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
apps/ui/src/components/ui/hotkey-button.tsx
Normal file
304
apps/ui/src/components/ui/hotkey-button.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
export interface HotkeyConfig {
|
||||
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
|
||||
key: string;
|
||||
/** Whether the Cmd/Ctrl modifier is required */
|
||||
cmdCtrl?: boolean;
|
||||
/** Whether the Shift modifier is required */
|
||||
shift?: boolean;
|
||||
/** Whether the Alt/Option modifier is required */
|
||||
alt?: boolean;
|
||||
/** Custom display label for the hotkey (overrides auto-generated label) */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface HotkeyButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
/** Hotkey configuration - can be a simple key string or a full config object */
|
||||
hotkey?: string | HotkeyConfig;
|
||||
/** Whether to show the hotkey indicator badge */
|
||||
showHotkeyIndicator?: boolean;
|
||||
/** Whether the hotkey listener is active (registers keyboard listener). Set to false if hotkey is already handled elsewhere. */
|
||||
hotkeyActive?: boolean;
|
||||
/** Optional scope element ref - hotkey will only work when this element is visible */
|
||||
scopeRef?: React.RefObject<HTMLElement | null>;
|
||||
/** Callback when hotkey is triggered */
|
||||
onHotkeyTrigger?: () => void;
|
||||
/** Whether to use the Slot component for composition */
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modifier key symbol based on platform
|
||||
*/
|
||||
function getModifierSymbol(isMac: boolean): string {
|
||||
return isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hotkey config into a normalized format
|
||||
*/
|
||||
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
|
||||
if (typeof hotkey === "string") {
|
||||
return { key: hotkey };
|
||||
}
|
||||
return hotkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the display label for the hotkey
|
||||
*/
|
||||
function getHotkeyDisplayLabel(
|
||||
config: HotkeyConfig,
|
||||
isMac: boolean
|
||||
): React.ReactNode {
|
||||
if (config.label) {
|
||||
return config.label;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
|
||||
if (config.cmdCtrl) {
|
||||
parts.push(
|
||||
<span key="mod" className="leading-none flex items-center justify-center">
|
||||
{getModifierSymbol(isMac)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.shift) {
|
||||
parts.push(
|
||||
<span
|
||||
key="shift"
|
||||
className="leading-none flex items-center justify-center"
|
||||
>
|
||||
⇧
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.alt) {
|
||||
parts.push(
|
||||
<span key="alt" className="leading-none flex items-center justify-center">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert key to display format
|
||||
let keyDisplay = config.key;
|
||||
switch (config.key.toLowerCase()) {
|
||||
case "enter":
|
||||
keyDisplay = "↵";
|
||||
break;
|
||||
case "escape":
|
||||
case "esc":
|
||||
keyDisplay = "Esc";
|
||||
break;
|
||||
case "arrowup":
|
||||
keyDisplay = "↑";
|
||||
break;
|
||||
case "arrowdown":
|
||||
keyDisplay = "↓";
|
||||
break;
|
||||
case "arrowleft":
|
||||
keyDisplay = "←";
|
||||
break;
|
||||
case "arrowright":
|
||||
keyDisplay = "→";
|
||||
break;
|
||||
case "backspace":
|
||||
keyDisplay = "⌫";
|
||||
break;
|
||||
case "delete":
|
||||
keyDisplay = "⌦";
|
||||
break;
|
||||
case "tab":
|
||||
keyDisplay = "⇥";
|
||||
break;
|
||||
case " ":
|
||||
keyDisplay = "Space";
|
||||
break;
|
||||
default:
|
||||
// Capitalize single letters
|
||||
if (config.key.length === 1) {
|
||||
keyDisplay = config.key.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<span key="key" className="leading-none flex items-center justify-center">
|
||||
{keyDisplay}
|
||||
</span>
|
||||
);
|
||||
|
||||
return <span className="inline-flex items-center gap-1.5">{parts}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is a form input
|
||||
*/
|
||||
function isInputElement(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const role = element.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button component that supports keyboard hotkeys
|
||||
*
|
||||
* Features:
|
||||
* - Automatic hotkey listening when mounted
|
||||
* - Visual hotkey indicator badge
|
||||
* - Support for modifier keys (Cmd/Ctrl, Shift, Alt)
|
||||
* - Respects focus context (doesn't trigger when typing in inputs)
|
||||
* - Scoped activation via scopeRef
|
||||
*/
|
||||
export function HotkeyButton({
|
||||
hotkey,
|
||||
showHotkeyIndicator = true,
|
||||
hotkeyActive = true,
|
||||
scopeRef,
|
||||
onHotkeyTrigger,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: HotkeyButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isMac, setIsMac] = React.useState(true);
|
||||
|
||||
// Detect platform on mount
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toLowerCase().includes("mac"));
|
||||
}, []);
|
||||
|
||||
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!config || !hotkeyActive || disabled) return;
|
||||
|
||||
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
|
||||
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
|
||||
if (
|
||||
!scopeRef &&
|
||||
!config.cmdCtrl &&
|
||||
isInputElement(document.activeElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check modifier keys
|
||||
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||
const shiftPressed = event.shiftKey;
|
||||
const altPressed = event.altKey;
|
||||
|
||||
// Validate modifier requirements
|
||||
if (config.cmdCtrl && !cmdCtrlPressed) return;
|
||||
if (!config.cmdCtrl && cmdCtrlPressed) return;
|
||||
if (config.shift && !shiftPressed) return;
|
||||
if (!config.shift && shiftPressed) return;
|
||||
if (config.alt && !altPressed) return;
|
||||
if (!config.alt && altPressed) return;
|
||||
|
||||
// Check if the key matches
|
||||
if (event.key.toLowerCase() !== config.key.toLowerCase()) return;
|
||||
|
||||
// If scoped, check that the scope element is visible
|
||||
if (scopeRef && scopeRef.current) {
|
||||
const scopeEl = scopeRef.current;
|
||||
const isVisible =
|
||||
scopeEl.offsetParent !== null ||
|
||||
getComputedStyle(scopeEl).display !== "none";
|
||||
if (!isVisible) return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Trigger the click handler or custom onHotkeyTrigger
|
||||
if (onHotkeyTrigger) {
|
||||
onHotkeyTrigger();
|
||||
} else if (onClick) {
|
||||
onClick(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||
} else if (buttonRef.current) {
|
||||
buttonRef.current.click();
|
||||
}
|
||||
},
|
||||
[config, hotkeyActive, disabled, scopeRef, onHotkeyTrigger, onClick]
|
||||
);
|
||||
|
||||
// Set up global key listener
|
||||
useEffect(() => {
|
||||
if (!config || !hotkeyActive) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [config, hotkeyActive, handleKeyDown]);
|
||||
|
||||
// Render the hotkey indicator
|
||||
const hotkeyIndicator =
|
||||
config && showHotkeyIndicator ? (
|
||||
<span
|
||||
className="px-2 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20 inline-flex items-center gap-1.5"
|
||||
data-testid="hotkey-indicator"
|
||||
>
|
||||
{getHotkeyDisplayLabel(config, isMac)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(className)}
|
||||
asChild={asChild}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { getHotkeyDisplayLabel, parseHotkeyConfig };
|
||||
291
apps/ui/src/components/ui/image-drop-zone.tsx
Normal file
291
apps/ui/src/components/ui/image-drop-zone.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Upload } from "lucide-react";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
|
||||
interface ImageDropZoneProps {
|
||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // in bytes, default 10MB
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
images?: ImageAttachment[]; // Optional controlled images prop
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function ImageDropZone({
|
||||
onImagesSelected,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
images,
|
||||
}: ImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled images if provided, otherwise use internal state
|
||||
const selectedImages = images ?? internalImages;
|
||||
|
||||
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
||||
const updateImages = useCallback((newImages: ImageAttachment[]) => {
|
||||
if (images === undefined) {
|
||||
setInternalImages(newImages);
|
||||
}
|
||||
onImagesSelected(newImages);
|
||||
}, [images, onImagesSelected]);
|
||||
|
||||
const processFiles = useCallback(async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + selectedImages.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
// You could show these errors to the user via a toast or notification
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
const allImages = [...selectedImages, ...newImages];
|
||||
updateImages(allImages);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
}, [disabled, processFiles]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [processFiles]);
|
||||
|
||||
const handleBrowseClick = useCallback(() => {
|
||||
if (!disabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
const updated = selectedImages.filter(img => img.id !== imageId);
|
||||
updateImages(updated);
|
||||
}, [selectedImages, updateImages]);
|
||||
|
||||
const clearAllImages = useCallback(() => {
|
||||
updateImages([]);
|
||||
}, [updateImages]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
||||
{
|
||||
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
|
||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children || (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className={cn(
|
||||
"rounded-full p-3 mb-4",
|
||||
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
|
||||
)}>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||
</p>
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={handleBrowseClick}
|
||||
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Browse files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image previews */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image{selectedImages.length > 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<button
|
||||
onClick={clearAllImages}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && image.id && (
|
||||
<button
|
||||
onClick={() => removeImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
65
apps/ui/src/components/ui/input.tsx
Normal file
65
apps/ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
startAddon?: React.ReactNode;
|
||||
endAddon?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Input({ className, type, startAddon, endAddon, ...props }: InputProps) {
|
||||
const hasAddons = startAddon || endAddon;
|
||||
|
||||
const inputElement = (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
// Inner shadow for depth
|
||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||
// Animated focus ring
|
||||
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
// Adjust padding for addons
|
||||
startAddon && "pl-0",
|
||||
endAddon && "pr-0",
|
||||
hasAddons && "border-0 shadow-none focus-visible:ring-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!hasAddons) {
|
||||
return inputElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
|
||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||
"transition-[box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
"has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
|
||||
"has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
|
||||
)}
|
||||
>
|
||||
{startAddon && (
|
||||
<span className="flex items-center justify-center px-3 text-muted-foreground text-sm">
|
||||
{startAddon}
|
||||
</span>
|
||||
)}
|
||||
{inputElement}
|
||||
{endAddon && (
|
||||
<span className="flex items-center justify-center px-3 text-muted-foreground text-sm">
|
||||
{endAddon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
659
apps/ui/src/components/ui/keyboard-map.tsx
Normal file
659
apps/ui/src/components/ui/keyboard-map.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// Detect if running on Mac
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// Keyboard layout - US QWERTY
|
||||
const KEYBOARD_ROWS = [
|
||||
// Number row
|
||||
[
|
||||
{ key: "`", label: "`", width: 1 },
|
||||
{ key: "1", label: "1", width: 1 },
|
||||
{ key: "2", label: "2", width: 1 },
|
||||
{ key: "3", label: "3", width: 1 },
|
||||
{ key: "4", label: "4", width: 1 },
|
||||
{ key: "5", label: "5", width: 1 },
|
||||
{ key: "6", label: "6", width: 1 },
|
||||
{ key: "7", label: "7", width: 1 },
|
||||
{ key: "8", label: "8", width: 1 },
|
||||
{ key: "9", label: "9", width: 1 },
|
||||
{ key: "0", label: "0", width: 1 },
|
||||
{ key: "-", label: "-", width: 1 },
|
||||
{ key: "=", label: "=", width: 1 },
|
||||
],
|
||||
// Top letter row
|
||||
[
|
||||
{ key: "Q", label: "Q", width: 1 },
|
||||
{ key: "W", label: "W", width: 1 },
|
||||
{ key: "E", label: "E", width: 1 },
|
||||
{ key: "R", label: "R", width: 1 },
|
||||
{ key: "T", label: "T", width: 1 },
|
||||
{ key: "Y", label: "Y", width: 1 },
|
||||
{ key: "U", label: "U", width: 1 },
|
||||
{ key: "I", label: "I", width: 1 },
|
||||
{ key: "O", label: "O", width: 1 },
|
||||
{ key: "P", label: "P", width: 1 },
|
||||
{ key: "[", label: "[", width: 1 },
|
||||
{ key: "]", label: "]", width: 1 },
|
||||
{ key: "\\", label: "\\", width: 1 },
|
||||
],
|
||||
// Home row
|
||||
[
|
||||
{ key: "A", label: "A", width: 1 },
|
||||
{ key: "S", label: "S", width: 1 },
|
||||
{ key: "D", label: "D", width: 1 },
|
||||
{ key: "F", label: "F", width: 1 },
|
||||
{ key: "G", label: "G", width: 1 },
|
||||
{ key: "H", label: "H", width: 1 },
|
||||
{ key: "J", label: "J", width: 1 },
|
||||
{ key: "K", label: "K", width: 1 },
|
||||
{ key: "L", label: "L", width: 1 },
|
||||
{ key: ";", label: ";", width: 1 },
|
||||
{ key: "'", label: "'", width: 1 },
|
||||
],
|
||||
// Bottom letter row
|
||||
[
|
||||
{ key: "Z", label: "Z", width: 1 },
|
||||
{ key: "X", label: "X", width: 1 },
|
||||
{ key: "C", label: "C", width: 1 },
|
||||
{ key: "V", label: "V", width: 1 },
|
||||
{ key: "B", label: "B", width: 1 },
|
||||
{ key: "N", label: "N", width: 1 },
|
||||
{ key: "M", label: "M", width: 1 },
|
||||
{ key: ",", label: ",", width: 1 },
|
||||
{ key: ".", label: ".", width: 1 },
|
||||
{ key: "/", label: "/", width: 1 },
|
||||
],
|
||||
];
|
||||
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: "Kanban Board",
|
||||
agent: "Agent Runner",
|
||||
spec: "Spec Editor",
|
||||
context: "Context",
|
||||
settings: "Settings",
|
||||
profiles: "AI Profiles",
|
||||
terminal: "Terminal",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
addFeature: "Add Feature",
|
||||
addContextFile: "Add Context File",
|
||||
startNext: "Start Next",
|
||||
newSession: "New Session",
|
||||
openProject: "Open Project",
|
||||
projectPicker: "Project Picker",
|
||||
cyclePrevProject: "Prev Project",
|
||||
cycleNextProject: "Next Project",
|
||||
addProfile: "Add Profile",
|
||||
splitTerminalRight: "Split Right",
|
||||
splitTerminalDown: "Split Down",
|
||||
closeTerminal: "Close Terminal",
|
||||
};
|
||||
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||
board: "navigation",
|
||||
agent: "navigation",
|
||||
spec: "navigation",
|
||||
context: "navigation",
|
||||
settings: "navigation",
|
||||
profiles: "navigation",
|
||||
terminal: "navigation",
|
||||
toggleSidebar: "ui",
|
||||
addFeature: "action",
|
||||
addContextFile: "action",
|
||||
startNext: "action",
|
||||
newSession: "action",
|
||||
openProject: "action",
|
||||
projectPicker: "action",
|
||||
cyclePrevProject: "action",
|
||||
cycleNextProject: "action",
|
||||
addProfile: "action",
|
||||
splitTerminalRight: "action",
|
||||
splitTerminalDown: "action",
|
||||
closeTerminal: "action",
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const CATEGORY_COLORS = {
|
||||
navigation: {
|
||||
bg: "bg-blue-500/20",
|
||||
border: "border-blue-500/50",
|
||||
text: "text-blue-400",
|
||||
label: "Navigation",
|
||||
},
|
||||
ui: {
|
||||
bg: "bg-purple-500/20",
|
||||
border: "border-purple-500/50",
|
||||
text: "text-purple-400",
|
||||
label: "UI Controls",
|
||||
},
|
||||
action: {
|
||||
bg: "bg-green-500/20",
|
||||
border: "border-green-500/50",
|
||||
text: "text-green-400",
|
||||
label: "Actions",
|
||||
},
|
||||
};
|
||||
|
||||
interface KeyboardMapProps {
|
||||
onKeySelect?: (key: string) => void;
|
||||
selectedKey?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}), [keyboardShortcuts]);
|
||||
|
||||
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||
const keyToShortcuts = React.useMemo(() => {
|
||||
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
||||
(Object.entries(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcutName, shortcutStr]) => {
|
||||
if (!shortcutStr) return; // Skip undefined shortcuts
|
||||
const parsed = parseShortcut(shortcutStr);
|
||||
const normalizedKey = parsed.key.toUpperCase();
|
||||
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||
if (!map[normalizedKey]) {
|
||||
map[normalizedKey] = [];
|
||||
}
|
||||
map[normalizedKey].push({ name: shortcutName, hasModifiers });
|
||||
}
|
||||
);
|
||||
return map;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||
const normalizedKey = keyDef.key.toUpperCase();
|
||||
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||
const shortcuts = shortcutInfos.map(s => s.name);
|
||||
const isBound = shortcuts.length > 0;
|
||||
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||
const isModified = shortcuts.some(
|
||||
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||
);
|
||||
|
||||
// Get category for coloring (use first shortcut's category if multiple)
|
||||
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||
const colors = category ? CATEGORY_COLORS[category] : null;
|
||||
|
||||
const keyElement = (
|
||||
<button
|
||||
key={keyDef.key}
|
||||
onClick={() => onKeySelect?.(keyDef.key)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||
"h-12 min-w-11 py-1",
|
||||
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||
// Base styles
|
||||
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||
// Bound key styles
|
||||
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||
// Selected state
|
||||
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||
// Modified indicator
|
||||
isModified && "ring-1 ring-yellow-500/50"
|
||||
)}
|
||||
data-testid={`keyboard-key-${keyDef.key}`}
|
||||
>
|
||||
{/* Key label - always at top */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-mono font-bold leading-none",
|
||||
isBound && colors ? colors.text : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{keyDef.label}
|
||||
</span>
|
||||
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||
isBound && shortcuts.length > 0
|
||||
? (colors ? colors.text : "text-muted-foreground")
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{isBound && shortcuts.length > 0
|
||||
? (shortcuts.length === 1
|
||||
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
|
||||
: `${shortcuts.length}x`)
|
||||
: "\u00A0" // Non-breaking space to maintain height
|
||||
}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip if bound
|
||||
if (isBound) {
|
||||
return (
|
||||
<Tooltip key={keyDef.key}>
|
||||
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{shortcuts.map((shortcut) => {
|
||||
const shortcutStr = mergedShortcuts[shortcut];
|
||||
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||
return (
|
||||
<div key={shortcut} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
|
||||
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
|
||||
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||
{displayShortcut}
|
||||
</kbd>
|
||||
{mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||
<span className="text-xs text-yellow-400">(custom)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return keyElement;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border",
|
||||
colors.bg,
|
||||
colors.border
|
||||
)}
|
||||
/>
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||
configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Full shortcut reference panel with editing capability
|
||||
interface ShortcutReferencePanelProps {
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
|
||||
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
|
||||
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
|
||||
const [keyValue, setKeyValue] = React.useState("");
|
||||
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}), [keyboardShortcuts]);
|
||||
|
||||
const groupedShortcuts = React.useMemo(() => {
|
||||
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||
navigation: [],
|
||||
ui: [],
|
||||
action: [],
|
||||
};
|
||||
|
||||
(Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcut, category]) => {
|
||||
groups[category].push({
|
||||
key: shortcut,
|
||||
label: SHORTCUT_LABELS[shortcut] ?? shortcut,
|
||||
value: mergedShortcuts[shortcut],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return groups;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
// Build the full shortcut string from key + modifiers
|
||||
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
|
||||
const parts: string[] = [];
|
||||
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
|
||||
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
|
||||
if (mods.shift) parts.push("Shift");
|
||||
parts.push(key.toUpperCase());
|
||||
return parts.join("+");
|
||||
}, []);
|
||||
|
||||
// Check for conflicts with other shortcuts
|
||||
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
const conflict = Object.entries(mergedShortcuts).find(
|
||||
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
||||
);
|
||||
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||
const currentValue = mergedShortcuts[key];
|
||||
const parsed = parseShortcut(currentValue);
|
||||
setEditingShortcut(key);
|
||||
setKeyValue(parsed.key);
|
||||
setModifiers({
|
||||
shift: parsed.shift || false,
|
||||
cmdCtrl: parsed.cmdCtrl || false,
|
||||
alt: parsed.alt || false,
|
||||
});
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleSaveShortcut = () => {
|
||||
if (!editingShortcut || shortcutError || !keyValue) return;
|
||||
const shortcutStr = buildShortcutString(keyValue, modifiers);
|
||||
setKeyboardShortcut(editingShortcut, shortcutStr);
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
setKeyValue(value);
|
||||
// Check for conflicts with full shortcut string
|
||||
if (!value) {
|
||||
setShortcutError("Key cannot be empty");
|
||||
} else {
|
||||
const shortcutStr = buildShortcutString(value, modifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
|
||||
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
|
||||
const newModifiers = checked
|
||||
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
|
||||
: { ...modifiers, [modifier]: false };
|
||||
|
||||
setModifiers(newModifiers);
|
||||
|
||||
// Recheck for conflicts
|
||||
if (keyValue) {
|
||||
const shortcutStr = buildShortcutString(keyValue, newModifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !shortcutError && keyValue) {
|
||||
handleSaveShortcut();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShortcut = (key: keyof KeyboardShortcuts) => {
|
||||
setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="gap-2 text-xs"
|
||||
data-testid="reset-all-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||
{colors.label}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
const isEditing = editingShortcut === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌘" : "Ctrl"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">+</span>
|
||||
<Input
|
||||
value={keyValue}
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-12 h-7 text-center font-mono text-xs uppercase",
|
||||
shortcutError && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-input-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveShortcut();
|
||||
}}
|
||||
disabled={!!shortcutError || !keyValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-mono rounded border",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{formatShortcut(value, true)}
|
||||
</kbd>
|
||||
{isModified && editable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetShortcut(key);
|
||||
}}
|
||||
data-testid={`reset-shortcut-${key}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModified && !editable && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
{editable && !isModified && (
|
||||
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
23
apps/ui/src/components/ui/label.tsx
Normal file
23
apps/ui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
772
apps/ui/src/components/ui/log-viewer.tsx
Normal file
772
apps/ui/src/components/ui/log-viewer.tsx
Normal file
@@ -0,0 +1,772 @@
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
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 {
|
||||
output: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getLogIcon = (type: LogEntryType) => {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
case "tool_call":
|
||||
return <Wrench className="w-4 h-4" />;
|
||||
case "tool_result":
|
||||
return <FileOutput className="w-4 h-4" />;
|
||||
case "phase":
|
||||
return <Zap className="w-4 h-4" />;
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "thinking":
|
||||
return <Brain className="w-4 h-4" />;
|
||||
case "debug":
|
||||
return <Bug className="w-4 h-4" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
let lastIndex = 0;
|
||||
const parts: { type: "text" | "json"; content: string }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = jsonRegex.exec(content)) !== null) {
|
||||
// Add text before JSON
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
content: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
parts.push({
|
||||
type: "json",
|
||||
content: JSON.stringify(parsed, null, 2),
|
||||
});
|
||||
} catch {
|
||||
// Not valid JSON, treat as text
|
||||
parts.push({ type: "text", content: match[1] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ type: "text", content: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: "text" as const, 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 transition-all duration-200",
|
||||
bgColor,
|
||||
borderColor,
|
||||
"hover:brightness-110"
|
||||
)}
|
||||
data-testid={`log-entry-${entry.type}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left"
|
||||
data-testid={`log-entry-toggle-${entry.id}`}
|
||||
>
|
||||
{hasContent ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<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",
|
||||
isToolCall ? toolCategoryColors : colors.badge
|
||||
)}
|
||||
data-testid="log-entry-badge"
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
||||
{collapsedPreview}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(isExpanded || !hasContent) && (
|
||||
<div
|
||||
className="px-4 pb-3 pt-1"
|
||||
data-testid={`log-entry-content-${entry.id}`}
|
||||
>
|
||||
{/* 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 scrollbar-styled 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());
|
||||
// Track if user has "Expand All" mode active - new entries will auto-expand when this is true
|
||||
const [expandAllMode, setExpandAllMode] = useState(false);
|
||||
|
||||
// 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
|
||||
// Also auto-expand all entries when expandAllMode is active
|
||||
const effectiveExpandedIds = useMemo(() => {
|
||||
const result = new Set(expandedIds);
|
||||
|
||||
// If expand all mode is active, expand all filtered entries
|
||||
if (expandAllMode) {
|
||||
entries.forEach((entry) => {
|
||||
result.add(entry.id);
|
||||
});
|
||||
} else {
|
||||
// Otherwise, only auto-expand entries based on initial state (shouldCollapseByDefault)
|
||||
initialExpandedIds.forEach((id) => {
|
||||
if (!appliedInitialRef.current.has(id)) {
|
||||
appliedInitialRef.current.add(id);
|
||||
result.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [expandedIds, initialExpandedIds, expandAllMode, entries]);
|
||||
|
||||
// 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) => {
|
||||
// When user manually collapses an entry, turn off expand all mode
|
||||
if (effectiveExpandedIds.has(id)) {
|
||||
setExpandAllMode(false);
|
||||
}
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
// Enable expand all mode so new entries will also be expanded
|
||||
setExpandAllMode(true);
|
||||
setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
// Disable expand all mode when collapsing all
|
||||
setExpandAllMode(false);
|
||||
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">
|
||||
<div className="text-center">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
|
||||
{output && output.trim() && (
|
||||
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto scrollbar-styled">
|
||||
<pre className="whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count entries by type
|
||||
const typeCounts = entries.reduce((acc, entry) => {
|
||||
acc[entry.type] = (acc[entry.type] || 0) + 1;
|
||||
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", 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-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 (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleTypeFilter(type as LogEntryType)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full transition-all",
|
||||
colors.badge,
|
||||
isHidden && "opacity-40 line-through"
|
||||
)}
|
||||
title={isHidden ? `Show ${type}` : `Hide ${type}`}
|
||||
data-testid={`log-type-filter-${type}`}
|
||||
>
|
||||
{type}: {count}
|
||||
</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={cn(
|
||||
"text-xs px-2 py-1 rounded transition-colors",
|
||||
expandAllMode
|
||||
? "text-primary bg-primary/20 hover:bg-primary/30"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
|
||||
)}
|
||||
data-testid="log-expand-all"
|
||||
title={expandAllMode ? "Expand All (Active - new items will auto-expand)" : "Expand All"}
|
||||
>
|
||||
Expand All{expandAllMode ? " (On)" : ""}
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
||||
data-testid="log-collapse-all"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
47
apps/ui/src/components/ui/markdown.tsx
Normal file
47
apps/ui/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Markdown component for rendering markdown content
|
||||
* Theme-aware styling that adapts to all predefined themes
|
||||
*/
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
// Headings
|
||||
"[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
|
||||
"[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
|
||||
"[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
|
||||
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
|
||||
// Paragraphs
|
||||
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
|
||||
// Lists
|
||||
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
|
||||
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
|
||||
// Code
|
||||
"[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
|
||||
"[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
|
||||
// Strong/Bold
|
||||
"[&_strong]:text-foreground [&_strong]:font-semibold",
|
||||
// Links
|
||||
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
|
||||
// Blockquotes
|
||||
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
|
||||
// Horizontal rules
|
||||
"[&_hr]:border-border [&_hr]:my-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/ui/src/components/ui/popover.tsx
Normal file
72
apps/ui/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
|
||||
{children}
|
||||
</PopoverTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverContentPrimitive
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
46
apps/ui/src/components/ui/radio-group.tsx
Normal file
46
apps/ui/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
|
||||
160
apps/ui/src/components/ui/select.tsx
Normal file
160
apps/ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
161
apps/ui/src/components/ui/sheet.tsx
Normal file
161
apps/ui/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
forceMount?: true;
|
||||
}
|
||||
|
||||
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
||||
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
|
||||
SheetOverlayProps & { "data-slot": string }
|
||||
>;
|
||||
return (
|
||||
<Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
forceMount?: true;
|
||||
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||
onPointerDownOutside?: (event: PointerEvent) => void;
|
||||
onInteractOutside?: (event: Event) => void;
|
||||
}
|
||||
|
||||
const SheetContent = ({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: SheetContentProps) => {
|
||||
const Content = SheetPrimitive.Content as React.ComponentType<
|
||||
SheetContentProps & { "data-slot": string }
|
||||
>;
|
||||
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Close>
|
||||
</Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
};
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
|
||||
const Title = SheetPrimitive.Title as React.ComponentType<
|
||||
SheetTitleProps & { "data-slot": string }
|
||||
>;
|
||||
return (
|
||||
<Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SheetDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||
|
||||
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
|
||||
const Description = SheetPrimitive.Description as React.ComponentType<
|
||||
SheetDescriptionProps & { "data-slot": string }
|
||||
>;
|
||||
return (
|
||||
<Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
67
apps/ui/src/components/ui/slider.tsx
Normal file
67
apps/ui/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Range> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Thumb> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
|
||||
value?: number[];
|
||||
defaultValue?: number[];
|
||||
onValueChange?: (value: number[]) => void;
|
||||
onValueCommit?: (value: number[]) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
dir?: "ltr" | "rtl";
|
||||
inverted?: boolean;
|
||||
minStepsBetweenThumbs?: number;
|
||||
}
|
||||
|
||||
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SliderRootPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
||||
</SliderTrackPrimitive>
|
||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
</SliderRootPrimitive>
|
||||
)
|
||||
);
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
31
apps/ui/src/components/ui/switch.tsx
Normal file
31
apps/ui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
||||
|
||||
123
apps/ui/src/components/ui/tabs.tsx
Normal file
123
apps/ui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsRootPrimitive
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsRootPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsListPrimitive
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsListPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsTriggerPrimitive
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
||||
"text-foreground/70 hover:text-foreground hover:bg-accent",
|
||||
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsContentPrimitive
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsContentPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
274
apps/ui/src/components/ui/task-progress-panel.tsx
Normal file
274
apps/ui/src/components/ui/task-progress-panel.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TaskInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
interface TaskProgressPanelProps {
|
||||
featureId: string;
|
||||
projectPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) {
|
||||
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Load initial tasks from feature's planSpec
|
||||
const loadInitialTasks = useCallback(async () => {
|
||||
if (!projectPath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.features) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.get(projectPath, featureId);
|
||||
if (result.success && result.feature?.planSpec?.tasks) {
|
||||
const planTasks = result.feature.planSpec.tasks;
|
||||
const currentId = result.feature.planSpec.currentTaskId;
|
||||
const completedCount = result.feature.planSpec.tasksCompleted || 0;
|
||||
|
||||
// Convert planSpec tasks to TaskInfo with proper status
|
||||
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status: index < completedCount
|
||||
? "completed" as const
|
||||
: t.id === currentId
|
||||
? "in_progress" as const
|
||||
: "pending" as const,
|
||||
}));
|
||||
|
||||
setTasks(initialTasks);
|
||||
setCurrentTaskId(currentId || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial tasks:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [featureId, projectPath]);
|
||||
|
||||
// Load initial state on mount
|
||||
useEffect(() => {
|
||||
loadInitialTasks();
|
||||
}, [loadInitialTasks]);
|
||||
|
||||
// Listen to task events for real-time updates
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
// Only handle events for this feature
|
||||
if (!("featureId" in event) || event.featureId !== featureId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_task_started":
|
||||
if ("taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
setCurrentTaskId(taskEvent.taskId);
|
||||
|
||||
setTasks((prev) => {
|
||||
// Check if task already exists
|
||||
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update status to in_progress and mark previous as completed
|
||||
return prev.map((t, idx) => {
|
||||
if (t.id === taskEvent.taskId) {
|
||||
return { ...t, status: "in_progress" as const };
|
||||
}
|
||||
// If we are moving to a task that is further down the list, assume previous ones are completed
|
||||
// This is a heuristic, but usually correct for sequential execution
|
||||
if (idx < existingIndex && t.status !== "completed") {
|
||||
return { ...t, status: "completed" as const };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
// Add new task if it doesn't exist (fallback)
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: taskEvent.taskId,
|
||||
description: taskEvent.taskDescription,
|
||||
status: "in_progress" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "auto_mode_task_complete":
|
||||
if ("taskId" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [featureId]);
|
||||
|
||||
const completedCount = tasks.filter((t) => t.status === "completed").length;
|
||||
const totalCount = tasks.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
if (isLoading || tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
|
||||
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
|
||||
)}>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<h3 className="font-semibold text-sm tracking-tight">Execution Plan</h3>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{completedCount} of {totalCount} tasks completed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Circular Progress (Mini) */}
|
||||
<div className="relative h-8 w-8 flex items-center justify-center">
|
||||
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
|
||||
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
|
||||
<circle
|
||||
className="text-primary transition-all duration-500 ease-in-out"
|
||||
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
|
||||
strokeDasharray={63}
|
||||
strokeDashoffset={63 - (63 * progressPercent) / 100}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={cn(
|
||||
"grid transition-all duration-300 ease-in-out",
|
||||
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
)}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||
{/* Vertical Connector Line */}
|
||||
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
|
||||
|
||||
<div className="space-y-5">
|
||||
{tasks.map((task, index) => {
|
||||
const isActive = task.status === "in_progress";
|
||||
const isCompleted = task.status === "completed";
|
||||
const isPending = task.status === "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"relative flex gap-4 group/item transition-all duration-300",
|
||||
isPending && "opacity-60 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{/* Icon Status */}
|
||||
<div className={cn(
|
||||
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
|
||||
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
|
||||
isPending && "bg-muted border-border text-muted-foreground"
|
||||
)}>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
{/* Task Content */}
|
||||
<div className={cn(
|
||||
"flex-1 pt-1 min-w-0 transition-all",
|
||||
isActive && "translate-x-1"
|
||||
)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className={cn(
|
||||
"text-sm font-medium leading-none truncate pr-4",
|
||||
isCompleted && "text-muted-foreground line-through decoration-border/60",
|
||||
isActive && "text-primary font-semibold"
|
||||
)}>
|
||||
{task.description}
|
||||
</p>
|
||||
{isActive && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(task.filePath || isActive) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
|
||||
{task.filePath ? (
|
||||
<>
|
||||
<FileCode className="h-3 w-3 opacity-70" />
|
||||
<span className="truncate opacity-80 hover:opacity-100 transition-opacity">
|
||||
{task.filePath}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="h-3 block" /> /* Spacer */
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/ui/src/components/ui/textarea.tsx
Normal file
24
apps/ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
|
||||
// Inner shadow for depth
|
||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||
// Animated focus ring
|
||||
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
70
apps/ui/src/components/ui/tooltip.tsx
Normal file
70
apps/ui/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
function TooltipTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTriggerPrimitive asChild={asChild} {...props}>
|
||||
{children}
|
||||
</TooltipTriggerPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContentPrimitive
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground",
|
||||
// Premium shadow
|
||||
"shadow-lg shadow-black/10",
|
||||
// Faster, snappier animations
|
||||
"animate-in fade-in-0 zoom-in-95 duration-150",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100",
|
||||
// Slide from edge
|
||||
"data-[side=bottom]:slide-in-from-top-1",
|
||||
"data-[side=left]:slide-in-from-right-1",
|
||||
"data-[side=right]:slide-in-from-left-1",
|
||||
"data-[side=top]:slide-in-from-bottom-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
119
apps/ui/src/components/ui/xml-syntax-editor.tsx
Normal file
119
apps/ui/src/components/ui/xml-syntax-editor.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting that uses CSS variables from the app's theme system
|
||||
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// XML tags - use primary color
|
||||
{ tag: t.tagName, color: "var(--primary)" },
|
||||
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
|
||||
|
||||
// Attributes
|
||||
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
|
||||
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
|
||||
|
||||
// Strings and content
|
||||
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
|
||||
{ tag: t.content, color: "var(--foreground)" },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
|
||||
|
||||
// Special
|
||||
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
|
||||
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "1rem",
|
||||
minHeight: "100%",
|
||||
caretColor: "var(--primary)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-gutters": {
|
||||
display: "none",
|
||||
},
|
||||
".cm-placeholder": {
|
||||
color: "var(--muted-foreground)",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [
|
||||
xml(),
|
||||
syntaxHighlighting(syntaxColors),
|
||||
editorTheme,
|
||||
];
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: XmlSyntaxEditorProps) {
|
||||
return (
|
||||
<div className={cn("w-full h-full", className)} data-testid={testId}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: true,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
apps/ui/src/components/views/agent-tools-view.tsx
Normal file
487
apps/ui/src/components/views/agent-tools-view.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
File,
|
||||
Pencil,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
tool: string;
|
||||
input: string;
|
||||
result: ToolResult | null;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function AgentToolsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read File Tool State
|
||||
const [readFilePath, setReadFilePath] = useState("");
|
||||
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
|
||||
const [isReadingFile, setIsReadingFile] = useState(false);
|
||||
|
||||
// Write File Tool State
|
||||
const [writeFilePath, setWriteFilePath] = useState("");
|
||||
const [writeFileContent, setWriteFileContent] = useState("");
|
||||
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
|
||||
null
|
||||
);
|
||||
const [isWritingFile, setIsWritingFile] = useState(false);
|
||||
|
||||
// Terminal Tool State
|
||||
const [terminalCommand, setTerminalCommand] = useState("ls");
|
||||
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
|
||||
const [isRunningCommand, setIsRunningCommand] = useState(false);
|
||||
|
||||
// Execute Read File
|
||||
const handleReadFile = useCallback(async () => {
|
||||
if (!readFilePath.trim()) return;
|
||||
|
||||
setIsReadingFile(true);
|
||||
setReadFileResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file read
|
||||
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
|
||||
|
||||
const result = await api.readFile(readFilePath);
|
||||
|
||||
if (result.success) {
|
||||
setReadFileResult({
|
||||
success: true,
|
||||
output: result.content,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
|
||||
} else {
|
||||
setReadFileResult({
|
||||
success: false,
|
||||
error: result.error || "Failed to read file",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setReadFileResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsReadingFile(false);
|
||||
}
|
||||
}, [readFilePath, api]);
|
||||
|
||||
// Execute Write File
|
||||
const handleWriteFile = useCallback(async () => {
|
||||
if (!writeFilePath.trim() || !writeFileContent.trim()) return;
|
||||
|
||||
setIsWritingFile(true);
|
||||
setWriteFileResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file write
|
||||
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
|
||||
|
||||
const result = await api.writeFile(writeFilePath, writeFileContent);
|
||||
|
||||
if (result.success) {
|
||||
setWriteFileResult({
|
||||
success: true,
|
||||
output: `File written successfully: ${writeFilePath}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
|
||||
} else {
|
||||
setWriteFileResult({
|
||||
success: false,
|
||||
error: result.error || "Failed to write file",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setWriteFileResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsWritingFile(false);
|
||||
}
|
||||
}, [writeFilePath, writeFileContent, api]);
|
||||
|
||||
// Execute Terminal Command
|
||||
const handleRunCommand = useCallback(async () => {
|
||||
if (!terminalCommand.trim()) return;
|
||||
|
||||
setIsRunningCommand(true);
|
||||
setTerminalResult(null);
|
||||
|
||||
try {
|
||||
// Terminal command simulation for demonstration purposes
|
||||
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
|
||||
|
||||
// Simulated outputs for common commands (preview mode)
|
||||
// In production, the agent executes commands via Claude SDK
|
||||
const simulatedOutputs: Record<string, string> = {
|
||||
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
|
||||
pwd: currentProject?.path || "/Users/demo/project",
|
||||
"echo hello": "hello",
|
||||
whoami: "automaker-agent",
|
||||
date: new Date().toString(),
|
||||
"cat package.json":
|
||||
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
|
||||
};
|
||||
|
||||
// Simulate command execution delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const output =
|
||||
simulatedOutputs[terminalCommand.toLowerCase()] ||
|
||||
`[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`;
|
||||
|
||||
setTerminalResult({
|
||||
success: true,
|
||||
output: output,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(
|
||||
`[Agent Tool] Command executed successfully: ${terminalCommand}`
|
||||
);
|
||||
} catch (error) {
|
||||
setTerminalResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsRunningCommand(false);
|
||||
}
|
||||
}, [terminalCommand, currentProject]);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="agent-tools-no-project"
|
||||
>
|
||||
<div className="text-center">
|
||||
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Open or create a project to test agent tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="agent-tools-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Test file system and terminal tools for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{/* Read File Tool */}
|
||||
<Card data-testid="read-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="w-5 h-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">Read File</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to read a file from the filesystem
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="read-file-path">File Path</Label>
|
||||
<Input
|
||||
id="read-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={readFilePath}
|
||||
onChange={(e) => setReadFilePath(e.target.value)}
|
||||
data-testid="read-file-path-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleReadFile}
|
||||
disabled={isReadingFile || !readFilePath.trim()}
|
||||
className="w-full"
|
||||
data-testid="read-file-button"
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Read
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{readFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
readFileResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="read-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{readFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{readFileResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{readFileResult.success
|
||||
? readFileResult.output
|
||||
: readFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Write File Tool */}
|
||||
<Card data-testid="write-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-green-500" />
|
||||
<CardTitle className="text-lg">Write File</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to write content to a file
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-path">File Path</Label>
|
||||
<Input
|
||||
id="write-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={writeFilePath}
|
||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||
data-testid="write-file-path-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-content">Content</Label>
|
||||
<textarea
|
||||
id="write-file-content"
|
||||
placeholder="File content..."
|
||||
value={writeFileContent}
|
||||
onChange={(e) => setWriteFileContent(e.target.value)}
|
||||
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
|
||||
data-testid="write-file-content-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleWriteFile}
|
||||
disabled={
|
||||
isWritingFile ||
|
||||
!writeFilePath.trim() ||
|
||||
!writeFileContent.trim()
|
||||
}
|
||||
className="w-full"
|
||||
data-testid="write-file-button"
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Write
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{writeFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
writeFileResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="write-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{writeFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{writeFileResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{writeFileResult.success
|
||||
? writeFileResult.output
|
||||
: writeFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Tool */}
|
||||
<Card data-testid="terminal-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-purple-500" />
|
||||
<CardTitle className="text-lg">Run Terminal</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to execute a terminal command
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="terminal-command">Command</Label>
|
||||
<Input
|
||||
id="terminal-command"
|
||||
placeholder="ls -la"
|
||||
value={terminalCommand}
|
||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||
data-testid="terminal-command-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunCommand}
|
||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||
className="w-full"
|
||||
data-testid="run-terminal-button"
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{terminalResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
terminalResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="terminal-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{terminalResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{terminalResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
|
||||
$ {terminalCommand}
|
||||
{"\n"}
|
||||
{terminalResult.success
|
||||
? terminalResult.output
|
||||
: terminalResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tool Log Section */}
|
||||
<Card className="mt-6" data-testid="tool-log">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
|
||||
<CardDescription>
|
||||
View agent tool requests and responses
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Open your browser's developer console to see detailed agent
|
||||
tool logs.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Read File - Agent requests file content from filesystem</li>
|
||||
<li>Write File - Agent writes content to specified path</li>
|
||||
<li>Run Terminal - Agent executes shell commands</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
915
apps/ui/src/components/views/agent-view.tsx
Normal file
915
apps/ui/src/components/views/agent-view.tsx
Normal file
@@ -0,0 +1,915 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
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";
|
||||
import {
|
||||
Bot,
|
||||
Send,
|
||||
User,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
Trash2,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Paperclip,
|
||||
X,
|
||||
ImageIcon,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
||||
import { SessionManager } from "@/components/session-manager";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
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 } =
|
||||
useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [input, setInput] = useState("");
|
||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
// Scroll management for auto-scroll
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Ref for quick create session function from SessionManager
|
||||
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
// Use the Electron agent hook (only if we have a session)
|
||||
const {
|
||||
messages,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
clearHistory,
|
||||
error: agentError,
|
||||
} = useElectronAgent({
|
||||
sessionId: currentSessionId || "",
|
||||
workingDirectory: currentProject?.path,
|
||||
model: selectedModel,
|
||||
onToolUse: (toolName) => {
|
||||
setCurrentTool(toolName);
|
||||
setTimeout(() => setCurrentTool(null), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle session selection with persistence
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string | null) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
// Persist the selection for this project
|
||||
if (currentProject?.path) {
|
||||
setLastSelectedSession(currentProject.path, sessionId);
|
||||
}
|
||||
},
|
||||
[currentProject?.path, setLastSelectedSession]
|
||||
);
|
||||
|
||||
// Restore last selected session when switching to Agent view or when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) {
|
||||
// No project, reset
|
||||
setCurrentSessionId(null);
|
||||
initialSessionLoadedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only restore once per project
|
||||
if (initialSessionLoadedRef.current) return;
|
||||
initialSessionLoadedRef.current = true;
|
||||
|
||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
||||
if (lastSessionId) {
|
||||
console.log(
|
||||
"[AgentView] Restoring last selected session:",
|
||||
lastSessionId
|
||||
);
|
||||
setCurrentSessionId(lastSessionId);
|
||||
}
|
||||
}, [currentProject?.path, getLastSelectedSession]);
|
||||
|
||||
// Reset initialSessionLoadedRef when project changes
|
||||
useEffect(() => {
|
||||
initialSessionLoadedRef.current = false;
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
|
||||
|
||||
const messageContent = input;
|
||||
const messageImages = selectedImages;
|
||||
|
||||
setInput("");
|
||||
setSelectedImages([]);
|
||||
setShowImageDropZone(false);
|
||||
|
||||
await sendMessage(messageContent, messageImages);
|
||||
}, [input, selectedImages, isProcessing, sendMessage]);
|
||||
|
||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||
setSelectedImages(images);
|
||||
}, []);
|
||||
|
||||
const toggleImageDropZone = useCallback(() => {
|
||||
setShowImageDropZone(!showImageDropZone);
|
||||
}, [showImageDropZone]);
|
||||
|
||||
// Helper function to convert file to base64
|
||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Process dropped files
|
||||
const processDroppedFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILES = 5;
|
||||
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + selectedImages.length >= MAX_FILES) {
|
||||
errors.push(`Maximum ${MAX_FILES} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||
}
|
||||
},
|
||||
[isProcessing, selectedImages, fileToBase64]
|
||||
);
|
||||
|
||||
// Remove individual image
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
// Drag and drop handlers for the input area
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
// Check if dragged items contain files
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only set dragOver to false if we're leaving the input container
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isProcessing || !isConnected) return;
|
||||
|
||||
// Check if we have files
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
processDroppedFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file paths (from screenshots or other sources)
|
||||
const items = e.dataTransfer.items;
|
||||
if (items && items.length > 0) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isProcessing, isConnected, processDroppedFiles]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent) => {
|
||||
// Check if clipboard contains files
|
||||
const items = e.clipboardData?.items;
|
||||
if (items) {
|
||||
const files: File[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
e.preventDefault(); // Prevent default paste of file path
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
files.forEach((file) => dataTransfer.items.add(file));
|
||||
await processDroppedFiles(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
},
|
||||
[processDroppedFiles]
|
||||
);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearChat = async () => {
|
||||
if (!confirm("Are you sure you want to clear this conversation?")) return;
|
||||
await clearHistory();
|
||||
};
|
||||
|
||||
// Scroll position detection
|
||||
const checkIfUserIsAtBottom = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const threshold = 50; // 50px threshold for "near bottom"
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
||||
threshold;
|
||||
|
||||
setIsUserAtBottom(isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom function
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = useCallback(() => {
|
||||
checkIfUserIsAtBottom();
|
||||
}, [checkIfUserIsAtBottom]);
|
||||
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if user was already at bottom
|
||||
if (isUserAtBottom && messages.length > 0) {
|
||||
// Use a small delay to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
scrollToBottom("smooth");
|
||||
}, 100);
|
||||
}
|
||||
}, [messages, isUserAtBottom, scrollToBottom]);
|
||||
|
||||
// Initial scroll to bottom when session changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId && messages.length > 0) {
|
||||
// Scroll immediately without animation when switching sessions
|
||||
setTimeout(() => {
|
||||
scrollToBottom("auto");
|
||||
setIsUserAtBottom(true);
|
||||
}, 100);
|
||||
}
|
||||
}, [currentSessionId, scrollToBottom]);
|
||||
|
||||
// Auto-focus input when session is selected/changed
|
||||
useEffect(() => {
|
||||
if (currentSessionId && inputRef.current) {
|
||||
// Small delay to ensure UI has updated
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Keyboard shortcuts for agent view
|
||||
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [];
|
||||
|
||||
// New session shortcut - only when in agent view with a project
|
||||
if (currentProject) {
|
||||
shortcutsList.push({
|
||||
key: shortcuts.newSession,
|
||||
action: () => {
|
||||
if (quickCreateSessionRef.current) {
|
||||
quickCreateSessionRef.current();
|
||||
}
|
||||
},
|
||||
description: "Create new session",
|
||||
});
|
||||
}
|
||||
|
||||
return shortcutsList;
|
||||
}, [currentProject, shortcuts]);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
useKeyboardShortcuts(agentShortcuts);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="agent-view-no-project"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-3 text-foreground">
|
||||
No Project Selected
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Open or create a project to start working with the AI agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show welcome message if no messages yet
|
||||
const displayMessages =
|
||||
messages.length === 0
|
||||
? [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant" as const,
|
||||
content:
|
||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex overflow-hidden bg-background"
|
||||
data-testid="agent-view"
|
||||
>
|
||||
{/* Session Manager Sidebar */}
|
||||
{showSessionManager && currentProject && (
|
||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
||||
<SessionManager
|
||||
currentSessionId={currentSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
projectPath={currentProject.path}
|
||||
isCurrentSessionThinking={isProcessing}
|
||||
onQuickCreateRef={quickCreateSessionRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
AI Agent
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.name}
|
||||
{currentSessionId && !isConnected && " - Connecting..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
<span className="font-medium">{currentTool}</span>
|
||||
</div>
|
||||
)}
|
||||
{agentError && (
|
||||
<span className="text-xs text-destructive font-medium">
|
||||
{agentError}
|
||||
</span>
|
||||
)}
|
||||
{currentSessionId && messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearChat}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{!currentSessionId ? (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center bg-background"
|
||||
data-testid="no-session-placeholder"
|
||||
>
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
||||
<Bot className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-3 text-foreground">
|
||||
No Session Selected
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||
Create or select a session to start chatting with the AI agent
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowSessionManager(true)}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
{showSessionManager ? "View" : "Show"} Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
|
||||
data-testid="message-list"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{displayMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-4 max-w-4xl",
|
||||
message.role === "user" ? "flex-row-reverse ml-auto" : ""
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
|
||||
message.role === "assistant"
|
||||
? "bg-primary/10 ring-1 ring-primary/20"
|
||||
: "bg-muted ring-1 ring-border"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card border border-border"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||
{message.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Display attached images for user messages */}
|
||||
{message.role === "user" &&
|
||||
message.images &&
|
||||
message.images.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{message.images.length} image
|
||||
{message.images.length > 1 ? "s" : ""} attached
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.images.map((image, index) => {
|
||||
// Construct proper data URL from base64 data and mime type
|
||||
const dataUrl = image.data.startsWith("data:")
|
||||
? image.data
|
||||
: `data:${image.mimeType || "image/png"};base64,${
|
||||
image.data
|
||||
}`;
|
||||
return (
|
||||
<div
|
||||
key={image.id || `img-${index}`}
|
||||
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={
|
||||
image.filename ||
|
||||
`Attached image ${index + 1}`
|
||||
}
|
||||
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
|
||||
{image.filename || `Image ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] mt-2 font-medium",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex gap-4 max-w-4xl">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Thinking...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
{currentSessionId && (
|
||||
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
|
||||
{/* Image Drop Zone (when visible) */}
|
||||
{showImageDropZone && (
|
||||
<ImageDropZone
|
||||
onImagesSelected={handleImagesSelected}
|
||||
images={selectedImages}
|
||||
maxFiles={5}
|
||||
className="mb-4"
|
||||
disabled={isProcessing || !isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||
{selectedImages.length > 0 && !showImageDropZone && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""} attached
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSelectedImages([])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate max-w-24">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{image.id && (
|
||||
<button
|
||||
onClick={() => removeImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input and Controls */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 transition-all duration-200 rounded-xl p-1",
|
||||
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
? "Drop your images here..."
|
||||
: "Describe what you want to build..."
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={handlePaste}
|
||||
disabled={isProcessing || !isConnected}
|
||||
data-testid="agent-input"
|
||||
className={cn(
|
||||
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
|
||||
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
|
||||
selectedImages.length > 0 && "border-primary/30",
|
||||
isDragOver && "border-primary bg-primary/5"
|
||||
)}
|
||||
/>
|
||||
{selectedImages.length > 0 && !isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
|
||||
{selectedImages.length} image
|
||||
{selectedImages.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
Drop here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Attachment Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleImageDropZone}
|
||||
disabled={isProcessing || !isConnected}
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl border-border",
|
||||
showImageDropZone &&
|
||||
"bg-primary/10 text-primary border-primary/30",
|
||||
selectedImages.length > 0 && "border-primary/30 text-primary"
|
||||
)}
|
||||
title="Attach images"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() && selectedImages.length === 0) ||
|
||||
isProcessing ||
|
||||
!isConnected
|
||||
}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||
Press{" "}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
to send
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
1133
apps/ui/src/components/views/analysis-view.tsx
Normal file
1133
apps/ui/src/components/views/analysis-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1244
apps/ui/src/components/views/board-view.tsx
Normal file
1244
apps/ui/src/components/views/board-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
136
apps/ui/src/components/views/board-view/board-controls.tsx
Normal file
136
apps/ui/src/components/views/board-view/board-controls.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BoardControlsProps {
|
||||
isMounted: boolean;
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
|
||||
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
|
||||
}
|
||||
|
||||
export function BoardControls({
|
||||
isMounted,
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
kanbanCardDetailLevel,
|
||||
onDetailLevelChange,
|
||||
}: BoardControlsProps) {
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowBoardBackground}
|
||||
className="h-8 px-2"
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Board Background Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Completed/Archived Features Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowCompletedModal}
|
||||
className="h-8 px-2 relative"
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{completedCount > 99 ? "99+" : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Completed Features ({completedCount})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kanban Card Detail Level Toggle */}
|
||||
<div
|
||||
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||
data-testid="kanban-detail-toggle"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange("minimal")}
|
||||
className={cn(
|
||||
"p-2 rounded-l-lg transition-colors",
|
||||
kanbanCardDetailLevel === "minimal"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-minimal"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Minimal - Title & category only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange("standard")}
|
||||
className={cn(
|
||||
"p-2 transition-colors",
|
||||
kanbanCardDetailLevel === "standard"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-standard"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Standard - Steps & progress</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDetailLevelChange("detailed")}
|
||||
className={cn(
|
||||
"p-2 rounded-r-lg transition-colors",
|
||||
kanbanCardDetailLevel === "detailed"
|
||||
? "bg-brand-500/20 text-brand-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
data-testid="kanban-toggle-detailed"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Detailed - Model, tools & tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
94
apps/ui/src/components/views/board-view/board-header.tsx
Normal file
94
apps/ui/src/components/views/board-view/board-header.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
maxConcurrency: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onAddFeature: () => void;
|
||||
addFeatureShortcut: KeyboardShortcut;
|
||||
isMounted: boolean;
|
||||
}
|
||||
|
||||
export function BoardHeader({
|
||||
projectName,
|
||||
maxConcurrency,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onAddFeature,
|
||||
addFeatureShortcut,
|
||||
isMounted,
|
||||
}: BoardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-20"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
||||
data-testid="concurrency-value"
|
||||
>
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={onAddFeature}
|
||||
hotkey={addFeatureShortcut}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/ui/src/components/views/board-view/board-search-bar.tsx
Normal file
88
apps/ui/src/components/views/board-view/board-search-bar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, X, Loader2 } from "lucide-react";
|
||||
|
||||
interface BoardSearchBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
isCreatingSpec: boolean;
|
||||
creatingSpecProjectPath?: string;
|
||||
currentProjectPath?: string;
|
||||
}
|
||||
|
||||
export function BoardSearchBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
currentProjectPath,
|
||||
}: BoardSearchBarProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus search input when "/" is pressed
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only focus if not typing in an input/textarea
|
||||
if (
|
||||
e.key === "/" &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md flex-1 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search features by keyword..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 pr-12 border-border"
|
||||
data-testid="kanban-search-input"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="kanban-search-clear"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||
data-testid="kanban-search-hotkey"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Spec Creation Loading Badge */}
|
||||
{isCreatingSpec &&
|
||||
currentProjectPath === creatingSpecProjectPath && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
|
||||
title="Creating App Specification"
|
||||
data-testid="spec-creation-badge"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||
Creating spec
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { KanbanCard } from "./kanban-card";
|
||||
export { KanbanColumn } from "./kanban-column";
|
||||
1260
apps/ui/src/components/views/board-view/components/kanban-card.tsx
Normal file
1260
apps/ui/src/components/views/board-view/components/kanban-card.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
colorClass: string;
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
opacity?: number;
|
||||
showBorder?: boolean;
|
||||
hideScrollbar?: boolean;
|
||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
id,
|
||||
title,
|
||||
colorClass,
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
width,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
// Use inline style for width if provided, otherwise use default w-72
|
||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"relative flex flex-col h-full rounded-xl transition-all duration-200",
|
||||
!width && "w-72", // Only apply w-72 if no custom width
|
||||
showBorder && "border border-border/60",
|
||||
isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
|
||||
)}
|
||||
style={widthStyle}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Background layer with opacity */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
|
||||
isOver ? "bg-accent/80" : "bg-card/80"
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Column Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex items-center gap-3 px-3 py-2.5",
|
||||
showBorder && "border-b border-border/40"
|
||||
)}
|
||||
>
|
||||
<div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
|
||||
hideScrollbar &&
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
// Smooth scrolling
|
||||
"scroll-smooth"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Drop zone indicator when dragging over */}
|
||||
{isOver && (
|
||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
22
apps/ui/src/components/views/board-view/constants.ts
Normal file
22
apps/ui/src/components/views/board-view/constants.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
export type ColumnId = Feature["status"];
|
||||
|
||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
|
||||
{
|
||||
id: "in_progress",
|
||||
title: "In Progress",
|
||||
colorClass: "bg-[var(--status-in-progress)]",
|
||||
},
|
||||
{
|
||||
id: "waiting_approval",
|
||||
title: "Waiting Approval",
|
||||
colorClass: "bg-[var(--status-waiting)]",
|
||||
},
|
||||
{
|
||||
id: "verified",
|
||||
title: "Verified",
|
||||
colorClass: "bg-[var(--status-success)]",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,556 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
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 {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
import {
|
||||
MessageSquare,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { modelSupportsThinking } from "@/lib/utils";
|
||||
import {
|
||||
useAppStore,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
FeatureImage,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
} from "@/store/app-store";
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
} from "../shared";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
interface AddFeatureDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (feature: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
images: FeatureImage[];
|
||||
imagePaths: DescriptionImagePath[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
defaultSkipTests: boolean;
|
||||
defaultBranch?: string;
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
}
|
||||
|
||||
export function AddFeatureDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
categorySuggestions,
|
||||
branchSuggestions,
|
||||
branchCardCounts,
|
||||
defaultSkipTests,
|
||||
defaultBranch = "main",
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
}: AddFeatureDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
category: "",
|
||||
description: "",
|
||||
steps: [""],
|
||||
images: [] as FeatureImage[],
|
||||
imagePaths: [] as DescriptionImagePath[],
|
||||
skipTests: false,
|
||||
model: "opus" as AgentModel,
|
||||
thinkingLevel: "none" as ThinkingLevel,
|
||||
branchName: "",
|
||||
priority: 2 as number, // Default to medium priority
|
||||
});
|
||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||
useState<ImagePreviewMap>(() => new Map());
|
||||
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 [planningMode, setPlanningMode] = useState<PlanningMode>("skip");
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
|
||||
// Get enhancement model, planning mode defaults, and worktrees setting from store
|
||||
const {
|
||||
enhancementModel,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
useWorktrees,
|
||||
} = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Find the default profile if one is set
|
||||
const defaultProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch || "",
|
||||
// Use default profile's model/thinkingLevel if set, else fallback to defaults
|
||||
model: defaultProfile?.model ?? "opus",
|
||||
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
|
||||
}));
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
defaultSkipTests,
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newFeature.description.trim()) {
|
||||
setDescriptionError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch selection when "other branch" is selected
|
||||
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
||||
toast.error("Please select a branch name");
|
||||
return;
|
||||
}
|
||||
|
||||
const category = newFeature.category || "Uncategorized";
|
||||
const selectedModel = newFeature.model;
|
||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||
? newFeature.thinkingLevel
|
||||
: "none";
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? currentBranch || ""
|
||||
: newFeature.branchName || "";
|
||||
|
||||
onAdd({
|
||||
category,
|
||||
description: newFeature.description,
|
||||
steps: newFeature.steps.filter((s) => s.trim()),
|
||||
images: newFeature.images,
|
||||
imagePaths: newFeature.imagePaths,
|
||||
skipTests: newFeature.skipTests,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
branchName: finalBranchName,
|
||||
priority: newFeature.priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setNewFeature({
|
||||
category: "",
|
||||
description: "",
|
||||
steps: [""],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: "opus",
|
||||
priority: 2,
|
||||
thinkingLevel: "none",
|
||||
branchName: "",
|
||||
});
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setDescriptionError(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
setNewFeaturePreviewMap(new Map());
|
||||
setShowAdvancedOptions(false);
|
||||
setDescriptionError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnhanceDescription = async () => {
|
||||
if (!newFeature.description.trim() || isEnhancing) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.enhancePrompt?.enhance(
|
||||
newFeature.description,
|
||||
enhancementMode,
|
||||
enhancementModel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
||||
toast.success("Description enhanced!");
|
||||
} else {
|
||||
toast.error(result?.error || "Failed to enhance description");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Enhancement failed:", error);
|
||||
toast.error("Failed to enhance description");
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: AgentModel) => {
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
thinkingLevel: modelSupportsThinking(model)
|
||||
? newFeature.thinkingLevel
|
||||
: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (
|
||||
model: AgentModel,
|
||||
thinkingLevel: ThinkingLevel
|
||||
) => {
|
||||
setNewFeature({
|
||||
...newFeature,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="add-feature-dialog"
|
||||
onPointerDownOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Feature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new feature card for the Kanban board.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs
|
||||
defaultValue="prompt"
|
||||
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model" data-testid="tab-model">
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" data-testid="tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
value={newFeature.description}
|
||||
onChange={(value) => {
|
||||
setNewFeature({ ...newFeature, description: value });
|
||||
if (value.trim()) {
|
||||
setDescriptionError(false);
|
||||
}
|
||||
}}
|
||||
images={newFeature.imagePaths}
|
||||
onImagesChange={(images) =>
|
||||
setNewFeature({ ...newFeature, imagePaths: images })
|
||||
}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={newFeaturePreviewMap}
|
||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
||||
autoFocus
|
||||
error={descriptionError}
|
||||
/>
|
||||
</div>
|
||||
<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"}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("improve")}
|
||||
>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("technical")}
|
||||
>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("simplify")}
|
||||
>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("acceptance")}
|
||||
>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!newFeature.description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Enhance with AI
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category (optional)</Label>
|
||||
<CategoryAutocomplete
|
||||
value={newFeature.category}
|
||||
onChange={(value) =>
|
||||
setNewFeature({ ...newFeature, category: value })
|
||||
}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={newFeature.branchName}
|
||||
onBranchNameChange={(value) =>
|
||||
setNewFeature({ ...newFeature, branchName: value })
|
||||
}
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
testIdPrefix="feature"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
selectedPriority={newFeature.priority}
|
||||
onPrioritySelect={(priority) =>
|
||||
setNewFeature({ ...newFeature, priority })
|
||||
}
|
||||
testIdPrefix="priority"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Simple Mode Active
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
data-testid="show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={newFeature.model}
|
||||
selectedThinkingLevel={newFeature.thinkingLevel}
|
||||
onSelect={handleProfileSelect}
|
||||
showManageLink
|
||||
onManageLinkClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: "/profiles" });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
{aiProfiles.length > 0 &&
|
||||
(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModel={newFeature.model}
|
||||
onModelSelect={handleModelSelect}
|
||||
/>
|
||||
{newModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={newFeature.thinkingLevel}
|
||||
onLevelSelect={(level) =>
|
||||
setNewFeature({ ...newFeature, thinkingLevel: level })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabsContent
|
||||
value="options"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={newFeature.description}
|
||||
testIdPrefix="add-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={newFeature.skipTests}
|
||||
onSkipTestsChange={(skipTests) =>
|
||||
setNewFeature({ ...newFeature, skipTests })
|
||||
}
|
||||
steps={newFeature.steps}
|
||||
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleAdd}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-add-feature"
|
||||
disabled={
|
||||
useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()
|
||||
}
|
||||
>
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
featureDescription: string;
|
||||
featureId: string;
|
||||
/** The status of the feature - used to determine if spinner should be shown */
|
||||
featureStatus?: string;
|
||||
/** Called when a number key (0-9) is pressed while the modal is open */
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = "parsed" | "raw" | "changes";
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
onClose,
|
||||
featureDescription,
|
||||
featureId,
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
||||
const [projectPath, setProjectPath] = useState<string>("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Load existing output from file
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const loadOutput = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get current project path from store (we'll need to pass this)
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
setProjectPath(currentProject.path);
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(
|
||||
currentProject.path,
|
||||
featureId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setOutput(result.content || "");
|
||||
} else {
|
||||
setOutput("");
|
||||
}
|
||||
} else {
|
||||
setOutput("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load output:", error);
|
||||
setOutput("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ("featureId" in event && event.featureId !== featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
|
||||
switch (event.type) {
|
||||
case "auto_mode_progress":
|
||||
newContent = event.content || "";
|
||||
break;
|
||||
case "auto_mode_tool":
|
||||
const toolName = event.tool || "Unknown Tool";
|
||||
const toolInput = event.input
|
||||
? JSON.stringify(event.input, null, 2)
|
||||
: "";
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||
toolInput ? `Input: ${toolInput}\n` : ""
|
||||
}`;
|
||||
break;
|
||||
case "auto_mode_phase":
|
||||
const phaseEmoji =
|
||||
event.phase === "planning"
|
||||
? "📋"
|
||||
: event.phase === "action"
|
||||
? "⚡"
|
||||
: "✅";
|
||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||
break;
|
||||
case "auto_mode_error":
|
||||
newContent = `\n❌ Error: ${event.error}\n`;
|
||||
break;
|
||||
case "auto_mode_ultrathink_preparation":
|
||||
// Format thinking level preparation information
|
||||
let prepContent = `\n🧠 Ultrathink Preparation\n`;
|
||||
|
||||
if (event.warnings && event.warnings.length > 0) {
|
||||
prepContent += `\n⚠️ Warnings:\n`;
|
||||
event.warnings.forEach((warning: string) => {
|
||||
prepContent += ` • ${warning}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.recommendations && event.recommendations.length > 0) {
|
||||
prepContent += `\n💡 Recommendations:\n`;
|
||||
event.recommendations.forEach((rec: string) => {
|
||||
prepContent += ` • ${rec}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.estimatedCost !== undefined) {
|
||||
prepContent += `\n💰 Estimated Cost: ~$${event.estimatedCost.toFixed(
|
||||
2
|
||||
)} per execution\n`;
|
||||
}
|
||||
|
||||
if (event.estimatedTime) {
|
||||
prepContent += `\n⏱️ Estimated Time: ${event.estimatedTime}\n`;
|
||||
}
|
||||
|
||||
newContent = prepContent;
|
||||
break;
|
||||
case "planning_started":
|
||||
// Show when planning mode begins
|
||||
if ("mode" in event && "message" in event) {
|
||||
const modeLabel =
|
||||
event.mode === "lite"
|
||||
? "Lite"
|
||||
: event.mode === "spec"
|
||||
? "Spec"
|
||||
: "Full";
|
||||
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approval_required":
|
||||
// Show when plan requires approval
|
||||
if ("planningMode" in event) {
|
||||
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_approved":
|
||||
// Show when plan is manually approved
|
||||
if ("hasEdits" in event) {
|
||||
newContent = event.hasEdits
|
||||
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
||||
: `\n✅ Plan approved - continuing to implementation...\n`;
|
||||
}
|
||||
break;
|
||||
case "plan_auto_approved":
|
||||
// Show when plan is auto-approved
|
||||
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
||||
break;
|
||||
case "plan_revision_requested":
|
||||
// Show when user requests plan revision
|
||||
if ("planVersion" in event) {
|
||||
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
||||
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_started":
|
||||
// Show when a task starts
|
||||
if ("taskId" in event && "taskDescription" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
||||
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_task_complete":
|
||||
// Show task completion progress
|
||||
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
|
||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
||||
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_phase_complete":
|
||||
// Show phase completion for full mode
|
||||
if ("phaseNumber" in event) {
|
||||
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
||||
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
||||
}
|
||||
break;
|
||||
case "auto_mode_feature_complete":
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
|
||||
// Close the modal when the feature is verified (passes = true)
|
||||
if (event.passes) {
|
||||
// Small delay to show the completion message before closing
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
// Only update local state - server is the single source of truth for file writes
|
||||
setOutput((prev) => prev + newContent);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, featureId]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
autoScrollRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
// Handle number key presses while modal is open
|
||||
useEffect(() => {
|
||||
if (!open || !onNumberKeyPress) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check if a number key (0-9) was pressed without modifiers
|
||||
if (
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
/^[0-9]$/.test(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
onNumberKeyPress(event.key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onNumberKeyPress]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
||||
data-testid="agent-output-modal"
|
||||
>
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== "verified" &&
|
||||
featureStatus !== "waiting_approval" && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
)}
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("parsed")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "parsed"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("changes")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "changes"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-changes"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("raw")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "raw"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-raw"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription
|
||||
className="mt-1 max-h-24 overflow-y-auto break-words"
|
||||
data-testid="agent-output-description"
|
||||
>
|
||||
{featureDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={projectPath}
|
||||
className="flex-shrink-0 mx-1"
|
||||
/>
|
||||
|
||||
{viewMode === "changes" ? (
|
||||
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
featureId={featureId}
|
||||
compact={false}
|
||||
useWorktrees={useWorktrees}
|
||||
className="border-0 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Archive } from "lucide-react";
|
||||
|
||||
interface ArchiveAllVerifiedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
verifiedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ArchiveAllVerifiedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
verifiedCount,
|
||||
onConfirm,
|
||||
}: ArchiveAllVerifiedDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="archive-all-verified-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive All Verified Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to archive all verified features? They will be
|
||||
moved to the archive box.
|
||||
{verifiedCount > 0 && (
|
||||
<span className="block mt-2 text-yellow-500">
|
||||
{verifiedCount} feature(s) will be archived.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Archive All
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
|
||||
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,103 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ArchiveRestore, Trash2 } from "lucide-react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
interface CompletedFeaturesModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
completedFeatures: Feature[];
|
||||
onUnarchive: (feature: Feature) => void;
|
||||
onDelete: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export function CompletedFeaturesModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
completedFeatures,
|
||||
onUnarchive,
|
||||
onDelete,
|
||||
}: CompletedFeaturesModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-6xl max-h-[90vh] flex flex-col"
|
||||
data-testid="completed-features-modal"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Completed Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
{completedFeatures.length === 0
|
||||
? "No completed features yet."
|
||||
: `${completedFeatures.length} completed feature${
|
||||
completedFeatures.length > 1 ? "s" : ""
|
||||
}`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{completedFeatures.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<ArchiveRestore className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No completed features</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{completedFeatures.map((feature) => (
|
||||
<Card
|
||||
key={feature.id}
|
||||
className="flex flex-col"
|
||||
data-testid={`completed-card-${feature.id}`}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2 flex-1">
|
||||
<CardTitle className="text-sm leading-tight line-clamp-3">
|
||||
{feature.description || feature.summary || feature.id}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1 truncate">
|
||||
{feature.category || "Uncategorized"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="p-3 pt-0 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={() => onUnarchive(feature)}
|
||||
data-testid={`unarchive-${feature.id}`}
|
||||
>
|
||||
<ArchiveRestore className="w-3 h-3 mr-1" />
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(feature)}
|
||||
data-testid={`delete-completed-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
|
||||
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,405 @@
|
||||
|
||||
import { useState, useEffect, useRef } 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;
|
||||
projectPath: string | null;
|
||||
onCreated: (prUrl?: string) => void;
|
||||
}
|
||||
|
||||
export function CreatePRDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
projectPath,
|
||||
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);
|
||||
// Track whether an operation completed that warrants a refresh
|
||||
const operationCompletedRef = useRef(false);
|
||||
|
||||
// Reset state when dialog opens or worktree changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Reset form fields
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
// Also reset result states when opening for a new worktree
|
||||
// This prevents showing stale PR URLs from previous worktrees
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
// Reset operation tracking
|
||||
operationCompletedRef.current = false;
|
||||
} else {
|
||||
// Reset everything when dialog closes
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setCommitMessage("");
|
||||
setBaseBranch("main");
|
||||
setIsDraft(false);
|
||||
setError(null);
|
||||
setPrUrl(null);
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
operationCompletedRef.current = 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, {
|
||||
projectPath: projectPath || undefined,
|
||||
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);
|
||||
// Mark operation as completed for refresh on close
|
||||
operationCompletedRef.current = true;
|
||||
|
||||
// Show different message based on whether PR already existed
|
||||
if (result.result.prAlreadyExisted) {
|
||||
toast.success("Pull request found!", {
|
||||
description: `PR already exists for ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.success("Pull request created!", {
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Don't call onCreated() here - keep dialog open to show success message
|
||||
// onCreated() will be called when user closes the dialog
|
||||
} 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);
|
||||
// Mark operation as completed - branch was pushed successfully
|
||||
operationCompletedRef.current = 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);
|
||||
// Mark operation as completed - branch was pushed even though PR creation failed
|
||||
operationCompletedRef.current = 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 = () => {
|
||||
// Only call onCreated() if an actual operation completed
|
||||
// This prevents unnecessary refreshes when user cancels
|
||||
if (operationCompletedRef.current) {
|
||||
// Pass the PR URL if one was created
|
||||
onCreated(prUrl || undefined);
|
||||
}
|
||||
onOpenChange(false);
|
||||
// State reset is handled by useEffect when open becomes false
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
onClick={() => window.open(prUrl, "_blank")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Pull Request
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</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,170 @@
|
||||
|
||||
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,53 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DeleteAllVerifiedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
verifiedCount: number;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteAllVerifiedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
verifiedCount,
|
||||
onConfirm,
|
||||
}: DeleteAllVerifiedDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="delete-all-verified-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Verified Features</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete all verified features? This action
|
||||
cannot be undone.
|
||||
{verifiedCount > 0 && (
|
||||
<span className="block mt-2 text-yellow-500">
|
||||
{verifiedCount} feature(s) will be deleted.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-all-verified">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete All
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
interface DeleteCompletedFeatureDialogProps {
|
||||
feature: Feature | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteCompletedFeatureDialog({
|
||||
feature,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteCompletedFeatureDialogProps) {
|
||||
if (!feature) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!feature} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent data-testid="delete-completed-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Delete Feature
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to permanently delete this feature?
|
||||
<span className="block mt-2 font-medium text-foreground">
|
||||
"{feature.description?.slice(0, 100)}
|
||||
{(feature.description?.length ?? 0) > 100 ? "..." : ""}"
|
||||
</span>
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
data-testid="cancel-delete-completed-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
data-testid="confirm-delete-completed-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
|
||||
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, FileWarning } 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;
|
||||
/** Number of features assigned to this worktree's branch */
|
||||
affectedFeatureCount?: number;
|
||||
}
|
||||
|
||||
export function DeleteWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onDeleted,
|
||||
affectedFeatureCount = 0,
|
||||
}: 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>
|
||||
|
||||
{affectedFeatureCount > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20 mt-2">
|
||||
<FileWarning className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "}
|
||||
{affectedFeatureCount !== 1 ? "are" : "is"} assigned to this
|
||||
branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be
|
||||
unassigned and moved to the main worktree.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted
|
||||
change(s). 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DependencyTreeDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
feature: Feature | null;
|
||||
allFeatures: Feature[];
|
||||
}
|
||||
|
||||
export function DependencyTreeDialog({
|
||||
open,
|
||||
onClose,
|
||||
feature,
|
||||
allFeatures,
|
||||
}: DependencyTreeDialogProps) {
|
||||
const [dependencyTree, setDependencyTree] = useState<{
|
||||
dependencies: Feature[];
|
||||
dependents: Feature[];
|
||||
}>({ dependencies: [], dependents: [] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature) return;
|
||||
|
||||
// Find features this depends on
|
||||
const dependencies = (feature.dependencies || [])
|
||||
.map((depId) => allFeatures.find((f) => f.id === depId))
|
||||
.filter((f): f is Feature => f !== undefined);
|
||||
|
||||
// Find features that depend on this one
|
||||
const dependents = allFeatures.filter((f) =>
|
||||
f.dependencies?.includes(feature.id)
|
||||
);
|
||||
|
||||
setDependencyTree({ dependencies, dependents });
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
if (!feature) return null;
|
||||
|
||||
const getStatusIcon = (status: Feature["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "verified":
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case "in_progress":
|
||||
case "waiting_approval":
|
||||
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority?: number) => {
|
||||
if (!priority) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded font-medium",
|
||||
priority === 1 && "bg-red-500/20 text-red-500",
|
||||
priority === 2 && "bg-yellow-500/20 text-yellow-500",
|
||||
priority === 3 && "bg-blue-500/20 text-blue-500"
|
||||
)}
|
||||
>
|
||||
P{priority}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dependency Tree</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Current Feature */}
|
||||
<div className="border-2 border-primary rounded-lg p-4 bg-primary/5">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(feature.status)}
|
||||
<h3 className="font-semibold text-sm">Current Feature</h3>
|
||||
{getPriorityBadge(feature.priority)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">
|
||||
Category: {feature.category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dependencies (what this feature needs) */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="font-semibold text-sm">
|
||||
Dependencies ({dependencyTree.dependencies.length})
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
This feature requires:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dependencyTree.dependencies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
|
||||
No dependencies - this feature can be started independently
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dependencyTree.dependencies.map((dep) => (
|
||||
<div
|
||||
key={dep.id}
|
||||
className={cn(
|
||||
"border rounded-lg p-3 transition-colors",
|
||||
dep.status === "completed" || dep.status === "verified"
|
||||
? "bg-green-500/5 border-green-500/20"
|
||||
: "bg-muted/30 border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{getStatusIcon(dep.status)}
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{dep.description.slice(0, 100)}
|
||||
{dep.description.length > 100 && "..."}
|
||||
</span>
|
||||
{getPriorityBadge(dep.priority)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-7">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dep.category}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
dep.status === "completed" || dep.status === "verified"
|
||||
? "bg-green-500/20 text-green-600"
|
||||
: dep.status === "in_progress"
|
||||
? "bg-blue-500/20 text-blue-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{dep.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dependents (what depends on this feature) */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="font-semibold text-sm">
|
||||
Dependents ({dependencyTree.dependents.length})
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Features blocked by this:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dependencyTree.dependents.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
|
||||
No dependents - no other features are waiting on this one
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dependencyTree.dependents.map((dependent) => (
|
||||
<div
|
||||
key={dependent.id}
|
||||
className="border rounded-lg p-3 bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{getStatusIcon(dependent.status)}
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{dependent.description.slice(0, 100)}
|
||||
{dependent.description.length > 100 && "..."}
|
||||
</span>
|
||||
{getPriorityBadge(dependent.priority)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-7">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dependent.category}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
dependent.status === "completed" ||
|
||||
dependent.status === "verified"
|
||||
? "bg-green-500/20 text-green-600"
|
||||
: dependent.status === "in_progress"
|
||||
? "bg-blue-500/20 text-blue-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{dependent.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning for incomplete dependencies */}
|
||||
{dependencyTree.dependencies.some(
|
||||
(d) => d.status !== "completed" && d.status !== "verified"
|
||||
) && (
|
||||
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-700 dark:text-yellow-500">
|
||||
Incomplete Dependencies
|
||||
</p>
|
||||
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
This feature has dependencies that aren't completed yet.
|
||||
Consider completing them first for a smoother implementation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
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 {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
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";
|
||||
import {
|
||||
Feature,
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
AIProfile,
|
||||
useAppStore,
|
||||
PlanningMode,
|
||||
} from "@/store/app-store";
|
||||
import {
|
||||
ModelSelector,
|
||||
ThinkingLevelSelector,
|
||||
ProfileQuickSelect,
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
BranchSelector,
|
||||
PlanningModeSelector,
|
||||
} from "../shared";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DependencyTreeDialog } from "./dependency-tree-dialog";
|
||||
|
||||
interface EditFeatureDialogProps {
|
||||
feature: Feature | null;
|
||||
onClose: () => void;
|
||||
onUpdate: (
|
||||
featureId: string,
|
||||
updates: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string; // Can be empty string to use current branch
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}
|
||||
) => void;
|
||||
categorySuggestions: string[];
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
currentBranch?: string;
|
||||
isMaximized: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
aiProfiles: AIProfile[];
|
||||
allFeatures: Feature[];
|
||||
}
|
||||
|
||||
export function EditFeatureDialog({
|
||||
feature,
|
||||
onClose,
|
||||
onUpdate,
|
||||
categorySuggestions,
|
||||
branchSuggestions,
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
isMaximized,
|
||||
showProfilesOnly,
|
||||
aiProfiles,
|
||||
allFeatures,
|
||||
}: EditFeatureDialogProps) {
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
||||
// If feature has no branchName, default to using current branch
|
||||
return !feature?.branchName;
|
||||
});
|
||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
||||
useState<ImagePreviewMap>(() => new Map());
|
||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||
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 and worktrees setting from store
|
||||
const { enhancementModel, useWorktrees } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
setPlanningMode(feature.planningMode ?? 'skip');
|
||||
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
||||
// If feature has no branchName, default to using current branch
|
||||
setUseCurrentBranch(!feature.branchName);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setShowEditAdvancedOptions(false);
|
||||
}
|
||||
}, [feature]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!editingFeature) return;
|
||||
|
||||
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||
const isBranchSelectorEnabled = editingFeature.status === "backlog";
|
||||
if (
|
||||
useWorktrees &&
|
||||
isBranchSelectorEnabled &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
) {
|
||||
toast.error("Please select a branch name");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
||||
selectedModel
|
||||
)
|
||||
? editingFeature.thinkingLevel ?? "none"
|
||||
: "none";
|
||||
|
||||
// Use current branch if toggle is on
|
||||
// If currentBranch is provided (non-primary worktree), use it
|
||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||
const finalBranchName = useCurrentBranch
|
||||
? (currentBranch || "")
|
||||
: editingFeature.branchName || "";
|
||||
|
||||
const updates = {
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
steps: editingFeature.steps,
|
||||
skipTests: editingFeature.skipTests ?? false,
|
||||
model: selectedModel,
|
||||
thinkingLevel: normalizedThinking,
|
||||
imagePaths: editingFeature.imagePaths ?? [],
|
||||
branchName: finalBranchName,
|
||||
priority: editingFeature.priority ?? 2,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
};
|
||||
|
||||
onUpdate(editingFeature.id, updates);
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setShowEditAdvancedOptions(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = (model: AgentModel) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel: modelSupportsThinking(model)
|
||||
? editingFeature.thinkingLevel
|
||||
: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileSelect = (
|
||||
model: AgentModel,
|
||||
thinkingLevel: ThinkingLevel
|
||||
) => {
|
||||
if (!editingFeature) return;
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
model,
|
||||
thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnhanceDescription = async () => {
|
||||
if (!editingFeature?.description.trim() || isEnhancing) return;
|
||||
|
||||
setIsEnhancing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.enhancePrompt?.enhance(
|
||||
editingFeature.description,
|
||||
enhancementMode,
|
||||
enhancementModel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
const enhancedText = result.enhancedText;
|
||||
setEditingFeature((prev) =>
|
||||
prev ? { ...prev, description: enhancedText } : prev
|
||||
);
|
||||
toast.success("Description enhanced!");
|
||||
} else {
|
||||
toast.error(result?.error || "Failed to enhance description");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Enhancement failed:", error);
|
||||
toast.error("Failed to enhance description");
|
||||
} finally {
|
||||
setIsEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
|
||||
|
||||
if (!editingFeature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="edit-feature-dialog"
|
||||
onPointerDownOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-testid="category-autocomplete-list"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs
|
||||
defaultValue="prompt"
|
||||
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Prompt
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model" data-testid="edit-tab-model">
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" data-testid="edit-tab-options">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Prompt Tab */}
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="space-y-4 overflow-y-auto cursor-default"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<DescriptionImageDropZone
|
||||
value={editingFeature.description}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
description: value,
|
||||
})
|
||||
}
|
||||
images={editingFeature.imagePaths ?? []}
|
||||
onImagesChange={(images) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
imagePaths: images,
|
||||
})
|
||||
}
|
||||
placeholder="Describe the feature..."
|
||||
previewMap={editFeaturePreviewMap}
|
||||
onPreviewMapChange={setEditFeaturePreviewMap}
|
||||
data-testid="edit-feature-description"
|
||||
/>
|
||||
</div>
|
||||
<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"}
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("improve")}
|
||||
>
|
||||
Improve Clarity
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("technical")}
|
||||
>
|
||||
Add Technical Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("simplify")}
|
||||
>
|
||||
Simplify
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnhancementMode("acceptance")}
|
||||
>
|
||||
Add Acceptance Criteria
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnhanceDescription}
|
||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
||||
loading={isEnhancing}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Enhance with AI
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category">Category (optional)</Label>
|
||||
<CategoryAutocomplete
|
||||
value={editingFeature.category}
|
||||
onChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
category: value,
|
||||
})
|
||||
}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder="e.g., Core, UI, API"
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
</div>
|
||||
{useWorktrees && (
|
||||
<BranchSelector
|
||||
useCurrentBranch={useCurrentBranch}
|
||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||
branchName={editingFeature.branchName ?? ""}
|
||||
onBranchNameChange={(value) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
branchName: value,
|
||||
})
|
||||
}
|
||||
branchSuggestions={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
currentBranch={currentBranch}
|
||||
disabled={editingFeature.status !== "backlog"}
|
||||
testIdPrefix="edit-feature"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Priority Selector */}
|
||||
<PrioritySelector
|
||||
selectedPriority={editingFeature.priority ?? 2}
|
||||
onPrioritySelect={(priority) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
priority,
|
||||
})
|
||||
}
|
||||
testIdPrefix="edit-priority"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Tab */}
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Simple Mode Active
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
||||
}
|
||||
data-testid="edit-show-advanced-options-toggle"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Select Profile Section */}
|
||||
<ProfileQuickSelect
|
||||
profiles={aiProfiles}
|
||||
selectedModel={editingFeature.model ?? "opus"}
|
||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
|
||||
onSelect={handleProfileSelect}
|
||||
testIdPrefix="edit-profile-quick-select"
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
{aiProfiles.length > 0 &&
|
||||
(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<div className="border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* Claude Models Section */}
|
||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
testIdPrefix="edit-model-select"
|
||||
/>
|
||||
{editModelAllowsThinking && (
|
||||
<ThinkingLevelSelector
|
||||
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
||||
onLevelSelect={(level) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
thinkingLevel: level,
|
||||
})
|
||||
}
|
||||
testIdPrefix="edit-thinking-level"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
||||
{/* Planning Mode Section */}
|
||||
<PlanningModeSelector
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
requireApproval={requirePlanApproval}
|
||||
onRequireApprovalChange={setRequirePlanApproval}
|
||||
featureDescription={editingFeature.description}
|
||||
testIdPrefix="edit-feature"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="border-t border-border my-4" />
|
||||
|
||||
{/* Testing Section */}
|
||||
<TestingTabContent
|
||||
skipTests={editingFeature.skipTests ?? false}
|
||||
onSkipTestsChange={(skipTests) =>
|
||||
setEditingFeature({ ...editingFeature, skipTests })
|
||||
}
|
||||
steps={editingFeature.steps}
|
||||
onStepsChange={(steps) =>
|
||||
setEditingFeature({ ...editingFeature, steps })
|
||||
}
|
||||
testIdPrefix="edit"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter className="sm:!justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDependencyTree(true)}
|
||||
className="gap-2 h-10"
|
||||
>
|
||||
<GitBranch className="w-4 h-4" />
|
||||
View Dependency Tree
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleUpdate}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={!!editingFeature}
|
||||
data-testid="confirm-edit-feature"
|
||||
disabled={
|
||||
useWorktrees &&
|
||||
editingFeature.status === "backlog" &&
|
||||
!useCurrentBranch &&
|
||||
!editingFeature.branchName?.trim()
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<DependencyTreeDialog
|
||||
open={showDependencyTree}
|
||||
onClose={() => setShowDependencyTree(false)}
|
||||
feature={editingFeature}
|
||||
allFeatures={allFeatures}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Loader2,
|
||||
Lightbulb,
|
||||
Download,
|
||||
StopCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FeatureSuggestionsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectPath: string;
|
||||
// Props to persist state across dialog open/close
|
||||
suggestions: FeatureSuggestion[];
|
||||
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
|
||||
isGenerating: boolean;
|
||||
setIsGenerating: (generating: boolean) => void;
|
||||
}
|
||||
|
||||
// Configuration for each suggestion type
|
||||
const suggestionTypeConfig: Record<SuggestionType, {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
description: string;
|
||||
color: string;
|
||||
}> = {
|
||||
features: {
|
||||
label: "Feature Suggestions",
|
||||
icon: Lightbulb,
|
||||
description: "Discover missing features and improvements",
|
||||
color: "text-yellow-500",
|
||||
},
|
||||
refactoring: {
|
||||
label: "Refactoring Suggestions",
|
||||
icon: RefreshCw,
|
||||
description: "Find code smells and refactoring opportunities",
|
||||
color: "text-blue-500",
|
||||
},
|
||||
security: {
|
||||
label: "Security Suggestions",
|
||||
icon: Shield,
|
||||
description: "Identify security vulnerabilities and issues",
|
||||
color: "text-red-500",
|
||||
},
|
||||
performance: {
|
||||
label: "Performance Suggestions",
|
||||
icon: Zap,
|
||||
description: "Discover performance bottlenecks and optimizations",
|
||||
color: "text-green-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function FeatureSuggestionsDialog({
|
||||
open,
|
||||
onClose,
|
||||
projectPath,
|
||||
suggestions,
|
||||
setSuggestions,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
}: FeatureSuggestionsDialogProps) {
|
||||
const [progress, setProgress] = useState<string[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const { features, setFeatures } = useAppStore();
|
||||
|
||||
// Initialize selectedIds when suggestions change
|
||||
useEffect(() => {
|
||||
if (suggestions.length > 0 && selectedIds.size === 0) {
|
||||
setSelectedIds(new Set(suggestions.map((s) => s.id)));
|
||||
}
|
||||
}, [suggestions, selectedIds.size]);
|
||||
|
||||
// Auto-scroll progress when new content arrives
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current && isGenerating) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [progress, isGenerating]);
|
||||
|
||||
// Listen for suggestion events when dialog is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.suggestions) return;
|
||||
|
||||
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
|
||||
if (event.type === "suggestions_progress") {
|
||||
setProgress((prev) => [...prev, event.content || ""]);
|
||||
} else if (event.type === "suggestions_tool") {
|
||||
const toolName = event.tool || "Unknown Tool";
|
||||
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
|
||||
} else if (event.type === "suggestions_complete") {
|
||||
setIsGenerating(false);
|
||||
if (event.suggestions && event.suggestions.length > 0) {
|
||||
setSuggestions(event.suggestions);
|
||||
// Select all by default
|
||||
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
|
||||
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
|
||||
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
|
||||
} else {
|
||||
toast.info("No suggestions generated. Try again.");
|
||||
}
|
||||
} else if (event.type === "suggestions_error") {
|
||||
setIsGenerating(false);
|
||||
toast.error(`Error: ${event.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
|
||||
|
||||
// Start generating suggestions for a specific type
|
||||
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.suggestions) {
|
||||
toast.error("Suggestions API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setProgress([]);
|
||||
setSuggestions([]);
|
||||
setSelectedIds(new Set());
|
||||
setCurrentSuggestionType(suggestionType);
|
||||
|
||||
try {
|
||||
const result = await api.suggestions.generate(projectPath, suggestionType);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Failed to start generation");
|
||||
setIsGenerating(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate suggestions:", error);
|
||||
toast.error("Failed to start generation");
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [projectPath, setIsGenerating, setSuggestions]);
|
||||
|
||||
// Stop generating
|
||||
const handleStop = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.suggestions) return;
|
||||
|
||||
try {
|
||||
await api.suggestions.stop();
|
||||
setIsGenerating(false);
|
||||
toast.info("Generation stopped");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop generation:", error);
|
||||
}
|
||||
}, [setIsGenerating]);
|
||||
|
||||
// Toggle suggestion selection
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle expand/collapse for a suggestion
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select/deselect all
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedIds.size === suggestions.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(suggestions.map((s) => s.id)));
|
||||
}
|
||||
}, [selectedIds.size, suggestions]);
|
||||
|
||||
// Import selected suggestions as features
|
||||
const handleImport = useCallback(async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.warning("No suggestions selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const selectedSuggestions = suggestions.filter((s) =>
|
||||
selectedIds.has(s.id)
|
||||
);
|
||||
|
||||
// Create new features from selected suggestions
|
||||
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
|
||||
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
category: s.category,
|
||||
description: s.description,
|
||||
steps: s.steps,
|
||||
status: "backlog" as const,
|
||||
skipTests: true, // As specified, testing mode true
|
||||
priority: s.priority, // Preserve priority from suggestion
|
||||
}));
|
||||
|
||||
// Create each new feature using the features API
|
||||
if (api.features) {
|
||||
for (const feature of newFeatures) {
|
||||
await api.features.create(projectPath, feature);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing features for store update
|
||||
const updatedFeatures = [...features, ...newFeatures];
|
||||
|
||||
// Update store
|
||||
setFeatures(updatedFeatures);
|
||||
|
||||
toast.success(`Imported ${newFeatures.length} features to backlog!`);
|
||||
|
||||
// Clear suggestions after importing
|
||||
setSuggestions([]);
|
||||
setSelectedIds(new Set());
|
||||
setProgress([]);
|
||||
setCurrentSuggestionType(null);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import features:", error);
|
||||
toast.error("Failed to import features");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
autoScrollRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
// Go back to type selection
|
||||
const handleBackToSelection = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setSelectedIds(new Set());
|
||||
setProgress([]);
|
||||
setCurrentSuggestionType(null);
|
||||
}, [setSuggestions]);
|
||||
|
||||
const hasStarted = progress.length > 0 || suggestions.length > 0;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
|
||||
data-testid="feature-suggestions-dialog"
|
||||
>
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{currentConfig ? (
|
||||
<>
|
||||
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
|
||||
{currentConfig.label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lightbulb className="w-5 h-5 text-yellow-500" />
|
||||
AI Suggestions
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentConfig
|
||||
? currentConfig.description
|
||||
: "Analyze your project to discover improvements. Choose a suggestion type below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasStarted ? (
|
||||
// Initial state - show suggestion type buttons
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-8">
|
||||
<p className="text-muted-foreground text-center max-w-lg mb-8">
|
||||
Our AI will analyze your project and generate actionable suggestions.
|
||||
Choose what type of analysis you want to perform:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
|
||||
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
|
||||
([type, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
|
||||
onClick={() => handleGenerate(type)}
|
||||
data-testid={`generate-${type}-btn`}
|
||||
>
|
||||
<Icon className={`w-8 h-8 ${config.color}`} />
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{config.description}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isGenerating ? (
|
||||
// Generating state - show progress
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Analyzing project...
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" onClick={handleStop}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{progress.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasSuggestions ? (
|
||||
// Results state - show suggestions list
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{suggestions.length} suggestions generated
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
|
||||
{selectedIds.size === suggestions.length
|
||||
? "Deselect All"
|
||||
: "Select All"}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
|
||||
>
|
||||
{suggestions.map((suggestion) => {
|
||||
const isSelected = selectedIds.has(suggestion.id);
|
||||
const isExpanded = expandedIds.has(suggestion.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className={`border rounded-lg p-3 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
data-testid={`suggestion-${suggestion.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={suggestion.id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(suggestion.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
onClick={() => toggleExpanded(suggestion.id)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
|
||||
#{suggestion.priority}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
|
||||
{suggestion.category}
|
||||
</span>
|
||||
</div>
|
||||
<Label
|
||||
htmlFor={suggestion.id}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{suggestion.description}
|
||||
</Label>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{suggestion.reasoning && (
|
||||
<p className="text-muted-foreground italic">
|
||||
{suggestion.reasoning}
|
||||
</p>
|
||||
)}
|
||||
{suggestion.steps.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Implementation Steps:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
|
||||
{suggestion.steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No results state
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No suggestions were generated. Try running the analysis again.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBackToSelection}>
|
||||
Back to Selection
|
||||
</Button>
|
||||
{currentSuggestionType && (
|
||||
<Button onClick={() => handleGenerate(currentSuggestionType)}>
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
{hasSuggestions && (
|
||||
<div className="flex gap-2 w-full justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBackToSelection}>
|
||||
Back
|
||||
</Button>
|
||||
{currentSuggestionType && (
|
||||
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
|
||||
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
|
||||
Regenerate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleImport}
|
||||
disabled={selectedIds.size === 0 || isImporting}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open && hasSuggestions}
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Import {selectedIds.size} Feature
|
||||
{selectedIds.size !== 1 ? "s" : ""}
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasSuggestions && !isGenerating && hasStarted && (
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
interface FollowUpDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feature: Feature | null;
|
||||
prompt: string;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
previewMap: ImagePreviewMap;
|
||||
onPromptChange: (prompt: string) => void;
|
||||
onImagePathsChange: (paths: DescriptionImagePath[]) => void;
|
||||
onPreviewMapChange: (map: ImagePreviewMap) => void;
|
||||
onSend: () => void;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
export function FollowUpDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feature,
|
||||
prompt,
|
||||
imagePaths,
|
||||
previewMap,
|
||||
onPromptChange,
|
||||
onImagePathsChange,
|
||||
onPreviewMapChange,
|
||||
onSend,
|
||||
isMaximized,
|
||||
}: FollowUpDialogProps) {
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
compact={!isMaximized}
|
||||
data-testid="follow-up-dialog"
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Follow-Up Prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send additional instructions to continue working on this feature.
|
||||
{feature && (
|
||||
<span className="block mt-2 text-primary">
|
||||
Feature: {feature.description.slice(0, 100)}
|
||||
{feature.description.length > 100 ? "..." : ""}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="follow-up-prompt">Instructions</Label>
|
||||
<DescriptionImageDropZone
|
||||
value={prompt}
|
||||
onChange={onPromptChange}
|
||||
images={imagePaths}
|
||||
onImagesChange={onImagePathsChange}
|
||||
placeholder="Describe what needs to be fixed or changed..."
|
||||
previewMap={previewMap}
|
||||
onPreviewMapChange={onPreviewMapChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The agent will continue from where it left off, using the existing
|
||||
context. You can attach screenshots to help explain the issue.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={onSend}
|
||||
disabled={!prompt.trim()}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-follow-up"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Send Follow-Up
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
9
apps/ui/src/components/views/board-view/dialogs/index.ts
Normal file
9
apps/ui/src/components/views/board-view/dialogs/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||
export { AgentOutputModal } from "./agent-output-modal";
|
||||
export { CompletedFeaturesModal } from "./completed-features-modal";
|
||||
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
|
||||
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
|
||||
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||
export { FollowUpDialog } from "./follow-up-dialog";
|
||||
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
||||
@@ -0,0 +1,220 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
|
||||
|
||||
interface PlanApprovalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feature: Feature | null;
|
||||
planContent: string;
|
||||
onApprove: (editedPlan?: string) => void;
|
||||
onReject: (feedback?: string) => void;
|
||||
isLoading?: boolean;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PlanApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feature,
|
||||
planContent,
|
||||
onApprove,
|
||||
onReject,
|
||||
isLoading = false,
|
||||
viewOnly = false,
|
||||
}: PlanApprovalDialogProps) {
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||
const [rejectFeedback, setRejectFeedback] = useState("");
|
||||
|
||||
// Reset state when dialog opens or plan content changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEditedPlan(planContent);
|
||||
setIsEditMode(false);
|
||||
setShowRejectFeedback(false);
|
||||
setRejectFeedback("");
|
||||
}
|
||||
}, [open, planContent]);
|
||||
|
||||
const handleApprove = () => {
|
||||
// Only pass edited plan if it was modified
|
||||
const wasEdited = editedPlan !== planContent;
|
||||
onApprove(wasEdited ? editedPlan : undefined);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (showRejectFeedback) {
|
||||
onReject(rejectFeedback.trim() || undefined);
|
||||
} else {
|
||||
setShowRejectFeedback(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelReject = () => {
|
||||
setShowRejectFeedback(false);
|
||||
setRejectFeedback("");
|
||||
};
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open && !isLoading) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className="max-w-4xl"
|
||||
data-testid="plan-approval-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewOnly
|
||||
? "View the generated plan for this feature."
|
||||
: "Review the generated plan before implementation begins."}
|
||||
{feature && (
|
||||
<span className="block mt-2 text-primary">
|
||||
Feature: {feature.description.slice(0, 150)}
|
||||
{feature.description.length > 150 ? "..." : ""}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
{/* Mode Toggle - Only show when not in viewOnly mode */}
|
||||
{!viewOnly && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{isEditMode ? "Edit Mode" : "View Mode"}
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Content */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
|
||||
{isEditMode && !viewOnly ? (
|
||||
<Textarea
|
||||
value={editedPlan}
|
||||
onChange={(e) => setEditedPlan(e.target.value)}
|
||||
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
|
||||
placeholder="Enter plan content..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 overflow-auto">
|
||||
<Markdown>{editedPlan || "No plan content available."}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revision Feedback Section - Only show when not in viewOnly mode */}
|
||||
{showRejectFeedback && !viewOnly && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="reject-feedback">What changes would you like?</Label>
|
||||
<Textarea
|
||||
id="reject-feedback"
|
||||
value={rejectFeedback}
|
||||
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||
placeholder="Describe the changes you'd like to see in the plan..."
|
||||
className="min-h-[80px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to cancel the feature, or provide feedback to regenerate the plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 gap-2">
|
||||
{viewOnly ? (
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : showRejectFeedback ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancelReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
10
apps/ui/src/components/views/board-view/hooks/index.ts
Normal file
10
apps/ui/src/components/views/board-view/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useBoardFeatures } from "./use-board-features";
|
||||
export { useBoardDragDrop } from "./use-board-drag-drop";
|
||||
export { useBoardActions } from "./use-board-actions";
|
||||
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
|
||||
export { useBoardColumnFeatures } from "./use-board-column-features";
|
||||
export { useBoardEffects } from "./use-board-effects";
|
||||
export { useBoardBackground } from "./use-board-background";
|
||||
export { useBoardPersistence } from "./use-board-persistence";
|
||||
export { useFollowUpState } from "./use-follow-up-state";
|
||||
export { useSuggestionsState } from "./use-suggestions-state";
|
||||
@@ -0,0 +1,896 @@
|
||||
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;
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
loadFeatures: () => Promise<void>;
|
||||
persistFeatureCreate: (feature: 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;
|
||||
setShowOutputModal: (show: boolean) => void;
|
||||
setOutputFeature: (feature: Feature | null) => void;
|
||||
followUpFeature: Feature | null;
|
||||
followUpPrompt: string;
|
||||
followUpImagePaths: DescriptionImagePath[];
|
||||
setFollowUpFeature: (feature: Feature | null) => void;
|
||||
setFollowUpPrompt: (prompt: string) => void;
|
||||
setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void;
|
||||
setFollowUpPreviewMap: (map: Map<string, string>) => void;
|
||||
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({
|
||||
currentProject,
|
||||
features,
|
||||
runningAutoTasks,
|
||||
loadFeatures,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
persistFeatureDelete,
|
||||
saveCategory,
|
||||
setEditingFeature,
|
||||
setShowOutputModal,
|
||||
setOutputFeature,
|
||||
followUpFeature,
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
setShowFollowUpDialog,
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const {
|
||||
addFeature,
|
||||
updateFeature,
|
||||
removeFeature,
|
||||
moveFeature,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// Worktrees are created when adding/editing features with a branch name
|
||||
// This ensures the worktree exists before the feature starts execution
|
||||
|
||||
const handleAddFeature = useCallback(
|
||||
async (featureData: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
images: FeatureImage[];
|
||||
imagePaths: DescriptionImagePath[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
}) => {
|
||||
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
|
||||
// Non-empty string is the actual branch name (for non-primary worktrees)
|
||||
const finalBranchName = featureData.branchName || undefined;
|
||||
|
||||
// If worktrees enabled and a branch is specified, create the worktree now
|
||||
// This ensures the worktree exists before the feature starts
|
||||
if (useWorktrees && finalBranchName && currentProject) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.create) {
|
||||
const result = await api.worktree.create(
|
||||
currentProject.path,
|
||||
finalBranchName
|
||||
);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||
result.worktree?.isNew ? "created" : "already exists"
|
||||
}`
|
||||
);
|
||||
// Refresh worktree list in UI
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
console.error(
|
||||
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error creating worktree:", error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
status: "backlog" as const,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
const createdFeature = addFeature(newFeatureData);
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
},
|
||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
async (
|
||||
featureId: string,
|
||||
updates: {
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
skipTests: boolean;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
imagePaths: DescriptionImagePath[];
|
||||
branchName: string;
|
||||
priority: number;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
}
|
||||
) => {
|
||||
const finalBranchName = updates.branchName || undefined;
|
||||
|
||||
// If worktrees enabled and a branch is specified, create the worktree now
|
||||
// This ensures the worktree exists before the feature starts
|
||||
if (useWorktrees && finalBranchName && currentProject) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.create) {
|
||||
const result = await api.worktree.create(
|
||||
currentProject.path,
|
||||
finalBranchName
|
||||
);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||
result.worktree?.isNew ? "created" : "already exists"
|
||||
}`
|
||||
);
|
||||
// Refresh worktree list in UI
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
console.error(
|
||||
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||
result.error
|
||||
);
|
||||
toast.error("Failed to create worktree", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error creating worktree:", error);
|
||||
toast.error("Failed to create worktree", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
|
||||
updateFeature(featureId, finalUpdates);
|
||||
persistFeatureUpdate(featureId, finalUpdates);
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
async (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return;
|
||||
|
||||
const isRunning = runningAutoTasks.includes(featureId);
|
||||
|
||||
if (isRunning) {
|
||||
try {
|
||||
await autoMode.stopFeature(featureId);
|
||||
toast.success("Agent stopped", {
|
||||
description: `Stopped and deleted: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature before delete:", error);
|
||||
toast.error("Failed to stop agent", {
|
||||
description: "The feature will still be deleted.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (feature.imagePaths && feature.imagePaths.length > 0) {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
for (const imagePathObj of feature.imagePaths) {
|
||||
try {
|
||||
await api.deleteFile(imagePathObj.path);
|
||||
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Board] Failed to delete image ${imagePathObj.path}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Board] Error deleting images for feature ${featureId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
removeFeature(featureId);
|
||||
persistFeatureDelete(featureId);
|
||||
},
|
||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||
);
|
||||
|
||||
const handleRunFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Server derives workDir from feature.branchName at execution time
|
||||
const result = await api.autoMode.runFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
"[Board] Feature run started successfully, branch:",
|
||||
feature.branchName || "default"
|
||||
);
|
||||
} else {
|
||||
console.error("[Board] Failed to run feature:", result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error running feature:", error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, useWorktrees, loadFeatures]
|
||||
);
|
||||
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!autoMode.canStartNewTask) {
|
||||
toast.error("Concurrency limit reached", {
|
||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||
autoMode.maxConcurrency > 1 ? "s" : ""
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
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);
|
||||
// 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,
|
||||
enableDependencyBlocking,
|
||||
features,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.verifyFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature verification started successfully");
|
||||
} else {
|
||||
console.error("[Board] Failed to verify feature:", result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error verifying feature:", error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures]
|
||||
);
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error("Auto mode API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature resume started successfully");
|
||||
} else {
|
||||
console.error("[Board] Failed to resume feature:", result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error resuming feature:", error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures, useWorktrees]
|
||||
);
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
(feature: Feature) => {
|
||||
moveFeature(feature.id, "verified");
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: "verified",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[moveFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleMoveBackToInProgress = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to In Progress: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleOpenFollowUp = useCallback(
|
||||
(feature: Feature) => {
|
||||
setFollowUpFeature(feature);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpImagePaths([]);
|
||||
setShowFollowUpDialog(true);
|
||||
},
|
||||
[
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setShowFollowUpDialog,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSendFollowUp = useCallback(async () => {
|
||||
if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return;
|
||||
|
||||
const featureId = followUpFeature.id;
|
||||
const featureDescription = followUpFeature.description;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.followUpFeature) {
|
||||
console.error("Follow-up feature API not available");
|
||||
toast.error("Follow-up not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = {
|
||||
status: "in_progress" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
justFinishedAt: undefined,
|
||||
};
|
||||
updateFeature(featureId, updates);
|
||||
persistFeatureUpdate(featureId, updates);
|
||||
|
||||
setShowFollowUpDialog(false);
|
||||
setFollowUpFeature(null);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
|
||||
toast.success("Follow-up started", {
|
||||
description: `Continuing work on: ${truncateDescription(
|
||||
featureDescription
|
||||
)}`,
|
||||
});
|
||||
|
||||
const imagePaths = followUpImagePaths.map((img) => img.path);
|
||||
// Server derives workDir from feature.branchName at execution time
|
||||
api.autoMode
|
||||
.followUpFeature(
|
||||
currentProject.path,
|
||||
followUpFeature.id,
|
||||
followUpPrompt,
|
||||
imagePaths
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
)
|
||||
.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",
|
||||
});
|
||||
loadFeatures();
|
||||
});
|
||||
}, [
|
||||
currentProject,
|
||||
followUpFeature,
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
loadFeatures,
|
||||
]);
|
||||
|
||||
const handleCommitFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
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.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Server derives workDir from feature.branchName
|
||||
const result = await api.autoMode.commitFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
// No worktreePath - server derives from feature.branchName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
moveFeature(feature.id, "verified");
|
||||
persistFeatureUpdate(feature.id, { status: "verified" });
|
||||
toast.success("Feature committed", {
|
||||
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", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error committing feature:", error);
|
||||
toast.error("Failed to commit feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[
|
||||
currentProject,
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
loadFeatures,
|
||||
onWorktreeCreated,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
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.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
)}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to merge feature:", result.error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error merging feature:", error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentProject, loadFeatures]
|
||||
);
|
||||
|
||||
const handleCompleteFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "completed" as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success("Feature completed", {
|
||||
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleUnarchiveFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: "verified" as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
toast.success("Feature restored", {
|
||||
description: `Moved back to verified: ${truncateDescription(
|
||||
feature.description
|
||||
)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleViewOutput = useCallback(
|
||||
(feature: Feature) => {
|
||||
setOutputFeature(feature);
|
||||
setShowOutputModal(true);
|
||||
},
|
||||
[setOutputFeature, setShowOutputModal]
|
||||
);
|
||||
|
||||
const handleOutputModalNumberKeyPress = useCallback(
|
||||
(key: string) => {
|
||||
const index = key === "0" ? 9 : parseInt(key, 10) - 1;
|
||||
const targetFeature = inProgressFeaturesForShortcuts[index];
|
||||
|
||||
if (!targetFeature) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetFeature.id === outputFeature?.id) {
|
||||
setShowOutputModal(false);
|
||||
} else {
|
||||
setOutputFeature(targetFeature);
|
||||
}
|
||||
},
|
||||
[
|
||||
inProgressFeaturesForShortcuts,
|
||||
outputFeature?.id,
|
||||
setShowOutputModal,
|
||||
setOutputFeature,
|
||||
]
|
||||
);
|
||||
|
||||
const handleForceStopFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
|
||||
const targetStatus =
|
||||
feature.skipTests && feature.status === "waiting_approval"
|
||||
? "waiting_approval"
|
||||
: "backlog";
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, 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
|
||||
)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Board] Error stopping feature:", error);
|
||||
toast.error("Failed to stop agent", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
[autoMode, moveFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This ensures "G" only starts features from the filtered list
|
||||
const primaryBranch = projectPath
|
||||
? getPrimaryWorktreeBranch(projectPath)
|
||||
: null;
|
||||
const backlogFeatures = features.filter((f) => {
|
||||
if (f.status !== "backlog") return false;
|
||||
|
||||
// Determine the feature's branch (default to primary branch if not set)
|
||||
const featureBranch = f.branchName || primaryBranch || "main";
|
||||
|
||||
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
|
||||
// show features with no branch or primary branch
|
||||
if (
|
||||
!currentWorktreeBranch ||
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
|
||||
) {
|
||||
return (
|
||||
!f.branchName ||
|
||||
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, only show features matching the selected worktree branch
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const availableSlots =
|
||||
useAppStore.getState().maxConcurrency - runningAutoTasks.length;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast.error("Concurrency limit reached", {
|
||||
description:
|
||||
"Wait for a task to complete or increase the concurrency limit.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
const isOnPrimaryBranch =
|
||||
!currentWorktreeBranch ||
|
||||
(projectPath &&
|
||||
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
|
||||
toast.info("Backlog empty", {
|
||||
description: !isOnPrimaryBranch
|
||||
? `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)
|
||||
// Features with blocking dependencies are sorted to the end
|
||||
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
|
||||
const aBlocked = enableDependencyBlocking
|
||||
? getBlockingDependencies(a, features).length > 0
|
||||
: false;
|
||||
const bBlocked = enableDependencyBlocking
|
||||
? getBlockingDependencies(b, features).length > 0
|
||||
: false;
|
||||
|
||||
// Blocked features go to the end
|
||||
if (aBlocked && !bBlocked) return 1;
|
||||
if (!aBlocked && bBlocked) return -1;
|
||||
|
||||
// Within same blocked/unblocked group, sort by priority
|
||||
return (a.priority || 999) - (b.priority || 999);
|
||||
});
|
||||
|
||||
// Find the first feature without blocking dependencies
|
||||
const featureToStart = sortedBacklog.find((f) => {
|
||||
if (!enableDependencyBlocking) return true;
|
||||
return getBlockingDependencies(f, features).length === 0;
|
||||
});
|
||||
|
||||
if (!featureToStart) {
|
||||
toast.info("No eligible features", {
|
||||
description:
|
||||
"All backlog features have unmet dependencies. Complete their dependencies first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start only one feature per keypress (user must press again for next)
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
await handleStartImplementation(featureToStart);
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
handleStartImplementation,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
]);
|
||||
|
||||
const handleArchiveAllVerified = useCallback(async () => {
|
||||
const verifiedFeatures = features.filter((f) => f.status === "verified");
|
||||
|
||||
for (const feature of verifiedFeatures) {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
if (isRunning) {
|
||||
try {
|
||||
await autoMode.stopFeature(feature.id);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Board] Error stopping feature before archive:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
// Archive the feature by setting status to completed
|
||||
const updates = {
|
||||
status: "completed" as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
}
|
||||
|
||||
toast.success("All verified features archived", {
|
||||
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||
});
|
||||
}, [
|
||||
features,
|
||||
runningAutoTasks,
|
||||
autoMode,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleAddFeature,
|
||||
handleUpdateFeature,
|
||||
handleDeleteFeature,
|
||||
handleStartImplementation,
|
||||
handleVerifyFeature,
|
||||
handleResumeFeature,
|
||||
handleManualVerify,
|
||||
handleMoveBackToInProgress,
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
handleViewOutput,
|
||||
handleOutputModalNumberKeyPress,
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||
|
||||
interface UseBoardBackgroundProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) {
|
||||
const boardBackgroundByProject = useAppStore(
|
||||
(state) => state.boardBackgroundByProject
|
||||
);
|
||||
|
||||
// Get background settings for current project
|
||||
const backgroundSettings = useMemo(() => {
|
||||
return (
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||
defaultBackgroundSettings
|
||||
);
|
||||
}, [currentProject, boardBackgroundByProject]);
|
||||
|
||||
// Build background image style if image exists
|
||||
const backgroundImageStyle = useMemo(() => {
|
||||
if (!backgroundSettings.imagePath || !currentProject) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${
|
||||
import.meta.env.VITE_SERVER_URL || "http://localhost:3008"
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||
backgroundSettings.imageVersion
|
||||
? `&v=${backgroundSettings.imageVersion}`
|
||||
: ""
|
||||
})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
} as React.CSSProperties;
|
||||
}, [backgroundSettings, currentProject]);
|
||||
|
||||
return {
|
||||
backgroundSettings,
|
||||
backgroundImageStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Feature, useAppStore } from "@/store/app-store";
|
||||
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
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(() => {
|
||||
const map: Record<ColumnId, Feature[]> = {
|
||||
backlog: [],
|
||||
in_progress: [],
|
||||
waiting_approval: [],
|
||||
verified: [],
|
||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||
};
|
||||
|
||||
// Filter features by search query (case-insensitive)
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
const filteredFeatures = normalizedQuery
|
||||
? features.filter(
|
||||
(f) =>
|
||||
f.description.toLowerCase().includes(normalizedQuery) ||
|
||||
f.category?.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
: 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 by branchName
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
let matchesWorktree: boolean;
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only on primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
matchesWorktree = isViewingPrimary;
|
||||
} 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 primary worktree's branch.
|
||||
matchesWorktree = projectPath
|
||||
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||
: false;
|
||||
} else {
|
||||
// Match by branch name
|
||||
matchesWorktree = featureBranch === effectiveBranch;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
if (matchesWorktree) {
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Get all features to check blocking dependencies against
|
||||
const allFeatures = features;
|
||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||
|
||||
// Sort blocked features to the end of the backlog
|
||||
// This keeps the dependency order within each group (unblocked/blocked)
|
||||
if (enableDependencyBlocking) {
|
||||
const unblocked: Feature[] = [];
|
||||
const blocked: Feature[] = [];
|
||||
|
||||
for (const f of orderedFeatures) {
|
||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||
blocked.push(f);
|
||||
} else {
|
||||
unblocked.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
map.backlog = [...unblocked, ...blocked];
|
||||
} else {
|
||||
map.backlog = orderedFeatures;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
|
||||
const getColumnFeatures = useCallback(
|
||||
(columnId: ColumnId) => {
|
||||
return columnFeaturesMap[columnId];
|
||||
},
|
||||
[columnFeaturesMap]
|
||||
);
|
||||
|
||||
// Memoize completed features for the archive modal
|
||||
const completedFeatures = useMemo(() => {
|
||||
return features.filter((f) => f.status === "completed");
|
||||
}, [features]);
|
||||
|
||||
return {
|
||||
columnFeaturesMap,
|
||||
getColumnFeatures,
|
||||
completedFeatures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { toast } from "sonner";
|
||||
import { COLUMNS, ColumnId } from "../constants";
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
currentProject: { path: string; id: string } | null;
|
||||
runningAutoTasks: string[];
|
||||
persistFeatureUpdate: (
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => Promise<void>;
|
||||
handleStartImplementation: (feature: Feature) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useBoardDragDrop({
|
||||
features,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const { moveFeature } = useAppStore();
|
||||
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const feature = features.find((f) => f.id === active.id);
|
||||
if (feature) {
|
||||
setActiveFeature(feature);
|
||||
}
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveFeature(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const featureId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Find the feature being dragged
|
||||
const draggedFeature = features.find((f) => f.id === featureId);
|
||||
if (!draggedFeature) return;
|
||||
|
||||
// Check if this is a running task (non-skipTests, TDD)
|
||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
// - verified items can always be dragged (to allow moving back to waiting_approval)
|
||||
// - in_progress items can be dragged (but not if they're currently running)
|
||||
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
|
||||
if (draggedFeature.status === "in_progress") {
|
||||
// Only allow dragging in_progress if it's not currently running
|
||||
if (isRunningTask) {
|
||||
console.log(
|
||||
"[Board] Cannot drag feature - currently running"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
if (column) {
|
||||
targetStatus = column.id;
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetStatus) return;
|
||||
|
||||
// Same column, nothing to do
|
||||
if (targetStatus === draggedFeature.status) return;
|
||||
|
||||
// Handle different drag scenarios
|
||||
// Note: Worktrees are created server-side at execution time based on feature.branchName
|
||||
if (draggedFeature.status === "backlog") {
|
||||
// From backlog
|
||||
if (targetStatus === "in_progress") {
|
||||
// Use helper function to handle concurrency check and start implementation
|
||||
// Server will derive workDir from feature.branchName
|
||||
await handleStartImplementation(draggedFeature);
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||
}
|
||||
} else if (draggedFeature.status === "waiting_approval") {
|
||||
// waiting_approval features can be dragged to verified for manual verification
|
||||
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
||||
// features often have skipTests=true, and we want status-based handling first
|
||||
if (targetStatus === "verified") {
|
||||
moveFeature(featureId, "verified");
|
||||
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "verified",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.success("Feature verified", {
|
||||
description: `Manually verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
// Clear justFinishedAt timestamp when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: "backlog",
|
||||
justFinishedAt: undefined,
|
||||
});
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.status === "in_progress") {
|
||||
// Handle in_progress features being moved
|
||||
if (targetStatus === "backlog") {
|
||||
// Allow moving in_progress cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (
|
||||
targetStatus === "verified" &&
|
||||
draggedFeature.skipTests
|
||||
) {
|
||||
// Manual verify via drag (only for skipTests features)
|
||||
moveFeature(featureId, "verified");
|
||||
persistFeatureUpdate(featureId, { status: "verified" });
|
||||
toast.success("Feature verified", {
|
||||
description: `Marked as verified: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.skipTests) {
|
||||
// skipTests feature being moved between verified and waiting_approval
|
||||
if (
|
||||
targetStatus === "waiting_approval" &&
|
||||
draggedFeature.status === "verified"
|
||||
) {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, "waiting_approval");
|
||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving skipTests cards back to backlog (from verified)
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
} else if (draggedFeature.status === "verified") {
|
||||
// Handle verified TDD (non-skipTests) features being moved back
|
||||
if (targetStatus === "waiting_approval") {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, "waiting_approval");
|
||||
persistFeatureUpdate(featureId, { status: "waiting_approval" });
|
||||
toast.info("Feature moved back", {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else if (targetStatus === "backlog") {
|
||||
// Allow moving verified cards back to backlog
|
||||
moveFeature(featureId, "backlog");
|
||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
||||
toast.info("Feature moved to backlog", {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
features,
|
||||
runningAutoTasks,
|
||||
moveFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
activeFeature,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface UseBoardEffectsProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
specCreatingForProject: string | null;
|
||||
setSpecCreatingForProject: (path: string | null) => void;
|
||||
setSuggestionsCount: (count: number) => void;
|
||||
setFeatureSuggestions: (suggestions: any[]) => void;
|
||||
setIsGeneratingSuggestions: (generating: boolean) => void;
|
||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||
features: any[];
|
||||
isLoading: boolean;
|
||||
setFeaturesWithContext: (set: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useBoardEffects({
|
||||
currentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
setSuggestionsCount,
|
||||
setFeatureSuggestions,
|
||||
setIsGeneratingSuggestions,
|
||||
checkContextExists,
|
||||
features,
|
||||
isLoading,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
(window as any).__currentProject = currentProject;
|
||||
}
|
||||
return () => {
|
||||
(window as any).__currentProject = null;
|
||||
};
|
||||
}, [currentProject]);
|
||||
|
||||
// Listen for suggestions events to update count (persists even when dialog is closed)
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.suggestions) return;
|
||||
|
||||
const unsubscribe = api.suggestions.onEvent((event) => {
|
||||
if (event.type === "suggestions_complete" && event.suggestions) {
|
||||
setSuggestionsCount(event.suggestions.length);
|
||||
setFeatureSuggestions(event.suggestions);
|
||||
setIsGeneratingSuggestions(false);
|
||||
} else if (event.type === "suggestions_error") {
|
||||
setIsGeneratingSuggestions(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]);
|
||||
|
||||
// Subscribe to spec regeneration events to clear creating state on completion
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||
console.log(
|
||||
"[BoardView] Spec regeneration event:",
|
||||
event.type,
|
||||
"for project:",
|
||||
event.projectPath
|
||||
);
|
||||
|
||||
if (event.projectPath !== specCreatingForProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "spec_regeneration_complete") {
|
||||
setSpecCreatingForProject(null);
|
||||
} else if (event.type === "spec_regeneration_error") {
|
||||
setSpecCreatingForProject(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||
|
||||
// Sync running tasks from electron backend on mount
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncRunningTasks = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const status = await api.autoMode.status(currentProject.path);
|
||||
if (status.success) {
|
||||
const projectId = currentProject.id;
|
||||
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
||||
|
||||
if (status.runningFeatures) {
|
||||
console.log(
|
||||
"[Board] Syncing running tasks from backend:",
|
||||
status.runningFeatures
|
||||
);
|
||||
|
||||
clearRunningTasks(projectId);
|
||||
|
||||
status.runningFeatures.forEach((featureId: string) => {
|
||||
addRunningTask(projectId, featureId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Failed to sync running tasks:", error);
|
||||
}
|
||||
};
|
||||
|
||||
syncRunningTasks();
|
||||
}, [currentProject]);
|
||||
|
||||
// Check which features have context files
|
||||
useEffect(() => {
|
||||
const checkAllContexts = async () => {
|
||||
const featuresWithPotentialContext = features.filter(
|
||||
(f) =>
|
||||
f.status === "in_progress" ||
|
||||
f.status === "waiting_approval" ||
|
||||
f.status === "verified"
|
||||
);
|
||||
const contextChecks = await Promise.all(
|
||||
featuresWithPotentialContext.map(async (f) => ({
|
||||
id: f.id,
|
||||
hasContext: await checkContextExists(f.id),
|
||||
}))
|
||||
);
|
||||
|
||||
const newSet = new Set<string>();
|
||||
contextChecks.forEach(({ id, hasContext }) => {
|
||||
if (hasContext) {
|
||||
newSet.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
setFeaturesWithContext(newSet);
|
||||
};
|
||||
|
||||
if (features.length > 0 && !isLoading) {
|
||||
checkAllContexts();
|
||||
}
|
||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseBoardFeaturesProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
const { features, setFeatures } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||
|
||||
// Track previous project path to detect project switches
|
||||
const prevProjectPathRef = useRef<string | null>(null);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const isSwitchingProjectRef = useRef(false);
|
||||
|
||||
// Load features using features API
|
||||
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const currentPath = currentProject.path;
|
||||
const previousPath = prevProjectPathRef.current;
|
||||
const isProjectSwitch =
|
||||
previousPath !== null && currentPath !== previousPath;
|
||||
|
||||
// Get cached features from store (without adding to dependencies)
|
||||
const cachedFeatures = useAppStore.getState().features;
|
||||
|
||||
// If project switched, mark it but don't clear features yet
|
||||
// We'll clear after successful API load to prevent data loss
|
||||
if (isProjectSwitch) {
|
||||
console.log(
|
||||
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
|
||||
);
|
||||
isSwitchingProjectRef.current = true;
|
||||
isInitialLoadRef.current = true;
|
||||
}
|
||||
|
||||
// Update the ref to track current project
|
||||
prevProjectPathRef.current = currentPath;
|
||||
|
||||
// Only show loading spinner on initial load to prevent board flash during reloads
|
||||
if (isInitialLoadRef.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
// Keep cached features if API is unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.getAll(currentProject.path);
|
||||
|
||||
if (result.success && result.features) {
|
||||
const featuresWithIds = result.features.map(
|
||||
(f: any, index: number) => ({
|
||||
...f,
|
||||
id: f.id || `feature-${index}-${Date.now()}`,
|
||||
status: f.status || "backlog",
|
||||
startedAt: f.startedAt, // Preserve startedAt timestamp
|
||||
// Ensure model and thinkingLevel are set for backward compatibility
|
||||
model: f.model || "opus",
|
||||
thinkingLevel: f.thinkingLevel || "none",
|
||||
})
|
||||
);
|
||||
// Successfully loaded features - now safe to set them
|
||||
setFeatures(featuresWithIds);
|
||||
|
||||
// Only clear categories on project switch AFTER successful load
|
||||
if (isProjectSwitch) {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} else if (!result.success && result.error) {
|
||||
console.error("[BoardView] API returned error:", result.error);
|
||||
// If it's a new project or the error indicates no features found,
|
||||
// that's expected - start with empty array
|
||||
if (isProjectSwitch) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
// Otherwise keep cached features
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load features:", error);
|
||||
// On error, keep existing cached features for the current project
|
||||
// Only clear on project switch if we have no features from server
|
||||
if (isProjectSwitch && cachedFeatures.length === 0) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadRef.current = false;
|
||||
isSwitchingProjectRef.current = false;
|
||||
}
|
||||
}, [currentProject, setFeatures]);
|
||||
|
||||
// Load persisted categories from file
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(
|
||||
`${currentProject.path}/.automaker/categories.json`
|
||||
);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const parsed = JSON.parse(result.content);
|
||||
if (Array.isArray(parsed)) {
|
||||
setPersistedCategories(parsed);
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load categories:", error);
|
||||
// If file doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
// Save a new category to the persisted categories file
|
||||
const saveCategory = useCallback(
|
||||
async (category: string) => {
|
||||
if (!currentProject || !category.trim()) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read existing categories
|
||||
let categories: string[] = [...persistedCategories];
|
||||
|
||||
// Add new category if it doesn't exist
|
||||
if (!categories.includes(category)) {
|
||||
categories.push(category);
|
||||
categories.sort(); // Keep sorted
|
||||
|
||||
// Write back to file
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/.automaker/categories.json`,
|
||||
JSON.stringify(categories, null, 2)
|
||||
);
|
||||
|
||||
// Update state
|
||||
setPersistedCategories(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save category:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, persistedCategories]
|
||||
);
|
||||
|
||||
// Subscribe to spec regeneration complete events to refresh kanban board
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||
// Refresh the kanban board when spec regeneration completes for the current project
|
||||
if (
|
||||
event.type === "spec_regeneration_complete" &&
|
||||
currentProject &&
|
||||
event.projectPath === currentProject.path
|
||||
) {
|
||||
console.log(
|
||||
"[BoardView] Spec regeneration complete, refreshing features"
|
||||
);
|
||||
loadFeatures();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [currentProject, loadFeatures]);
|
||||
|
||||
// Listen for auto mode feature completion and errors to reload features
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode || !currentProject) return;
|
||||
|
||||
const { removeRunningTask } = useAppStore.getState();
|
||||
const projectId = currentProject.id;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Use event's projectPath or projectId if available, otherwise use current project
|
||||
// Board view only reacts to events for the currently selected project
|
||||
const eventProjectId =
|
||||
("projectId" in event && event.projectId) || projectId;
|
||||
|
||||
if (event.type === "auto_mode_feature_complete") {
|
||||
// Reload features when a feature is completed
|
||||
console.log("[Board] Feature completed, reloading features...");
|
||||
loadFeatures();
|
||||
// Play ding sound when feature is done (unless muted)
|
||||
const { muteDoneSound } = useAppStore.getState();
|
||||
if (!muteDoneSound) {
|
||||
const audio = new Audio("/sounds/ding.mp3");
|
||||
audio
|
||||
.play()
|
||||
.catch((err) => console.warn("Could not play ding sound:", err));
|
||||
}
|
||||
} else if (event.type === "plan_approval_required") {
|
||||
// Reload features when plan is generated and requires approval
|
||||
// This ensures the feature card shows the "Approve Plan" button
|
||||
console.log("[Board] Plan approval required, reloading features...");
|
||||
loadFeatures();
|
||||
} else if (event.type === "auto_mode_error") {
|
||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||
console.log(
|
||||
"[Board] Feature error, reloading features...",
|
||||
event.error
|
||||
);
|
||||
|
||||
// Remove from running tasks so it moves to the correct column
|
||||
if (event.featureId) {
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
|
||||
loadFeatures();
|
||||
|
||||
// Check for authentication errors and show a more helpful message
|
||||
const isAuthError =
|
||||
event.errorType === "authentication" ||
|
||||
(event.error &&
|
||||
(event.error.includes("Authentication failed") ||
|
||||
event.error.includes("Invalid API key")));
|
||||
|
||||
if (isAuthError) {
|
||||
toast.error("Authentication Failed", {
|
||||
description:
|
||||
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast.error("Agent encountered an error", {
|
||||
description: event.error || "Check the logs for details",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [loadFeatures, currentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
|
||||
// Load persisted categories on mount
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
return {
|
||||
features,
|
||||
isLoading,
|
||||
persistedCategories,
|
||||
loadFeatures,
|
||||
loadCategories,
|
||||
saveCategory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
interface UseBoardKeyboardShortcutsProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
onAddFeature: () => void;
|
||||
onStartNextFeatures: () => void;
|
||||
onViewOutput: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export function useBoardKeyboardShortcuts({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
onAddFeature,
|
||||
onStartNextFeatures,
|
||||
onViewOutput,
|
||||
}: UseBoardKeyboardShortcutsProps) {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
return features.filter((f) => {
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
return isRunning || f.status === "in_progress";
|
||||
});
|
||||
}, [features, runningAutoTasks]);
|
||||
|
||||
// Ref to hold the start next callback (to avoid dependency issues)
|
||||
const startNextFeaturesRef = useRef<() => void>(() => {});
|
||||
|
||||
// Update ref when callback changes
|
||||
useEffect(() => {
|
||||
startNextFeaturesRef.current = onStartNextFeatures;
|
||||
}, [onStartNextFeatures]);
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcutsList: KeyboardShortcut[] = [
|
||||
{
|
||||
key: shortcuts.addFeature,
|
||||
action: onAddFeature,
|
||||
description: "Add new feature",
|
||||
},
|
||||
{
|
||||
key: shortcuts.startNext,
|
||||
action: () => startNextFeaturesRef.current(),
|
||||
description: "Start next features from backlog",
|
||||
},
|
||||
];
|
||||
|
||||
// Add shortcuts for in-progress cards (1-9 and 0 for 10th)
|
||||
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
|
||||
// Keys 1-9 for first 9 cards, 0 for 10th card
|
||||
const key = index === 9 ? "0" : String(index + 1);
|
||||
shortcutsList.push({
|
||||
key,
|
||||
action: () => {
|
||||
onViewOutput(feature);
|
||||
},
|
||||
description: `View output for in-progress card ${index + 1}`,
|
||||
});
|
||||
});
|
||||
|
||||
return shortcutsList;
|
||||
}, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]);
|
||||
|
||||
useKeyboardShortcuts(boardShortcuts);
|
||||
|
||||
return {
|
||||
inProgressFeaturesForShortcuts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface UseBoardPersistenceProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
}
|
||||
|
||||
export function useBoardPersistence({
|
||||
currentProject,
|
||||
}: UseBoardPersistenceProps) {
|
||||
const { updateFeature } = useAppStore();
|
||||
|
||||
// Persist feature update to API (replaces saveFeatures)
|
||||
const persistFeatureUpdate = useCallback(
|
||||
async (featureId: string, updates: Partial<Feature>) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.update(
|
||||
currentProject.path,
|
||||
featureId,
|
||||
updates
|
||||
);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature update:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature creation to API
|
||||
const persistFeatureCreate = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.create(currentProject.path, feature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature creation:", error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature deletion to API
|
||||
const persistFeatureDelete = useCallback(
|
||||
async (featureId: string) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.features.delete(currentProject.path, featureId);
|
||||
} catch (error) {
|
||||
console.error("Failed to persist feature deletion:", error);
|
||||
}
|
||||
},
|
||||
[currentProject]
|
||||
);
|
||||
|
||||
return {
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
persistFeatureDelete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import {
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
ImagePreviewMap,
|
||||
} from "@/components/ui/description-image-dropzone";
|
||||
|
||||
export function useFollowUpState() {
|
||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||
const [followUpPrompt, setFollowUpPrompt] = useState("");
|
||||
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||
|
||||
const resetFollowUpState = useCallback(() => {
|
||||
setShowFollowUpDialog(false);
|
||||
setFollowUpFeature(null);
|
||||
setFollowUpPrompt("");
|
||||
setFollowUpImagePaths([]);
|
||||
setFollowUpPreviewMap(new Map());
|
||||
}, []);
|
||||
|
||||
const handleFollowUpDialogChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resetFollowUpState();
|
||||
} else {
|
||||
setShowFollowUpDialog(open);
|
||||
}
|
||||
}, [resetFollowUpState]);
|
||||
|
||||
return {
|
||||
// State
|
||||
showFollowUpDialog,
|
||||
followUpFeature,
|
||||
followUpPrompt,
|
||||
followUpImagePaths,
|
||||
followUpPreviewMap,
|
||||
// Setters
|
||||
setShowFollowUpDialog,
|
||||
setFollowUpFeature,
|
||||
setFollowUpPrompt,
|
||||
setFollowUpImagePaths,
|
||||
setFollowUpPreviewMap,
|
||||
// Helpers
|
||||
resetFollowUpState,
|
||||
handleFollowUpDialogChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { FeatureSuggestion } from "@/lib/electron";
|
||||
|
||||
export function useSuggestionsState() {
|
||||
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
|
||||
const [suggestionsCount, setSuggestionsCount] = useState(0);
|
||||
const [featureSuggestions, setFeatureSuggestions] = useState<FeatureSuggestion[]>([]);
|
||||
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||
|
||||
const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => {
|
||||
setFeatureSuggestions(suggestions);
|
||||
setSuggestionsCount(suggestions.length);
|
||||
}, []);
|
||||
|
||||
const closeSuggestionsDialog = useCallback(() => {
|
||||
setShowSuggestionsDialog(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
showSuggestionsDialog,
|
||||
suggestionsCount,
|
||||
featureSuggestions,
|
||||
isGeneratingSuggestions,
|
||||
// Setters
|
||||
setShowSuggestionsDialog,
|
||||
setSuggestionsCount,
|
||||
setFeatureSuggestions,
|
||||
setIsGeneratingSuggestions,
|
||||
// Helpers
|
||||
updateSuggestions,
|
||||
closeSuggestionsDialog,
|
||||
};
|
||||
}
|
||||
251
apps/ui/src/components/views/board-view/kanban-board.tsx
Normal file
251
apps/ui/src/components/views/board-view/kanban-board.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { KanbanColumn, KanbanCard } from "./components";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { FastForward, Lightbulb, Archive } from "lucide-react";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
|
||||
import { COLUMNS, ColumnId } from "./constants";
|
||||
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
collisionDetectionStrategy: (args: any) => any;
|
||||
onDragStart: (event: any) => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
activeFeature: Feature | null;
|
||||
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||
backgroundImageStyle: React.CSSProperties;
|
||||
backgroundSettings: {
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
hideScrollbar: boolean;
|
||||
cardOpacity: number;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
};
|
||||
onEdit: (feature: Feature) => void;
|
||||
onDelete: (featureId: string) => void;
|
||||
onViewOutput: (feature: Feature) => void;
|
||||
onVerify: (feature: Feature) => void;
|
||||
onResume: (feature: Feature) => void;
|
||||
onForceStop: (feature: Feature) => void;
|
||||
onManualVerify: (feature: Feature) => void;
|
||||
onMoveBackToInProgress: (feature: Feature) => void;
|
||||
onFollowUp: (feature: Feature) => void;
|
||||
onCommit: (feature: Feature) => void;
|
||||
onComplete: (feature: Feature) => void;
|
||||
onImplement: (feature: Feature) => void;
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
onApprovePlan: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||
onStartNextFeatures: () => void;
|
||||
onShowSuggestions: () => void;
|
||||
suggestionsCount: number;
|
||||
onArchiveAllVerified: () => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
sensors,
|
||||
collisionDetectionStrategy,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
activeFeature,
|
||||
getColumnFeatures,
|
||||
backgroundImageStyle,
|
||||
backgroundSettings,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
onResume,
|
||||
onForceStop,
|
||||
onManualVerify,
|
||||
onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onCommit,
|
||||
onComplete,
|
||||
onImplement,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
shortcuts,
|
||||
onStartNextFeatures,
|
||||
onShowSuggestions,
|
||||
suggestionsCount,
|
||||
onArchiveAllVerified,
|
||||
}: KanbanBoardProps) {
|
||||
// Use responsive column widths based on window size
|
||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="flex gap-5 h-full py-1 justify-center">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
headerAction={
|
||||
column.id === "verified" &&
|
||||
columnFeatures.length > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Archive All
|
||||
</Button>
|
||||
) : column.id === "backlog" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||
onClick={onShowSuggestions}
|
||||
title="Feature Suggestions"
|
||||
data-testid="feature-suggestions-button"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5" />
|
||||
{suggestionsCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||
data-testid="suggestions-count"
|
||||
>
|
||||
{suggestionsCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{columnFeatures.length > 0 && (
|
||||
<HotkeyButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||
onClick={onStartNextFeatures}
|
||||
hotkey={shortcuts.startNext}
|
||||
hotkeyActive={false}
|
||||
data-testid="start-next-button"
|
||||
>
|
||||
<FastForward className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
</HotkeyButton>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === "in_progress" && index < 10) {
|
||||
shortcutKey =
|
||||
index === 9 ? "0" : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() =>
|
||||
onMoveBackToInProgress(feature)
|
||||
}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onCommit={() => onCommit(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={
|
||||
backgroundSettings.cardGlassmorphism
|
||||
}
|
||||
cardBorderEnabled={
|
||||
backgroundSettings.cardBorderEnabled
|
||||
}
|
||||
cardBorderOpacity={
|
||||
backgroundSettings.cardBorderOpacity
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</KanbanColumn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<Card
|
||||
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
|
||||
style={{ width: `${columnWidth}px` }}
|
||||
>
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
interface BranchSelectorProps {
|
||||
useCurrentBranch: boolean;
|
||||
onUseCurrentBranchChange: (useCurrent: boolean) => void;
|
||||
branchName: string;
|
||||
onBranchNameChange: (branchName: string) => void;
|
||||
branchSuggestions: string[];
|
||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
||||
currentBranch?: string;
|
||||
disabled?: boolean;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function BranchSelector({
|
||||
useCurrentBranch,
|
||||
onUseCurrentBranchChange,
|
||||
branchName,
|
||||
onBranchNameChange,
|
||||
branchSuggestions,
|
||||
branchCardCounts,
|
||||
currentBranch,
|
||||
disabled = false,
|
||||
testIdPrefix = "branch",
|
||||
}: BranchSelectorProps) {
|
||||
// Validate: if "other branch" is selected, branch name is required
|
||||
const isBranchRequired = !useCurrentBranch;
|
||||
const hasError = isBranchRequired && !branchName.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
|
||||
<RadioGroup
|
||||
value={useCurrentBranch ? "current" : "other"}
|
||||
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
|
||||
disabled={disabled}
|
||||
data-testid={`${testIdPrefix}-radio-group`}
|
||||
aria-labelledby={`${testIdPrefix}-label`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
|
||||
<Label
|
||||
htmlFor={`${testIdPrefix}-current`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
Use current selected branch
|
||||
{currentBranch && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({currentBranch})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
|
||||
<Label
|
||||
htmlFor={`${testIdPrefix}-other`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
Other branch
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{!useCurrentBranch && (
|
||||
<div className="ml-6 space-y-1">
|
||||
<BranchAutocomplete
|
||||
value={branchName}
|
||||
onChange={onBranchNameChange}
|
||||
branches={branchSuggestions}
|
||||
branchCardCounts={branchCardCounts}
|
||||
placeholder="Select or create branch..."
|
||||
data-testid={`${testIdPrefix}-input`}
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Branch name is required when "Other branch" is selected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{disabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Branch cannot be changed after work has started.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{useCurrentBranch
|
||||
? "Work will be done in the currently selected branch. A worktree will be created if needed."
|
||||
: "Work will be done in this branch. A worktree will be created if needed."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
apps/ui/src/components/views/board-view/shared/index.ts
Normal file
8
apps/ui/src/components/views/board-view/shared/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./model-constants";
|
||||
export * from "./model-selector";
|
||||
export * from "./thinking-level-selector";
|
||||
export * from "./profile-quick-select";
|
||||
export * from "./testing-tab-content";
|
||||
export * from "./priority-selector";
|
||||
export * from "./branch-selector";
|
||||
export * from "./planning-mode-selector";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||
import {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ModelOption = {
|
||||
id: AgentModel;
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: "claude";
|
||||
};
|
||||
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: "haiku",
|
||||
label: "Claude Haiku",
|
||||
description: "Fast and efficient for simple tasks.",
|
||||
badge: "Speed",
|
||||
provider: "claude",
|
||||
},
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "Claude Sonnet",
|
||||
description: "Balanced performance with strong reasoning.",
|
||||
badge: "Balanced",
|
||||
provider: "claude",
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
label: "Claude Opus",
|
||||
description: "Most capable model for complex work.",
|
||||
badge: "Premium",
|
||||
provider: "claude",
|
||||
},
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"ultrathink",
|
||||
];
|
||||
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: "None",
|
||||
low: "Low",
|
||||
medium: "Med",
|
||||
high: "High",
|
||||
ultrathink: "Ultra",
|
||||
};
|
||||
|
||||
// Profile icon mapping
|
||||
export const PROFILE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Brain } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentModel } from "@/store/app-store";
|
||||
import { CLAUDE_MODELS, ModelOption } from "./model-constants";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: AgentModel;
|
||||
onModelSelect: (model: AgentModel) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
testIdPrefix = "model-select",
|
||||
}: ModelSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" />
|
||||
Claude (SDK)
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
||||
Native
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CLAUDE_MODELS.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
const shortName = option.label.replace("Claude ", "");
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
"flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
>
|
||||
{shortName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Zap, ClipboardList, FileText, ScrollText,
|
||||
Loader2, Check, Eye, RefreshCw, Sparkles
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PlanSpec } from "@/store/app-store";
|
||||
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ParsedTask, PlanSpec } from "@/store/app-store";
|
||||
|
||||
interface PlanningModeSelectorProps {
|
||||
mode: PlanningMode;
|
||||
onModeChange: (mode: PlanningMode) => void;
|
||||
requireApproval?: boolean;
|
||||
onRequireApprovalChange?: (require: boolean) => void;
|
||||
planSpec?: PlanSpec;
|
||||
onGenerateSpec?: () => void;
|
||||
onApproveSpec?: () => void;
|
||||
onRejectSpec?: () => void;
|
||||
onViewSpec?: () => void;
|
||||
isGenerating?: boolean;
|
||||
featureDescription?: string; // For auto-generation context
|
||||
testIdPrefix?: string;
|
||||
compact?: boolean; // For use in dialogs vs settings
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{
|
||||
value: 'skip' as const,
|
||||
label: 'Skip',
|
||||
description: 'Direct implementation, no upfront planning',
|
||||
icon: Zap,
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
badge: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'lite' as const,
|
||||
label: 'Lite',
|
||||
description: 'Think through approach, create task list',
|
||||
icon: ClipboardList,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
borderColor: 'border-blue-500/30',
|
||||
},
|
||||
{
|
||||
value: 'spec' as const,
|
||||
label: 'Spec',
|
||||
description: 'Generate spec with acceptance criteria',
|
||||
icon: FileText,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
borderColor: 'border-purple-500/30',
|
||||
badge: 'Approval Required',
|
||||
},
|
||||
{
|
||||
value: 'full' as const,
|
||||
label: 'Full',
|
||||
description: 'Comprehensive spec with phased plan',
|
||||
icon: ScrollText,
|
||||
color: 'text-amber-500',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
badge: 'Approval Required',
|
||||
},
|
||||
];
|
||||
|
||||
export function PlanningModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
requireApproval,
|
||||
onRequireApprovalChange,
|
||||
planSpec,
|
||||
onGenerateSpec,
|
||||
onApproveSpec,
|
||||
onRejectSpec,
|
||||
onViewSpec,
|
||||
isGenerating = false,
|
||||
featureDescription,
|
||||
testIdPrefix = 'planning',
|
||||
compact = false,
|
||||
}: PlanningModeSelectorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const selectedMode = modes.find(m => m.value === mode);
|
||||
const requiresApproval = mode === 'spec' || mode === 'full';
|
||||
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
|
||||
const hasSpec = planSpec && planSpec.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
selectedMode?.bgColor || "bg-muted"
|
||||
)}>
|
||||
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Planning Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how much upfront planning before implementation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick action buttons when spec/full mode */}
|
||||
{requiresApproval && hasSpec && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onViewSpec}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode Selection Cards */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
|
||||
)}
|
||||
>
|
||||
{modes.map((m) => {
|
||||
const isSelected = mode === m.value;
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => onModeChange(m.value)}
|
||||
data-testid={`${testIdPrefix}-mode-${m.value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
|
||||
"border-2 hover:border-primary/50",
|
||||
isSelected
|
||||
? cn("border-primary", m.bgColor)
|
||||
: "border-border/50 bg-card/50 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
|
||||
isSelected ? m.bgColor : "bg-muted"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-5 w-5 transition-colors",
|
||||
isSelected ? m.color : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className={cn(
|
||||
"font-medium text-sm",
|
||||
isSelected ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.badge && (
|
||||
<span className={cn(
|
||||
"text-[9px] px-1 py-0.5 rounded font-medium",
|
||||
m.badge === 'Default'
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-amber-500/15 text-amber-500"
|
||||
)}>
|
||||
{m.badge === 'Default' ? 'Default' : 'Review'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!compact && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{m.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Require Approval Checkbox - Only show when mode !== 'skip' */}
|
||||
{mode !== 'skip' && onRequireApprovalChange && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Checkbox
|
||||
id="require-approval"
|
||||
checked={requireApproval}
|
||||
onCheckedChange={(checked) => onRequireApprovalChange(checked === true)}
|
||||
data-testid={`${testIdPrefix}-require-approval-checkbox`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="require-approval"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Manually approve plan before implementation
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
|
||||
{requiresApproval && (
|
||||
<div className={cn(
|
||||
"rounded-xl border transition-all duration-300",
|
||||
planSpec?.status === 'approved'
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: planSpec?.status === 'generated'
|
||||
? "border-amber-500/30 bg-amber-500/5"
|
||||
: "border-border/50 bg-muted/30"
|
||||
)}>
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
|
||||
</>
|
||||
) : planSpec?.status === 'approved' ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm text-emerald-500 font-medium">Spec Approved</span>
|
||||
</>
|
||||
) : planSpec?.status === 'generated' ? (
|
||||
<>
|
||||
<Eye className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Spec will be generated when feature starts
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-generate toggle area */}
|
||||
{!planSpec?.status && canGenerate && onGenerateSpec && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGenerateSpec}
|
||||
disabled={isGenerating}
|
||||
className="h-7"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1" />
|
||||
Pre-generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spec content preview */}
|
||||
{hasSpec && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="w-full justify-between h-8 px-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||
</span>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{showPreview && (
|
||||
<div className="rounded-lg bg-background/80 border border-border/50 p-3 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{planSpec.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons when spec is generated */}
|
||||
{planSpec?.status === 'generated' && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRejectSpec}
|
||||
className="flex-1"
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onApproveSpec}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
Approve Spec
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regenerate option when approved */}
|
||||
{planSpec?.status === 'approved' && onGenerateSpec && (
|
||||
<div className="flex items-center justify-end pt-2 border-t border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onGenerateSpec}
|
||||
className="h-7"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info text for non-approval modes */}
|
||||
{!requiresApproval && (
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
{mode === 'skip'
|
||||
? "The agent will start implementing immediately without creating a plan or spec."
|
||||
: "The agent will create a planning outline before implementing, but won't wait for approval."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PrioritySelectorProps {
|
||||
selectedPriority: number;
|
||||
onPrioritySelect: (priority: number) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function PrioritySelector({
|
||||
selectedPriority,
|
||||
onPrioritySelect,
|
||||
testIdPrefix = "priority",
|
||||
}: PrioritySelectorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Priority</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(1)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
selectedPriority === 1
|
||||
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
|
||||
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-high-button`}
|
||||
>
|
||||
High
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(2)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
selectedPriority === 2
|
||||
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
|
||||
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-medium-button`}
|
||||
>
|
||||
Medium
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(3)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
selectedPriority === 3
|
||||
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
|
||||
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-low-button`}
|
||||
>
|
||||
Low
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Brain, UserCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store";
|
||||
import { PROFILE_ICONS } from "./model-constants";
|
||||
|
||||
interface ProfileQuickSelectProps {
|
||||
profiles: AIProfile[];
|
||||
selectedModel: AgentModel;
|
||||
selectedThinkingLevel: ThinkingLevel;
|
||||
onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
|
||||
testIdPrefix?: string;
|
||||
showManageLink?: boolean;
|
||||
onManageLinkClick?: () => void;
|
||||
}
|
||||
|
||||
export function ProfileQuickSelect({
|
||||
profiles,
|
||||
selectedModel,
|
||||
selectedThinkingLevel,
|
||||
onSelect,
|
||||
testIdPrefix = "profile-quick-select",
|
||||
showManageLink = false,
|
||||
onManageLinkClick,
|
||||
}: ProfileQuickSelectProps) {
|
||||
if (profiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<UserCircle className="w-4 h-4 text-brand-500" />
|
||||
Quick Select Profile
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
|
||||
Presets
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{profiles.slice(0, 6).map((profile) => {
|
||||
const IconComponent = profile.icon
|
||||
? PROFILE_ICONS[profile.icon]
|
||||
: Brain;
|
||||
const isSelected =
|
||||
selectedModel === profile.model &&
|
||||
selectedThinkingLevel === profile.thinkingLevel;
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
|
||||
isSelected
|
||||
? "bg-brand-500/10 border-brand-500 text-foreground"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${profile.id}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded flex items-center justify-center shrink-0 bg-primary/10">
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{profile.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{profile.model}
|
||||
{profile.thinkingLevel !== "none" &&
|
||||
` + ${profile.thinkingLevel}`}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Or customize below.
|
||||
{showManageLink && onManageLinkClick && (
|
||||
<>
|
||||
{" "}
|
||||
Manage profiles in{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLinkClick}
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
AI Profiles
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
|
||||
interface TestingTabContentProps {
|
||||
skipTests: boolean;
|
||||
onSkipTestsChange: (skipTests: boolean) => void;
|
||||
steps: string[];
|
||||
onStepsChange: (steps: string[]) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function TestingTabContent({
|
||||
skipTests,
|
||||
onSkipTestsChange,
|
||||
steps,
|
||||
onStepsChange,
|
||||
testIdPrefix = "",
|
||||
}: TestingTabContentProps) {
|
||||
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
|
||||
|
||||
const handleStepChange = (index: number, value: string) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[index] = value;
|
||||
onStepsChange(newSteps);
|
||||
};
|
||||
|
||||
const handleAddStep = () => {
|
||||
onStepsChange([...steps, ""]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={!skipTests}
|
||||
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
|
||||
Enable automated testing
|
||||
</Label>
|
||||
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, this feature will use automated TDD. When disabled, it
|
||||
will require manual verification.
|
||||
</p>
|
||||
|
||||
{/* Verification Steps - Only shown when skipTests is enabled */}
|
||||
{skipTests && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label>Verification Steps</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Add manual steps to verify this feature works correctly.
|
||||
</p>
|
||||
{steps.map((step, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
value={step}
|
||||
placeholder={`Verification step ${index + 1}`}
|
||||
onChange={(e) => handleStepChange(index, e.target.value)}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddStep}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Verification Step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Brain } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThinkingLevel } from "@/store/app-store";
|
||||
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants";
|
||||
|
||||
interface ThinkingLevelSelectorProps {
|
||||
selectedLevel: ThinkingLevel;
|
||||
onLevelSelect: (level: ThinkingLevel) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function ThinkingLevelSelector({
|
||||
selectedLevel,
|
||||
onLevelSelect,
|
||||
testIdPrefix = "thinking-level",
|
||||
}: ThinkingLevelSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
Thinking Level
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => onLevelSelect(level)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
||||
selectedLevel === level
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${level}`}
|
||||
>
|
||||
{THINKING_LEVEL_LABELS[level]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher levels give more time to reason through complex problems.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
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,238 @@
|
||||
|
||||
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,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo } 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;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => 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,
|
||||
onAddressPRComments,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
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>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{!worktree.isMain && hasPR && worktree.pr && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(worktree.pr!.url, "_blank");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||
PR #{worktree.pr.number}
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||
{worktree.pr.state}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
// Convert stored PR info to the full PRInfo format for the handler
|
||||
// The handler will fetch full comments from GitHub
|
||||
const prInfo: PRInfo = {
|
||||
number: worktree.pr!.number,
|
||||
title: worktree.pr!.title,
|
||||
url: worktree.pr!.url,
|
||||
state: worktree.pr!.state,
|
||||
author: "", // Will be fetched
|
||||
body: "", // Will be fetched
|
||||
comments: [],
|
||||
reviewComments: [],
|
||||
};
|
||||
onAddressPRComments(worktree, prInfo);
|
||||
}}
|
||||
className="text-xs text-blue-500 focus:text-blue-600"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</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,353 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
cardCount?: number; // Number of unarchived cards for this branch
|
||||
hasChanges?: boolean; // Whether the worktree has uncommitted changes
|
||||
changedFilesCount?: number; // Number of files with uncommitted changes
|
||||
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;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
worktree,
|
||||
cardCount,
|
||||
hasChanges,
|
||||
changedFilesCount,
|
||||
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,
|
||||
onAddressPRComments,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
// Determine border color based on state:
|
||||
// - Running features: cyan border (high visibility, indicates active work)
|
||||
// - Uncommitted changes: amber border (warning state, needs attention)
|
||||
// - Both: cyan takes priority (running is more important to see)
|
||||
const getBorderClasses = () => {
|
||||
if (isRunning) {
|
||||
return "ring-2 ring-cyan-500 ring-offset-1 ring-offset-background";
|
||||
}
|
||||
if (hasChanges) {
|
||||
return "ring-2 ring-amber-500 ring-offset-1 ring-offset-background";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const borderClasses = getBorderClasses();
|
||||
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? "open";
|
||||
const prStateClasses = (() => {
|
||||
// When selected (active tab), use high contrast solid background (paper-like)
|
||||
if (isSelected) {
|
||||
return "bg-background text-foreground border-transparent shadow-sm";
|
||||
}
|
||||
|
||||
// When not selected, use the colored variants
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
|
||||
case "draft":
|
||||
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
|
||||
case "merged":
|
||||
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
|
||||
case "closed":
|
||||
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
|
||||
}
|
||||
})();
|
||||
|
||||
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
|
||||
|
||||
// Helper to get status icon color for the selected state
|
||||
const getStatusColorClass = () => {
|
||||
if (!isSelected) return "";
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "text-emerald-600 dark:text-emerald-500";
|
||||
case "draft":
|
||||
return "text-amber-600 dark:text-amber-500";
|
||||
case "merged":
|
||||
return "text-purple-600 dark:text-purple-500";
|
||||
case "closed":
|
||||
return "text-rose-600 dark:text-rose-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
prBadge = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
|
||||
"appearance-none cursor-pointer hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states
|
||||
prStateClasses
|
||||
)}
|
||||
style={{
|
||||
// Override any inherited button styles
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
title={`${prLabel} - Click to open`}
|
||||
aria-label={`${prLabel} - Click to open pull request`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering worktree selection
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent event from bubbling to parent button
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
|
||||
PR #{worktree.pr.number}
|
||||
</span>
|
||||
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
|
||||
{prState}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center rounded-md", borderClasses)}>
|
||||
{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}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<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">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{prBadge}
|
||||
</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}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<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">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{prBadge}
|
||||
</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}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
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,53 @@
|
||||
|
||||
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,30 @@
|
||||
|
||||
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,153 @@
|
||||
|
||||
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,37 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { WorktreeInfo, FeatureInfo } from "../types";
|
||||
|
||||
interface UseRunningFeaturesOptions {
|
||||
runningFeatureIds: string[];
|
||||
features: FeatureInfo[];
|
||||
}
|
||||
|
||||
export function useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
features,
|
||||
}: UseRunningFeaturesOptions) {
|
||||
const hasRunningFeatures = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
if (runningFeatureIds.length === 0) return false;
|
||||
|
||||
return runningFeatureIds.some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (!feature) return false;
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
// No branch assigned - belongs to main worktree
|
||||
return worktree.isMain;
|
||||
});
|
||||
},
|
||||
[runningFeatureIds, features]
|
||||
);
|
||||
|
||||
return {
|
||||
hasRunningFeatures,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { toast } from "sonner";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user