Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases

This commit is contained in:
webdevcody
2026-01-12 23:49:33 -05:00
562 changed files with 65881 additions and 13321 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -19,6 +20,8 @@ import {
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('AgentToolsView');
interface ToolResult {
success: boolean;
output?: string;
@@ -62,7 +65,7 @@ export function AgentToolsView() {
try {
// Simulate agent requesting file read
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
logger.info(`[Agent Tool] Requesting to read file: ${readFilePath}`);
const result = await api.readFile(readFilePath);
@@ -72,14 +75,14 @@ export function AgentToolsView() {
output: result.content,
timestamp: new Date(),
});
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
logger.info(`[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}`);
logger.info(`[Agent Tool] File read failed: ${result.error}`);
}
} catch (error) {
setReadFileResult({
@@ -101,7 +104,7 @@ export function AgentToolsView() {
try {
// Simulate agent requesting file write
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
logger.info(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
const result = await api.writeFile(writeFilePath, writeFileContent);
@@ -111,14 +114,14 @@ export function AgentToolsView() {
output: `File written successfully: ${writeFilePath}`,
timestamp: new Date(),
});
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
logger.info(`[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}`);
logger.info(`[Agent Tool] File write failed: ${result.error}`);
}
} catch (error) {
setWriteFileResult({
@@ -140,7 +143,7 @@ export function AgentToolsView() {
try {
// Terminal command simulation for demonstration purposes
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
logger.info(`[Agent Tool] Simulating command: ${terminalCommand}`);
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
@@ -165,7 +168,7 @@ export function AgentToolsView() {
output: output,
timestamp: new Date(),
});
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
logger.info(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AgentHeaderProps {
projectName: string;
currentSessionId: string | null;
isConnected: boolean;
isProcessing: boolean;
currentTool: string | null;
messagesCount: number;
showSessionManager: boolean;
onToggleSessionManager: () => void;
onClearChat: () => void;
}
export function AgentHeader({
projectName,
currentSessionId,
isConnected,
isProcessing,
currentTool,
messagesCount,
showSessionManager,
onToggleSessionManager,
onClearChat,
}: AgentHeaderProps) {
return (
<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={onToggleSessionManager}
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">
{projectName}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{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>
)}
{currentSessionId && messagesCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageList } from './message-list';
import { NoSessionState } from './empty-states';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
isError?: boolean;
images?: ImageAttachment[];
}
interface ChatAreaProps {
currentSessionId: string | null;
messages: Message[];
isProcessing: boolean;
showSessionManager: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
onShowSessionManager: () => void;
}
export function ChatArea({
currentSessionId,
messages,
isProcessing,
showSessionManager,
messagesContainerRef,
onScroll,
onShowSessionManager,
}: ChatAreaProps) {
if (!currentSessionId) {
return (
<NoSessionState
showSessionManager={showSessionManager}
onShowSessionManager={onShowSessionManager}
/>
);
}
return (
<MessageList
messages={messages}
isProcessing={isProcessing}
messagesContainerRef={messagesContainerRef}
onScroll={onScroll}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function NoProjectState() {
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>
);
}
interface NoSessionStateProps {
showSessionManager: boolean;
onShowSessionManager: () => void;
}
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
return (
<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={onShowSessionManager} variant="outline" className="gap-2">
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { ThinkingIndicator } from './thinking-indicator';
export { NoProjectState, NoSessionState } from './empty-states';
export { MessageBubble } from './message-bubble';
export { MessageList } from './message-list';
export { AgentHeader } from './agent-header';
export { ChatArea } from './chat-area';

View File

@@ -0,0 +1,129 @@
import { Bot, User, ImageIcon, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment } from '@/store/app-store';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
isError?: boolean;
}
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isError = message.isError && message.role === 'assistant';
return (
<div
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',
isError
? 'bg-red-500/10 ring-1 ring-red-500/20'
: message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{isError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : 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',
isError
? 'bg-red-500/10 border border-red-500/30'
: message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === 'assistant' ? (
<Markdown
className={cn(
'text-sm prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded',
isError
? 'text-red-600 dark:text-red-400 prose-code:text-red-600 dark:prose-code:text-red-400 prose-code:bg-red-500/10'
: 'text-foreground prose-code:text-primary prose-code:bg-muted'
)}
>
{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',
isError
? 'text-red-500/70'
: message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ImageAttachment } from '@/store/app-store';
import { MessageBubble } from './message-bubble';
import { ThinkingIndicator } from './thinking-indicator';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
images?: ImageAttachment[];
}
interface MessageListProps {
messages: Message[];
isProcessing: boolean;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
}
export function MessageList({
messages,
isProcessing,
messagesContainerRef,
onScroll,
}: MessageListProps) {
return (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={onScroll}
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* Thinking Indicator */}
{isProcessing && <ThinkingIndicator />}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Bot } from 'lucide-react';
export function ThinkingIndicator() {
return (
<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>
);
}

View File

@@ -0,0 +1,4 @@
export { useAgentScroll } from './use-agent-scroll';
export { useFileAttachments } from './use-file-attachments';
export { useAgentShortcuts } from './use-agent-shortcuts';
export { useAgentSession } from './use-agent-session';

View File

@@ -0,0 +1,78 @@
import { useRef, useState, useCallback, useEffect } from 'react';
interface UseAgentScrollOptions {
messagesLength: number;
currentSessionId: string | null;
}
interface UseAgentScrollResult {
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
isUserAtBottom: boolean;
handleScroll: () => void;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
export function useAgentScroll({
messagesLength,
currentSessionId,
}: UseAgentScrollOptions): UseAgentScrollResult {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// 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 && messagesLength > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom('smooth');
}, 100);
}
}, [messagesLength, isUserAtBottom, scrollToBottom]);
// Initial scroll to bottom when session changes
useEffect(() => {
if (currentSessionId && messagesLength > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
}, [currentSessionId, scrollToBottom, messagesLength]);
return {
messagesContainerRef,
isUserAtBottom,
handleScroll,
scrollToBottom,
};
}

View File

@@ -0,0 +1,64 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('AgentSession');
interface UseAgentSessionOptions {
projectPath: string | undefined;
}
interface UseAgentSessionResult {
currentSessionId: string | null;
handleSelectSession: (sessionId: string | null) => void;
}
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (projectPath) {
setLastSelectedSession(projectPath, sessionId);
}
},
[projectPath, setLastSelectedSession]
);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!projectPath) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(projectPath);
if (lastSessionId) {
logger.info('Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [projectPath, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [projectPath]);
return {
currentSessionId,
handleSelectSession,
};
}

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
type KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
interface UseAgentShortcutsOptions {
currentProject: { path: string; name: string } | null;
quickCreateSessionRef: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function useAgentShortcuts({
currentProject,
quickCreateSessionRef,
}: UseAgentShortcutsOptions): void {
const shortcuts = useKeyboardShortcutsConfig();
// 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, quickCreateSessionRef]);
// Register keyboard shortcuts
useKeyboardShortcuts(agentShortcuts);
}

View File

@@ -0,0 +1,291 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
const logger = createLogger('FileAttachments');
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
interface UseFileAttachmentsOptions {
isProcessing: boolean;
isConnected: boolean;
}
interface UseFileAttachmentsResult {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
handleImagesSelected: (images: ImageAttachment[]) => void;
toggleImageDropZone: () => void;
processDroppedFiles: (files: FileList) => Promise<void>;
removeImage: (imageId: string) => void;
removeTextFile: (fileId: string) => void;
handleDragEnter: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => Promise<void>;
handlePaste: (e: React.ClipboardEvent) => Promise<void>;
clearAllFiles: () => void;
setSelectedImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
setSelectedTextFiles: React.Dispatch<React.SetStateAction<TextFileAttachment[]>>;
setShowImageDropZone: React.Dispatch<React.SetStateAction<boolean>>;
}
export function useFileAttachments({
isProcessing,
isConnected,
}: UseFileAttachmentsOptions): UseFileAttachmentsResult {
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
}, []);
const toggleImageDropZone = useCallback(() => {
setShowImageDropZone((prev) => !prev);
}, []);
// Process dropped files (images and text files)
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
logger.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, selectedTextFiles]
);
// Remove individual image
const removeImage = useCallback((imageId: string) => {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Clear all files
const clearAllFiles = useCallback(() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}, []);
// Drag and drop handlers
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]
);
return {
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
handleImagesSelected,
toggleImageDropZone,
processDroppedFiles,
removeImage,
removeTextFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
clearAllFiles,
setSelectedImages,
setSelectedTextFiles,
setShowImageDropZone,
};
}

View File

@@ -0,0 +1,133 @@
import { ImageDropZone } from '@/components/ui/image-drop-zone';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { FilePreview } from './file-preview';
import { QueueDisplay } from './queue-display';
import { InputControls } from './input-controls';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface AgentInputAreaProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
/** Current model selection (model + optional thinking level) */
modelSelection: PhaseModelEntry;
/** Callback when model is selected */
onModelSelect: (entry: PhaseModelEntry) => void;
isProcessing: boolean;
isConnected: boolean;
// File attachments
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
showImageDropZone: boolean;
isDragOver: boolean;
onImagesSelected: (images: ImageAttachment[]) => void;
onToggleImageDropZone: () => void;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAllFiles: () => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
// Queue
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function AgentInputArea({
input,
onInputChange,
onSend,
onStop,
modelSelection,
onModelSelect,
isProcessing,
isConnected,
selectedImages,
selectedTextFiles,
showImageDropZone,
isDragOver,
onImagesSelected,
onToggleImageDropZone,
onRemoveImage,
onRemoveTextFile,
onClearAllFiles,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onPaste,
serverQueue,
onRemoveFromQueue,
onClearQueue,
inputRef,
}: AgentInputAreaProps) {
const hasFiles = selectedImages.length > 0 || selectedTextFiles.length > 0;
return (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={onImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={!isConnected}
/>
)}
{/* Queued Prompts List */}
<QueueDisplay
serverQueue={serverQueue}
onRemoveFromQueue={onRemoveFromQueue}
onClearQueue={onClearQueue}
/>
{/* Selected Files Preview - only show when ImageDropZone is hidden */}
{!showImageDropZone && (
<FilePreview
selectedImages={selectedImages}
selectedTextFiles={selectedTextFiles}
isProcessing={isProcessing}
onRemoveImage={onRemoveImage}
onRemoveTextFile={onRemoveTextFile}
onClearAll={onClearAllFiles}
/>
)}
{/* Input Controls */}
<InputControls
input={input}
onInputChange={onInputChange}
onSend={onSend}
onStop={onStop}
onToggleImageDropZone={onToggleImageDropZone}
onPaste={onPaste}
modelSelection={modelSelection}
onModelSelect={onModelSelect}
isProcessing={isProcessing}
isConnected={isConnected}
hasFiles={hasFiles}
isDragOver={isDragOver}
showImageDropZone={showImageDropZone}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
inputRef={inputRef}
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { X, FileText } from 'lucide-react';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { formatFileSize } from '@/lib/image-utils';
interface FilePreviewProps {
selectedImages: ImageAttachment[];
selectedTextFiles: TextFileAttachment[];
isProcessing: boolean;
onRemoveImage: (imageId: string) => void;
onRemoveTextFile: (fileId: string) => void;
onClearAll: () => void;
}
export function FilePreview({
selectedImages,
selectedTextFiles,
isProcessing,
onRemoveImage,
onRemoveTextFile,
onClearAll,
}: FilePreviewProps) {
const totalFiles = selectedImages.length + selectedTextFiles.length;
if (totalFiles === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{totalFiles} file{totalFiles > 1 ? 's' : ''} attached
</p>
<button
onClick={onClearAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{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={() => onRemoveImage(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>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.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"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
{/* Remove button */}
<button
onClick={() => onRemoveTextFile(file.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>
);
}

View File

@@ -0,0 +1,4 @@
export { AgentInputArea } from './agent-input-area';
export { FilePreview } from './file-preview';
export { QueueDisplay } from './queue-display';
export { InputControls } from './input-controls';

View File

@@ -0,0 +1,186 @@
import { useRef, useCallback, useEffect } from 'react';
import { Send, Paperclip, Square, ListOrdered } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { AgentModelSelector } from '../shared/agent-model-selector';
import type { PhaseModelEntry } from '@automaker/types';
interface InputControlsProps {
input: string;
onInputChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
onToggleImageDropZone: () => void;
onPaste: (e: React.ClipboardEvent) => Promise<void>;
/** Current model selection (model + optional thinking level) */
modelSelection: PhaseModelEntry;
/** Callback when model is selected */
onModelSelect: (entry: PhaseModelEntry) => void;
isProcessing: boolean;
isConnected: boolean;
hasFiles: boolean;
isDragOver: boolean;
showImageDropZone: boolean;
// Drag handlers
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => Promise<void>;
// Refs
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
}
export function InputControls({
input,
onInputChange,
onSend,
onStop,
onToggleImageDropZone,
onPaste,
modelSelection,
onModelSelect,
isProcessing,
isConnected,
hasFiles,
isDragOver,
showImageDropZone,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
inputRef: externalInputRef,
}: InputControlsProps) {
const internalInputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = externalInputRef || internalInputRef;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const adjustTextareaHeight = useCallback(() => {
const textarea = inputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, [inputRef]);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
const canSend = (input.trim() || hasFiles) && isConnected;
return (
<>
{/* 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={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
<Textarea
ref={inputRef}
placeholder={
isDragOver
? 'Drop your files here...'
: isProcessing
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={onPaste}
disabled={!isConnected}
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !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">
files attached
</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>
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <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,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}
for new line
</p>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { X } from 'lucide-react';
interface QueueItem {
id: string;
message: string;
imagePaths?: string[];
}
interface QueueDisplayProps {
serverQueue: QueueItem[];
onRemoveFromQueue: (id: string) => void;
onClearQueue: () => void;
}
export function QueueDisplay({ serverQueue, onRemoveFromQueue, onClearQueue }: QueueDisplayProps) {
if (serverQueue.length === 0) {
return null;
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={onClearQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => onRemoveFromQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Re-export PhaseModelSelector in compact mode for use in agent chat view.
* This ensures we have a single source of truth for model selection logic.
*/
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import type { PhaseModelEntry } from '@automaker/types';
// Re-export types for convenience
export type { PhaseModelEntry };
interface AgentModelSelectorProps {
/** Current model selection (model + optional thinking level) */
value: PhaseModelEntry;
/** Callback when model is selected */
onChange: (entry: PhaseModelEntry) => void;
/** Disabled state */
disabled?: boolean;
}
export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
return (
<PhaseModelSelector value={value} onChange={onChange} disabled={disabled} compact align="end" />
);
}

View File

@@ -0,0 +1,9 @@
// Agent view constants
export const WELCOME_MESSAGE = {
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(),
};

View File

@@ -0,0 +1,2 @@
export { AgentModelSelector } from './agent-model-selector';
export * from './constants';

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -21,6 +22,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
const logger = createLogger('AnalysisView');
const IGNORE_PATTERNS = [
'node_modules',
'.git',
@@ -109,7 +112,7 @@ export function AnalysisView() {
return nodes;
} catch (error) {
console.error('Failed to scan directory:', path, error);
logger.error('Failed to scan directory:', path, error);
return [];
}
},
@@ -165,7 +168,7 @@ export function AnalysisView() {
setProjectAnalysis(analysis);
} catch (error) {
console.error('Analysis failed:', error);
logger.error('Analysis failed:', error);
} finally {
setIsAnalyzing(false);
}
@@ -373,7 +376,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
setSpecError(writeResult.error || 'Failed to write spec file');
}
} catch (error) {
console.error('Failed to generate spec:', error);
logger.error('Failed to generate spec:', error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
} finally {
setIsGeneratingSpec(false);
@@ -639,12 +642,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
category: detectedFeature.category,
description: detectedFeature.description,
status: 'backlog',
});
// Initialize with empty steps so the object satisfies the Feature type
steps: [],
} as any);
}
setFeatureListGenerated(true);
} catch (error) {
console.error('Failed to generate feature list:', error);
logger.error('Failed to generate feature list:', error);
setFeatureListError(
error instanceof Error ? error.message : 'Failed to generate feature list'
);

View File

@@ -1,16 +1,35 @@
// @ts-nocheck
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
PointerSensor,
useSensor,
useSensors,
rectIntersection,
pointerWithin,
type PointerEvent as DndPointerEvent,
} from '@dnd-kit/core';
// Custom pointer sensor that ignores drag events from within dialogs
class DialogAwarePointerSensor extends PointerSensor {
static activators = [
{
eventName: 'onPointerDown' as const,
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
// Don't start drag if the event originated from inside a dialog
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
return false;
}
return true;
},
},
];
}
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -21,10 +40,7 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -33,7 +49,6 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
@@ -56,24 +71,24 @@ import {
useBoardBackground,
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
useSelectionMode,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
maxConcurrency,
setMaxConcurrency,
defaultSkipTests,
showProfilesOnly,
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
setBoardViewMode,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
@@ -85,12 +100,23 @@ export function BoardView() {
setWorktrees,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
const showInitScriptIndicatorByProject = useAppStore(
(state) => state.showInitScriptIndicatorByProject
);
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -145,27 +171,29 @@ export function BoardView() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
handleFollowUpDialogChange,
addToPromptHistory,
} = useFollowUpState();
// Suggestions state hook
// Selection mode hook for mass editing
const {
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
updateSuggestions,
closeSuggestionsDialog,
} = useSuggestionsState();
isSelectionMode,
selectedFeatureIds,
selectedCount,
toggleSelectionMode,
toggleFeatureSelection,
selectAll,
clearSelection,
exitSelectionMode,
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -188,7 +216,7 @@ export function BoardView() {
return result.success && result.exists === true;
} catch (error) {
console.error('[Board] Error checking context:', error);
logger.error('Error checking context:', error);
return false;
}
},
@@ -200,9 +228,6 @@ export function BoardView() {
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features: hookFeatures,
isLoading,
@@ -222,7 +247,7 @@ export function BoardView() {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
console.error('[Board] Failed to load pipeline config:', error);
logger.error('Failed to load pipeline config:', error);
}
};
@@ -237,6 +262,9 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
// Init script events hook - subscribe to worktree init script events
useInitScriptEvents(currentProject?.path ?? null);
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -245,7 +273,7 @@ export function BoardView() {
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
useSensor(DialogAwarePointerSensor, {
activationConstraint: {
distance: 8,
},
@@ -288,7 +316,7 @@ export function BoardView() {
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error('[BoardView] Error fetching branches:', error);
logger.error('Error fetching branches:', error);
setBranchSuggestions([]);
}
};
@@ -462,6 +490,111 @@ export function BoardView() {
currentWorktreeBranch,
});
// Handler for bulk updating multiple features
const handleBulkUpdate = useCallback(
async (updates: Partial<Feature>) => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
if (result.success) {
// Update local state
featureIds.forEach((featureId) => {
updateFeature(featureId, updates);
});
toast.success(`Updated ${result.updatedCount} features`);
exitSelectionMode();
} else {
toast.error('Failed to update some features', {
description: `${result.failedCount} features failed to update`,
});
}
} catch (error) {
logger.error('Bulk update failed:', error);
toast.error('Failed to update features');
}
},
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]
);
// Handler for bulk deleting multiple features
const handleBulkDelete = useCallback(async () => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkDelete(currentProject.path, featureIds);
const successfullyDeletedIds =
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? [];
if (successfullyDeletedIds.length > 0) {
// Delete from local state without calling the API again
successfullyDeletedIds.forEach((featureId) => {
useAppStore.getState().removeFeature(featureId);
});
toast.success(`Deleted ${successfullyDeletedIds.length} features`);
}
if (result.failedCount && result.failedCount > 0) {
toast.error('Failed to delete some features', {
description: `${result.failedCount} features failed to delete`,
});
}
// Exit selection mode and reload if the operation was at least partially processed.
if (result.results) {
exitSelectionMode();
loadFeatures();
} else if (!result.success) {
toast.error('Failed to delete features', { description: result.error });
}
} catch (error) {
logger.error('Bulk delete failed:', error);
toast.error('Failed to delete features');
}
}, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]);
// Get selected features for mass edit dialog
const selectedFeatures = useMemo(() => {
return hookFeatures.filter((f) => selectedFeatureIds.has(f.id));
}, [hookFeatures, selectedFeatureIds]);
// Get backlog feature IDs in current branch for "Select All"
const allSelectableFeatureIds = useMemo(() => {
return hookFeatures
.filter((f) => {
// Only backlog features
if (f.status !== 'backlog') return false;
// Filter by current worktree branch
const featureBranch = f.branchName;
if (!featureBranch) {
// No branch assigned - only selectable on primary worktree
return currentWorktreePath === null;
}
if (currentWorktreeBranch === null) {
// Viewing main but branch hasn't been initialized
return currentProject?.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
})
.map((f) => f.id);
}, [
hookFeatures,
currentWorktreePath,
currentWorktreeBranch,
currentProject?.path,
isPrimaryWorktreeBranch,
]);
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
@@ -497,7 +630,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -538,7 +671,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -561,7 +694,7 @@ export function BoardView() {
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
@@ -665,10 +798,17 @@ export function BoardView() {
}, []);
useEffect(() => {
logger.info(
'[AutoMode] Effect triggered - isRunning:',
autoMode.isRunning,
'hasProject:',
!!currentProject
);
if (!autoMode.isRunning || !currentProject) {
return;
}
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
let isChecking = false;
let isActive = true; // Track if this effect is still active
@@ -688,6 +828,14 @@ export function BoardView() {
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
logger.debug(
'[AutoMode] Skipping check - isActive:',
isActive,
'autoModeRunning:',
autoModeRunningRef.current,
'hasProject:',
!!currentProject
);
return;
}
@@ -695,6 +843,12 @@ export function BoardView() {
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
logger.debug(
'[AutoMode] Checking features - running:',
currentRunning,
'available slots:',
availableSlots
);
// No available slots, skip check
if (availableSlots <= 0) {
@@ -702,10 +856,12 @@ export function BoardView() {
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency
// This logic mirrors use-board-column-features.ts for consistency.
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
// so we fall back to "all backlog features" when none are visible in the current view.
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
const backlogFeaturesInView = currentFeatures.filter((f) => {
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
@@ -729,7 +885,25 @@ export function BoardView() {
return featureBranch === currentWorktreeBranch;
});
const backlogFeatures =
backlogFeaturesInView.length > 0
? backlogFeaturesInView
: currentFeatures.filter((f) => f.status === 'backlog');
logger.debug(
'[AutoMode] Features - total:',
currentFeatures.length,
'backlog in view:',
backlogFeaturesInView.length,
'backlog total:',
backlogFeatures.length
);
if (backlogFeatures.length === 0) {
logger.debug(
'[AutoMode] No backlog features found, statuses:',
currentFeatures.map((f) => f.status).join(', ')
);
return;
}
@@ -739,12 +913,25 @@ export function BoardView() {
);
// Filter out features with blocking dependencies if dependency blocking is enabled
const eligibleFeatures = enableDependencyBlocking
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
return blockingDeps.length === 0;
})
: sortedBacklog;
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
// should NOT exclude blocked features in that mode.
const eligibleFeatures =
enableDependencyBlocking && !skipVerificationInAutoMode
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
if (blockingDeps.length > 0) {
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
}
return blockingDeps.length === 0;
})
: sortedBacklog;
logger.debug(
'[AutoMode] Eligible features after dep check:',
eligibleFeatures.length,
'dependency blocking enabled:',
enableDependencyBlocking
);
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
@@ -753,6 +940,13 @@ export function BoardView() {
return;
}
logger.info(
'[AutoMode] Starting',
featuresToStart.length,
'features:',
featuresToStart.map((f) => f.id).join(', ')
);
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
@@ -760,8 +954,9 @@ export function BoardView() {
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
// If feature has no branchName, assign it to the primary branch so it can run consistently
// even when the user is viewing a non-primary worktree.
if (!feature.branchName) {
const primaryBranch =
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
@@ -811,6 +1006,7 @@ export function BoardView() {
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
persistFeatureUpdate,
]);
@@ -889,10 +1085,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error('[Board] Failed to approve plan:', result.error);
logger.error('Failed to approve plan:', result.error);
}
} catch (error) {
console.error('[Board] Error approving plan:', error);
logger.error('Error approving plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -945,10 +1141,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error('[Board] Failed to reject plan:', result.error);
logger.error('Failed to reject plan:', result.error);
}
} catch (error) {
console.error('[Board] Error rejecting plan:', error);
logger.error('Error rejecting plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -1008,7 +1204,7 @@ export function BoardView() {
>
{/* Header */}
<BoardHeader
projectName={currentProject.name}
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
@@ -1020,137 +1216,117 @@ export function BoardView() {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
onOpenPlanDialog={() => setShowPlanDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
/>
{/* Worktree Panel */}
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath ?? undefined}
currentProjectPath={currentProject?.path}
/>
{/* Board Background & Detail Level Controls */}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
kanbanCardDetailLevel={kanbanCardDetailLevel}
onDetailLevelChange={setKanbanCardDetailLevel}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/>
</div>
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
{/* View Content - Kanban Board */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
</div>
{/* Selection Action Bar */}
{isSelectionMode && (
<SelectionActionBar
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)}
onDelete={handleBulkDelete}
onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)}
/>
)}
{/* Mass Edit Dialog */}
<MassEditDialog
open={showMassEditDialog}
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
/>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}
@@ -1196,10 +1372,16 @@ export function BoardView() {
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
? currentWorktreeBranch || undefined
: undefined
}
// When the worktree setting is disabled, force 'current' branch mode
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Edit Feature Dialog */}
@@ -1212,8 +1394,6 @@ export function BoardView() {
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
allFeatures={hookFeatures}
/>
@@ -1267,17 +1447,8 @@ export function BoardView() {
onPreviewMapChange={setFollowUpPreviewMap}
onSend={handleSendFollowUp}
isMaximized={isMaximized}
/>
{/* Feature Suggestions Dialog */}
<FeatureSuggestionsDialog
open={showSuggestionsDialog}
onClose={closeSuggestionsDialog}
projectPath={currentProject.path}
suggestions={featureSuggestions}
setSuggestions={updateSuggestions}
isGenerating={isGeneratingSuggestions}
setIsGenerating={setIsGeneratingSuggestions}
promptHistory={followUpPromptHistory}
onHistoryAdd={addToPromptHistory}
/>
{/* Backlog Plan Dialog */}
@@ -1290,6 +1461,7 @@ export function BoardView() {
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
/>
{/* Plan Approval Dialog */}
@@ -1357,6 +1529,7 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
@@ -1407,7 +1580,7 @@ export function BoardView() {
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
).catch(console.error);
).catch((err) => logger.error('Error in handleMove:', err));
}
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
@@ -1424,6 +1597,11 @@ export function BoardView() {
setSelectedWorktreeForAction(null);
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />
)}
</div>
);
}

View File

@@ -1,18 +1,12 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
import { ImageIcon, Archive } from 'lucide-react';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
}
export function BoardControls({
@@ -20,61 +14,12 @@ export function BoardControls({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
kanbanCardDetailLevel,
onDetailLevelChange,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2 ml-4">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
@@ -115,70 +60,6 @@ export function BoardControls({
<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>
);

View File

@@ -1,92 +1,216 @@
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
interface BoardHeaderProps {
projectName: string;
projectPath: string;
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
// Search bar props
searchQuery: string;
onSearchChange: (query: string) => void;
isCreatingSpec: boolean;
creatingSpecProjectPath?: string;
// Board controls props
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
}
// Shared styles for header control containers
const controlContainerClass =
'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
export function BoardHeader({
projectName,
projectPath,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onAddFeature,
onOpenPlanDialog,
addFeatureShortcut,
isMounted,
searchQuery,
onSearchChange,
isCreatingSpec,
creatingSpecProjectPath,
onShowBoardBackground,
onShowCompletedModal,
completedCount,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
const setPlanUseSelectedWorktreeBranch = useAppStore(
(state) => state.setPlanUseSelectedWorktreeBranch
);
const addFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.addFeatureUseSelectedWorktreeBranch
);
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.setAddFeatureUseSelectedWorktreeBranch
);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Worktree panel visibility (per-project)
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
const isWorktreePanelVisible = worktreePanelVisibleByProject[projectPath] ?? true;
const handleWorktreePanelToggle = useCallback(
async (visible: boolean) => {
// Update local store
setWorktreePanelVisible(projectPath, visible);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(projectPath, {
worktreePanelVisible: visible,
});
} catch (error) {
console.error('Failed to persist worktree panel visibility:', error);
}
},
[projectPath, setWorktreePanelVisible]
);
// Claude usage tracking visibility logic
// Hide when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
// Codex usage tracking visibility logic
// Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated;
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 className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isCreatingSpec={isCreatingSpec}
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
/>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - only show for CLI users (not API key users) */}
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
{/* Worktrees 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"
data-testid="concurrency-slider-container"
>
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-20"
data-testid="concurrency-slider"
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
Worktrees
</Label>
<Switch
id="worktrees-toggle"
checked={isWorktreePanelVisible}
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<span
className="text-sm text-muted-foreground min-w-[5ch] text-center"
data-testid="concurrency-value"
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
{runningAgentsCount} / {maxConcurrency}
</span>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
<PopoverTrigger asChild>
<button
className={`${controlContainerClass} cursor-pointer hover:bg-accent/50 transition-colors`}
data-testid="concurrency-slider-container"
>
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<span className="text-sm text-muted-foreground" data-testid="concurrency-value">
{runningAgentsCount}/{maxConcurrency}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Max Concurrent Agents</h4>
<p className="text-xs text-muted-foreground">
Controls how many AI agents can run simultaneously. Higher values process more
features in parallel but use more API resources.
</p>
</div>
<div className="flex items-center gap-3">
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="flex-1"
data-testid="concurrency-slider"
/>
<span className="text-sm font-medium min-w-[2ch] text-right">
{maxConcurrency}
</span>
</div>
</div>
</PopoverContent>
</Popover>
)}
{/* 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">
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
@@ -96,29 +220,52 @@ export function BoardHeader({
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
<button
onClick={() => setShowAutoModeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
{/* Auto Mode Settings Dialog */}
<AutoModeSettingsDialog
open={showAutoModeSettings}
onOpenChange={setShowAutoModeSettings}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<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>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
</div>
</div>
);

View File

@@ -0,0 +1,120 @@
import { memo } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { formatShortcut } from '@/store/app-store';
import { getEmptyStateConfig, type EmptyStateConfig } from '../constants';
import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react';
const ICON_MAP = {
lightbulb: Lightbulb,
play: Play,
clock: Clock,
check: CheckCircle2,
sparkles: Sparkles,
} as const;
interface EmptyStateCardProps {
columnId: string;
columnTitle?: string;
/** Keyboard shortcut for adding features (from settings) */
addFeatureShortcut?: string;
/** Whether the column is empty due to active filters */
isFilteredEmpty?: boolean;
/** Whether we're in read-only mode (hide actions) */
isReadOnly?: boolean;
/** Called when user clicks "Use AI Suggestions" */
onAiSuggest?: () => void;
/** Card opacity (matches board settings) */
opacity?: number;
/** Enable glassmorphism effect */
glassmorphism?: boolean;
/** Custom config override for pipeline steps */
customConfig?: Partial<EmptyStateConfig>;
}
export const EmptyStateCard = memo(function EmptyStateCard({
columnId,
addFeatureShortcut,
isFilteredEmpty = false,
isReadOnly = false,
onAiSuggest,
customConfig,
}: EmptyStateCardProps) {
// Get base config and merge with custom overrides
const baseConfig = getEmptyStateConfig(columnId);
const config: EmptyStateConfig = { ...baseConfig, ...customConfig };
const IconComponent = ICON_MAP[config.icon];
const showActions = !isReadOnly && !isFilteredEmpty;
const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions;
// Action button handler
const handlePrimaryAction = () => {
if (!config.primaryAction) return;
if (config.primaryAction.actionType === 'ai-suggest') {
onAiSuggest?.();
}
};
return (
<div
className={cn(
'w-full h-full min-h-[200px] flex-1',
'flex flex-col items-center justify-center',
'text-center px-4',
'transition-all duration-300 ease-out',
'animate-in fade-in duration-300',
'group'
)}
data-testid={`empty-state-card-${columnId}`}
>
{/* Icon */}
<div className="mb-3 text-muted-foreground/30">
<IconComponent className="w-8 h-8" />
</div>
{/* Title */}
<h4 className="font-medium text-sm text-muted-foreground/50 mb-1">
{isFilteredEmpty ? 'No Matching Items' : config.title}
</h4>
{/* Description */}
<p className="text-xs text-muted-foreground/40 leading-relaxed max-w-[180px]">
{isFilteredEmpty ? 'No features match your current filters.' : config.description}
</p>
{/* Keyboard shortcut hint for backlog */}
{showShortcut && (
<div className="flex items-center gap-1.5 mt-3 text-muted-foreground/40">
<span className="text-xs">Press</span>
<Kbd className="bg-muted/30 border-0 px-1.5 py-0.5 text-[10px] text-muted-foreground/50">
{formatShortcut(addFeatureShortcut, true)}
</Kbd>
<span className="text-xs">to add</span>
</div>
)}
{/* AI Suggest action for backlog */}
{showActions && config.primaryAction && config.primaryAction.actionType === 'ai-suggest' && (
<Button
variant="ghost"
size="sm"
className="mt-4 h-7 text-xs text-muted-foreground/50 hover:text-muted-foreground/70"
onClick={handlePrimaryAction}
data-testid={`empty-state-primary-action-${columnId}`}
>
<Wand2 className="w-3 h-3 mr-1.5" />
{config.primaryAction.label}
</Button>
)}
{/* Filtered empty state hint */}
{isFilteredEmpty && (
<p className="text-[10px] mt-2 text-muted-foreground/30 italic">
Clear filters to see all items
</p>
)}
</div>
);
});

View File

@@ -1,2 +1,4 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';

View File

@@ -1,5 +1,8 @@
// @ts-nocheck
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import { Feature, ThinkingLevel } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
import {
AgentTaskInfo,
parseAgentContext,
@@ -8,7 +11,6 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
ListTodo,
Sparkles,
@@ -20,6 +22,7 @@ import {
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
/**
* Formats thinking level for compact display
@@ -36,6 +39,22 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
return labels[level];
}
/**
* Formats reasoning effort for compact display
*/
function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
if (!effort || effort === 'none') return '';
const labels: Record<ReasoningEffort, string> = {
none: '',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
};
return labels[effort];
}
interface AgentInfoPanelProps {
feature: Feature;
contextContent?: string;
@@ -49,11 +68,9 @@ export function AgentInfoPanel({
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const { kanbanCardDetailLevel } = useAppStore();
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
useEffect(() => {
const loadContext = async () => {
@@ -104,15 +121,22 @@ export function AgentInfoPanel({
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === 'backlog') {
if (feature.status === 'backlog') {
const provider = getProviderFromModel(feature.model);
const isCodex = provider === 'codex';
const isClaude = provider === 'claude';
return (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
return <ProviderIcon className="w-3 h-3" />;
})()}
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
{isClaude && feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
@@ -120,20 +144,31 @@ export function AgentInfoPanel({
</span>
</div>
) : null}
{isCodex && feature.reasoningEffort && feature.reasoningEffort !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatReasoningEffort(feature.reasoningEffort as ReasoningEffort)}
</span>
</div>
) : null}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
if (feature.status !== 'backlog' && agentInfo) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
return <ProviderIcon className="w-3 h-3" />;
})()}
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
@@ -163,32 +198,47 @@ export function AgentInfoPanel({
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
<div
className={cn(
'space-y-0.5 overflow-y-auto',
isTodosExpanded ? 'max-h-40' : 'max-h-16'
)}
>
{(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map(
(todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
>
{todo.content}
</span>
</div>
))}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
)
)}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
<button
onClick={(e) => {
e.stopPropagation();
setIsTodosExpanded(!isTodosExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 pl-4 hover:text-muted-foreground transition-colors cursor-pointer"
>
{isTodosExpanded ? 'Show less' : `+${agentInfo.todos.length - 3} more`}
</button>
)}
</div>
</div>
@@ -218,7 +268,11 @@ export function AgentInfoPanel({
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
<p
className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden select-text cursor-text"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{feature.summary || summary || agentInfo.summary}
</p>
</div>
@@ -255,19 +309,15 @@ export function AgentInfoPanel({
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// Always render SummaryDialog (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (
<>
{showAgentInfo && (
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
)}
</>
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
);
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
@@ -17,6 +18,7 @@ interface CardActionsProps {
isCurrentAutoTask: boolean;
hasContext?: boolean;
shortcutKey?: string;
isSelectionMode?: boolean;
onEdit: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
@@ -35,6 +37,7 @@ export function CardActions({
isCurrentAutoTask,
hasContext,
shortcutKey,
isSelectionMode = false,
onEdit,
onViewOutput,
onVerify,
@@ -47,6 +50,11 @@ export function CardActions({
onViewPlan,
onApprovePlan,
}: CardActionsProps) {
// Hide all actions when in selection mode
if (isSelectionMode) {
return null;
}
return (
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
{isCurrentAutoTask && (

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
@@ -5,118 +6,44 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
'data-testid'?: string;
title?: string;
}
/**
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
title={title}
>
{children}
</div>
);
}
/** Uniform badge style for all card badges */
const uniformBadgeClass =
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px]';
interface CardBadgesProps {
feature: Feature;
}
/**
* CardBadges - Shows error badges below the card header
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
*/
export function CardBadges({ feature }: CardBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
// Status badges row (error, blocked)
const showStatusBadges =
feature.error ||
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog');
if (!showStatusBadges) {
if (!feature.error) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
{/* Error badge */}
{feature.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Blocked badge */}
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -126,8 +53,17 @@ interface PriorityBadgesProps {
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
const isJustFinished = useMemo(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
@@ -161,25 +97,27 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
};
}, [feature.justFinishedAt, feature.status, currentTime]);
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
const isBlocked =
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
if (!showPriorityBadges) {
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
if (!showBadges) {
return null;
}
return (
<div className="absolute top-2 left-2 flex items-center gap-1.5">
<div className="absolute top-2 left-2 flex items-center gap-1">
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
<div
className={cn(
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
uniformBadgeClass,
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
@@ -189,14 +127,10 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
<span className="font-bold text-xs">
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
@@ -210,17 +144,21 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
{showManualVerification && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</CardBadge>
<Hand className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
@@ -229,15 +167,59 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
</TooltipProvider>
)}
{/* Blocked badge */}
{isBlocked && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished badge */}
{isJustFinished && (
<CardBadge
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
</CardBadge>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
)}
data-testid={`just-finished-badge-${feature.id}`}
>
<Sparkles className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Agent just finished working on this feature</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
@@ -18,17 +19,18 @@ import {
MoreVertical,
ChevronDown,
ChevronUp,
Cpu,
GitFork,
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
interface CardHeaderProps {
feature: Feature;
isDraggable: boolean;
isCurrentAutoTask: boolean;
isSelectionMode?: boolean;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
@@ -39,6 +41,7 @@ export function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
isSelectionMode = false,
onEdit,
onDelete,
onViewOutput,
@@ -59,7 +62,7 @@ export function CardHeaderSection({
return (
<CardHeader className="p-3 pb-2 block">
{/* Running task header */}
{isCurrentAutoTask && (
{isCurrentAutoTask && !isSelectionMode && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
@@ -107,19 +110,24 @@ export function CardHeaderSection({
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
return (
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<ProviderIcon className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
@@ -150,6 +158,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
!isSelectionMode &&
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
@@ -285,12 +294,17 @@ export function CardHeaderSection({
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
return (
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<ProviderIcon className="w-3 h-3" />
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,7 +1,9 @@
// @ts-nocheck
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
@@ -22,7 +24,12 @@ function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSPropert
return {};
}
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
function getCursorClass(
isOverlay: boolean | undefined,
isDraggable: boolean,
isSelectionMode: boolean
): string {
if (isSelectionMode) return 'cursor-pointer';
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
@@ -54,6 +61,10 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
}
export const KanbanCard = memo(function KanbanCard({
@@ -82,6 +93,9 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
@@ -95,13 +109,14 @@ export const KanbanCard = memo(function KanbanCard({
}, [isOverlay]);
const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
!isSelectionMode &&
(feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask));
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: feature.id,
disabled: !isDraggable || isOverlay,
disabled: !isDraggable || isOverlay || isSelectionMode,
});
const dndStyle = {
@@ -110,9 +125,12 @@ export const KanbanCard = memo(function KanbanCard({
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
// Only allow selection for backlog features
const isSelectable = isSelectionMode && feature.status === 'backlog';
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable),
getCursorClass(isOverlay, isDraggable, isSelectable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);
@@ -127,14 +145,24 @@ export const KanbanCard = memo(function KanbanCard({
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
isSelected && isSelectable && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background'
);
const handleCardClick = (e: React.MouseEvent) => {
if (isSelectable && onToggleSelect) {
e.preventDefault();
e.stopPropagation();
onToggleSelect();
}
};
const renderCardContent = () => (
<Card
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
onDoubleClick={isSelectionMode ? undefined : onEdit}
onClick={handleCardClick}
>
{/* Background overlay with opacity */}
{(!isDragging || isOverlay) && (
@@ -150,8 +178,16 @@ export const KanbanCard = memo(function KanbanCard({
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
{/* Category row with selection checkbox */}
<div className="px-3 pt-3 flex items-center gap-2">
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.()}
className="h-4 w-4 border-2 data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
)}
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
@@ -163,6 +199,7 @@ export const KanbanCard = memo(function KanbanCard({
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
isSelectionMode={isSelectionMode}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
@@ -187,6 +224,7 @@ export const KanbanCard = memo(function KanbanCard({
isCurrentAutoTask={!!isCurrentAutoTask}
hasContext={hasContext}
shortcutKey={shortcutKey}
isSelectionMode={isSelectionMode}
onEdit={onEdit}
onViewOutput={onViewOutput}
onVerify={onVerify}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import { AgentTaskInfo } from '@/lib/agent-context-parser';
import {
@@ -30,8 +31,11 @@ export function SummaryDialog({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col select-text"
data-testid={`summary-dialog-${feature.id}`}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">

View File

@@ -10,6 +10,8 @@ interface KanbanColumnProps {
count: number;
children: ReactNode;
headerAction?: ReactNode;
/** Floating action button at the bottom of the column */
footerAction?: ReactNode;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
@@ -24,6 +26,7 @@ export const KanbanColumn = memo(function KanbanColumn({
count,
children,
headerAction,
footerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
@@ -79,12 +82,21 @@ export const KanbanColumn = memo(function KanbanColumn({
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth'
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
)}
>
{children}
</div>
{/* Floating Footer Action */}
{footerAction && (
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
{footerAction}
</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" />

View File

@@ -0,0 +1,149 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onDelete: () => void;
onClear: () => void;
onSelectAll: () => void;
}
export function SelectionActionBar({
selectedCount,
totalCount,
onEdit,
onDelete,
onClear,
onSelectAll,
}: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount;
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
setShowDeleteDialog(false);
onDelete();
};
return (
<>
<div
className={cn(
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
'animate-in slide-in-from-bottom-4 fade-in duration-200'
)}
data-testid="selection-action-bar"
>
<span className="text-sm font-medium text-foreground">
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
</span>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
{!allSelected && (
<Button
variant="outline"
size="sm"
onClick={onSelectAll}
className="h-8"
data-testid="selection-select-all-button"
>
<CheckSquare className="w-4 h-4 mr-1.5" />
Select All ({totalCount})
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 text-muted-foreground hover:text-foreground"
data-testid="selection-clear-button"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent data-testid="bulk-delete-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete {selectedCount} feature
{selectedCount !== 1 ? 's' : ''}?
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowDeleteDialog(false)}
data-testid="cancel-bulk-delete-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-bulk-delete-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -3,6 +3,69 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
export type ColumnId = Feature['status'];
/**
* Empty state configuration for each column type
*/
export interface EmptyStateConfig {
title: string;
description: string;
icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles';
shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A')
shortcutHint?: string; // Human-readable shortcut hint
primaryAction?: {
label: string;
actionType: 'ai-suggest' | 'none';
};
}
/**
* Default empty state configurations per column type
*/
export const EMPTY_STATE_CONFIGS: Record<string, EmptyStateConfig> = {
backlog: {
title: 'Ready for Ideas',
description:
'Add your first feature idea to get started using the button below, or let AI help generate ideas.',
icon: 'lightbulb',
shortcutHint: 'Press',
primaryAction: {
label: 'Use AI Suggestions',
actionType: 'none',
},
},
in_progress: {
title: 'Nothing in Progress',
description: 'Drag a feature from the backlog here or click implement to start working on it.',
icon: 'play',
},
waiting_approval: {
title: 'No Items Awaiting Approval',
description: 'Features will appear here after implementation is complete and need your review.',
icon: 'clock',
},
verified: {
title: 'No Verified Features',
description: 'Approved features will appear here. They can then be completed and archived.',
icon: 'check',
},
// Pipeline step default configuration
pipeline_default: {
title: 'Pipeline Step Empty',
description: 'Features will flow through this step during the automated pipeline process.',
icon: 'sparkles',
},
};
/**
* Get empty state config for a column, with fallback for pipeline columns
*/
export function getEmptyStateConfig(columnId: string): EmptyStateConfig {
if (columnId.startsWith('pipeline_')) {
return EMPTY_STATE_CONFIGS.pipeline_default;
}
return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.default;
}
export interface Column {
id: FeatureStatusWithPipeline;
title: string;

View File

@@ -0,0 +1,254 @@
import { useState, useRef, 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 { Textarea } from '@/components/ui/textarea';
import { Upload } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
import { STEP_TEMPLATES } from './pipeline-step-templates';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
interface AddEditPipelineStepDialogProps {
open: boolean;
onClose: () => void;
onSave: (step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => void;
existingStep?: PipelineStep | null;
defaultOrder: number;
}
export function AddEditPipelineStepDialog({
open,
onClose,
onSave,
existingStep,
defaultOrder,
}: AddEditPipelineStepDialogProps) {
const isEditing = !!existingStep;
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [instructions, setInstructions] = useState('');
const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
// Reset form when dialog opens/closes or existingStep changes
useEffect(() => {
if (open) {
if (existingStep) {
setName(existingStep.name);
setInstructions(existingStep.instructions);
setColorClass(existingStep.colorClass);
setSelectedTemplate(null);
} else {
setName('');
setInstructions('');
setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value);
setSelectedTemplate(null);
}
}
}, [open, existingStep, defaultOrder]);
const handleTemplateClick = (templateId: string) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setName(template.name);
setInstructions(template.instructions);
setColorClass(template.colorClass);
setSelectedTemplate(templateId);
toast.success(`Loaded "${template.name}" template`);
}
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setInstructions(content);
toast.success('Instructions loaded from file');
} catch {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = () => {
if (!name.trim()) {
toast.error('Step name is required');
return;
}
if (!instructions.trim()) {
toast.error('Step instructions are required');
return;
}
onSave({
id: existingStep?.id,
name: name.trim(),
instructions: instructions.trim(),
colorClass,
order: existingStep?.order ?? defaultOrder,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'}</DialogTitle>
<DialogDescription>
{isEditing
? 'Modify the step configuration below.'
: 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-6">
{/* Template Quick Start - Only show for new steps */}
{!isEditing && (
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Start from Template</Label>
<div className="flex flex-wrap gap-2">
{STEP_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => handleTemplateClick(template.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm',
selectedTemplate === template.id
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<div
className={cn('w-2 h-2 rounded-full', template.colorClass.replace('/20', ''))}
/>
{template.name}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Click a template to pre-fill the form, then customize as needed.
</p>
</div>
)}
{/* Divider */}
{!isEditing && <div className="border-t" />}
{/* Step Name */}
<div className="space-y-2">
<Label htmlFor="step-name">
Step Name <span className="text-destructive">*</span>
</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus={isEditing}
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Column Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() => setColorClass(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Agent Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">
Agent Instructions <span className="text-destructive">*</span>
</Label>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleFileUpload}>
<Upload className="h-3 w-3 mr-1" />
Load from file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step. Use markdown formatting for best results."
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
These instructions will be sent to the agent when this step runs. Be specific about
what you want the agent to review, check, or modify.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>{isEditing ? 'Update Step' : 'Add to Pipeline'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react';
// @ts-nocheck
import { useState, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -7,11 +9,11 @@ import {
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
@@ -19,49 +21,67 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
Sparkles,
ChevronDown,
Play,
} from 'lucide-react';
import { Play, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
AgentModel,
ModelAlias,
ThinkingLevel,
FeatureImage,
AIProfile,
PlanningMode,
Feature,
} from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
WorkModeSelector,
PlanningModeSelect,
AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
type BaseHistoryEntry,
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
getAncestors,
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
const logger = createLogger('AddFeatureDialog');
/**
* Determines the default work mode based on global settings and current worktree selection.
*
* Priority:
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
*/
const getDefaultWorkMode = (
useWorktrees: boolean,
selectedNonMainWorktreeBranch?: string,
forceCurrentBranchMode?: boolean
): WorkMode => {
// If force current branch mode is enabled (worktree setting is off), always use 'current'
if (forceCurrentBranchMode) {
return 'current';
}
// If a non-main worktree is selected, default to 'custom' mode with that branch
if (selectedNonMainWorktreeBranch) {
return 'custom';
}
// Otherwise, respect the global worktree setting
return useWorktrees ? 'auto' : 'current';
};
type FeatureData = {
title: string;
category: string;
@@ -72,11 +92,13 @@ type FeatureData = {
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
reasoningEffort: ReasoningEffort;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
workMode: WorkMode;
};
interface AddFeatureDialogProps {
@@ -86,16 +108,30 @@ interface AddFeatureDialogProps {
onAddAndStart?: (feature: FeatureData) => void;
categorySuggestions: string[];
branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
branchCardCounts?: Record<string, number>;
defaultSkipTests: boolean;
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
// Spawn task mode props
parentFeature?: Feature | null;
allFeatures?: Feature[];
/**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
*/
selectedNonMainWorktreeBranch?: string;
/**
* When true, forces the dialog to default to 'current' work mode (work on current branch).
* This is used when the "Use selected worktree branch" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
}
/**
* A single entry in the description history
*/
interface DescriptionHistoryEntry extends BaseHistoryEntry {
description: string;
}
export function AddFeatureDialog({
@@ -110,77 +146,76 @@ export function AddFeatureDialog({
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
parentFeature = null,
allFeatures = [],
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
title: '',
category: '',
description: '',
images: [] as FeatureImage[],
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
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 [workMode, setWorkMode] = useState<WorkMode>('current');
// Form state
const [title, setTitle] = useState('');
const [category, setCategory] = useState('');
const [description, setDescription] = useState('');
const [images, setImages] = useState<FeatureImage[]>([]);
const [imagePaths, setImagePaths] = useState<DescriptionImagePath[]>([]);
const [textFilePaths, setTextFilePaths] = useState<DescriptionTextFilePath[]>([]);
const [skipTests, setSkipTests] = useState(false);
const [branchName, setBranchName] = useState('');
const [priority, setPriority] = useState(2);
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Planning mode state
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [descriptionError, setDescriptionError] = useState(false);
// Description history state
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
useWorktrees,
} = useAppStore();
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Sync defaults when dialog opens
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
// Sync defaults only when dialog opens (transitions from closed to open)
useEffect(() => {
if (open) {
// Find the default profile if one is set
const defaultProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
const justOpened = open && !wasOpenRef.current;
wasOpenRef.current = open;
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);
if (justOpened) {
setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
setAncestors(ancestorList);
// Only select parent by default - ancestors are optional context
setSelectedAncestorIds(new Set([parentFeature.id]));
} else {
setAncestors([]);
@@ -193,39 +228,45 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
parentFeature,
allFeatures,
]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
};
const buildFeatureData = (): FeatureData | null => {
if (!newFeature.description.trim()) {
if (!description.trim()) {
setDescriptionError(true);
return null;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
if (workMode === 'custom' && !branchName.trim()) {
toast.error('Please select a branch name');
return null;
}
const category = newFeature.category || 'Uncategorized';
const selectedModel = newFeature.model;
const finalCategory = category || 'Uncategorized';
const selectedModel = modelEntry.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
? modelEntry.thinkingLevel || 'none'
: 'none';
const normalizedReasoning = supportsReasoningEffort(selectedModel)
? modelEntry.reasoningEffort || '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 || '' : newFeature.branchName || '';
// For 'current' mode, use empty string (work on current branch)
// For 'auto' mode, use empty string (will be auto-generated in use-board-actions)
// For 'custom' mode, use the specified branch name
const finalBranchName = workMode === 'custom' ? branchName || '' : '';
// Build final description - prepend ancestor context in spawn mode
let finalDescription = newFeature.description;
// Build final description with ancestor context in spawn mode
let finalDescription = description;
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
// Create parent context as an AncestorContext
const parentContext: AncestorContext = {
id: parentFeature.id,
title: parentFeature.title,
@@ -242,119 +283,75 @@ export function AddFeatureDialog({
);
if (contextText) {
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`;
}
}
return {
title: newFeature.title,
category,
title,
category: finalCategory,
description: finalDescription,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths,
skipTests: newFeature.skipTests,
images,
imagePaths,
textFilePaths,
skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
branchName: finalBranchName,
priority: newFeature.priority,
priority,
planningMode,
requirePlanApproval,
// In spawn mode, automatically add parent as dependency
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
workMode,
};
};
const resetForm = () => {
setNewFeature({
title: '',
category: '',
description: '',
images: [],
imagePaths: [],
textFilePaths: [],
skipTests: defaultSkipTests,
model: 'opus',
priority: 2,
thinkingLevel: 'none',
branchName: '',
});
setUseCurrentBranch(true);
setTitle('');
setCategory('');
setDescription('');
setImages([]);
setImagePaths([]);
setTextFilePaths([]);
setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry({ model: 'opus' });
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setPreviewMap(new Map());
setDescriptionError(false);
setDescriptionHistory([]);
onOpenChange(false);
};
const handleAction = (actionFn?: (data: FeatureData) => void) => {
if (!actionFn) return;
const featureData = buildFeatureData();
if (!featureData) return;
actionFn(featureData);
resetForm();
};
const handleAdd = () => handleAction(onAdd);
const handleAddAndStart = () => handleAction(onAddAndStart);
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setPreviewMap(new Map());
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);
// Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
@@ -382,221 +379,245 @@ export function AddFeatureDialog({
: '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">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={descriptionHistory}
currentValue={description}
onRestore={setDescription}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={newFeature.description}
value={description}
onChange={(value) => {
setNewFeature({ ...newFeature, description: value });
if (value.trim()) {
setDescriptionError(false);
}
setDescription(value);
if (value.trim()) setDescriptionError(false);
}}
images={newFeature.imagePaths}
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
textFiles={newFeature.textFilePaths}
onTextFilesChange={(textFiles) =>
setNewFeature({ ...newFeature, textFilePaths: textFiles })
}
images={imagePaths}
onImagesChange={setImagePaths}
textFiles={textFilePaths}
onTextFilesChange={setTextFilePaths}
placeholder="Describe the feature..."
previewMap={newFeaturePreviewMap}
onPreviewMapChange={setNewFeaturePreviewMap}
previewMap={previewMap}
onPreviewMapChange={setPreviewMap}
autoFocus
error={descriptionError}
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
value={newFeature.title}
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Leave blank to auto-generate"
/>
</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>
{/* Enhancement Section */}
<EnhanceWithAI
value={description}
onChange={setDescription}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
setDescriptionHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last entry to avoid duplicates
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</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 className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="add-feature-planning"
compact
/>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="add-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-skip-tests"
checked={!skipTests}
onCheckedChange={(checked) => setSkipTests(!checked)}
data-testid="add-feature-skip-tests-checkbox"
/>
<Label
htmlFor="add-feature-skip-tests"
className="text-xs font-normal cursor-pointer"
>
Run tests
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="add-feature-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
</div>
</div>
</div>
</div>
{/* Organization Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<FolderKanban className="w-4 h-4 text-muted-foreground" />
<span>Organization</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Category</Label>
<CategoryAutocomplete
value={category}
onChange={setCategory}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Priority</Label>
<PrioritySelector
selectedPriority={priority}
onPrioritySelect={setPriority}
testIdPrefix="priority"
/>
</div>
</div>
{/* Work Mode Selector */}
<div className="pt-2">
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
testIdPrefix="feature-work-mode"
/>
)}
</div>
</div>
</div>
{/* 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 })}
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
@@ -606,7 +627,7 @@ export function AddFeatureDialog({
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
disabled={workMode === 'custom' && !branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
@@ -617,7 +638,7 @@ export function AddFeatureDialog({
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
disabled={workMode === 'custom' && !branchName.trim()}
>
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
</HotkeyButton>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -6,12 +6,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { Loader2, List, FileText, GitBranch, ClipboardList } 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 { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -27,7 +29,7 @@ interface AgentOutputModalProps {
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -40,8 +42,14 @@ export function AgentOutputModal({
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [projectPath, setProjectPath] = useState<string>('');
// Extract summary from output
const summary = useMemo(() => extractSummary(output), [output]);
// Determine the effective view mode - default to summary if available, otherwise parsed
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
@@ -299,8 +307,8 @@ export function AgentOutputModal({
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">
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
@@ -308,10 +316,24 @@ export function AgentOutputModal({
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-summary"
>
<ClipboardList className="w-3.5 h-3.5" />
Summary
</button>
)}
<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'
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -323,7 +345,7 @@ export function AgentOutputModal({
<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'
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -335,7 +357,7 @@ export function AgentOutputModal({
<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'
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -361,7 +383,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -378,6 +400,10 @@ export function AgentOutputModal({
</div>
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
<>
<div
@@ -394,7 +420,7 @@ export function AgentOutputModal({
<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' ? (
) : effectiveViewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>

View File

@@ -0,0 +1,68 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { FastForward, Settings2 } from 'lucide-react';
interface AutoModeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
skipVerificationInAutoMode: boolean;
onSkipVerificationChange: (value: boolean) => void;
}
export function AutoModeSettingsDialog({
open,
onOpenChange,
skipVerificationInAutoMode,
onSkipVerificationChange,
}: AutoModeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="auto-mode-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Auto Mode Settings
</DialogTitle>
<DialogDescription>
Configure how auto mode handles feature execution and dependencies.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Skip Verification Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="skip-verification-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<FastForward className="w-4 h-4 text-brand-500" />
Skip verification requirement
</Label>
<Switch
id="skip-verification-toggle"
checked={skipVerificationInAutoMode}
onCheckedChange={onSkipVerificationChange}
data-testid="skip-verification-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, auto mode will grab features even if their dependencies are not
verified, as long as they are not currently running. This allows faster pipeline
execution without waiting for manual verification.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,7 +23,35 @@ import {
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { BacklogPlanResult, BacklogChange } from '@automaker/types';
import type {
BacklogPlanResult,
BacklogChange,
ModelAlias,
CursorModelId,
PhaseModelEntry,
} from '@automaker/types';
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
import { useAppStore } from '@/store/app-store';
/**
* Normalize PhaseModelEntry or string to PhaseModelEntry
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
}
return entry;
}
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
interface BacklogPlanDialogProps {
open: boolean;
@@ -35,6 +63,8 @@ interface BacklogPlanDialogProps {
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
// Branch to use for created features (defaults to 'main' when applying)
currentBranch?: string;
}
type DialogMode = 'input' | 'review' | 'applying';
@@ -48,11 +78,15 @@ export function BacklogPlanDialog({
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
currentBranch,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
const [selectedChanges, setSelectedChanges] = useState<Set<number>>(new Set());
const [modelOverride, setModelOverride] = useState<PhaseModelEntry | null>(null);
const { phaseModels } = useAppStore();
// Set mode based on whether we have a pending result
useEffect(() => {
@@ -83,7 +117,10 @@ export function BacklogPlanDialog({
// Start generation in background
setIsGeneratingPlan(true);
const result = await api.backlogPlan.generate(projectPath, prompt);
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model;
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
if (!result.success) {
setIsGeneratingPlan(false);
toast.error(result.error || 'Failed to start plan generation');
@@ -96,7 +133,7 @@ export function BacklogPlanDialog({
});
setPrompt('');
onClose();
}, [projectPath, prompt, setIsGeneratingPlan, onClose]);
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]);
const handleApply = useCallback(async () => {
if (!pendingPlanResult) return;
@@ -133,7 +170,11 @@ export function BacklogPlanDialog({
}) || [],
};
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
const result = await api.backlogPlan.apply(
projectPath,
filteredPlanResult,
currentBranch ?? 'main'
);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
@@ -150,6 +191,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
onPlanApplied,
onClose,
currentBranch,
]);
const handleDiscard = useCallback(() => {
@@ -358,6 +400,10 @@ export function BacklogPlanDialog({
}
};
// Get effective model entry (override or global default)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl">
@@ -378,6 +424,17 @@ export function BacklogPlanDialog({
<DialogFooter>
{mode === 'input' && (
<>
<div className="flex items-center gap-2 mr-auto">
<span className="text-xs text-muted-foreground">Model:</span>
<ModelOverrideTrigger
currentModelEntry={effectiveModelEntry}
onModelChange={setModelOverride}
phase="backlogPlanningModel"
size="sm"
variant="button"
isOverridden={modelOverride !== null}
/>
</div>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import {
Dialog,
DialogContent,

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -22,6 +23,8 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -77,7 +80,7 @@ export function CreateBranchDialog({
setError(result.error || 'Failed to create branch');
}
} catch (err) {
console.error('Create branch failed:', err);
logger.error('Create branch failed:', err);
setError('Failed to create branch');
} finally {
setIsCreating(false);

View File

@@ -10,10 +10,73 @@ import {
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 { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
/**
* Parse git/worktree error messages and return user-friendly versions
*/
function parseWorktreeError(error: string): { title: string; description?: string } {
const errorLower = error.toLowerCase();
// Worktree already exists
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
return {
title: 'A worktree with this name already exists',
description: 'Try a different branch name or delete the existing worktree first.',
};
}
// Branch already checked out in another worktree
if (
errorLower.includes('already checked out') ||
errorLower.includes('is already used by worktree')
) {
return {
title: 'This branch is already in use',
description: 'The branch is checked out in another worktree. Use a different branch name.',
};
}
// Branch name conflicts with existing branch
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
return {
title: 'A branch with this name already exists',
description: 'The worktree will use the existing branch, or try a different name.',
};
}
// Not a git repository
if (errorLower.includes('not a git repository')) {
return {
title: 'Not a git repository',
description: 'Initialize git in this project first with "git init".',
};
}
// Lock file exists (another git operation in progress)
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
return {
title: 'Another git operation is in progress',
description: 'Wait for it to complete or remove stale lock files.',
};
}
// Permission denied
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
return {
title: 'Permission denied',
description: 'Check file permissions for the project directory.',
};
}
// Default: return original error but cleaned up
return {
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
};
}
interface CreatedWorktreeInfo {
path: string;
branch: string;
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError('Branch name is required');
setError({ title: '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.'
);
setError({
title: 'Invalid branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
return;
}
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError('Worktree API not available');
setError({ title: 'Worktree API not available' });
return;
}
const result = await api.worktree.create(projectPath, branchName);
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
onOpenChange(false);
setBranchName('');
} else {
setError(result.error || 'Failed to create worktree');
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create worktree');
setError(
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
);
} finally {
setIsLoading(false);
}
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground space-y-1">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Default value for the "delete branch" checkbox */
defaultDeleteBranch?: boolean;
}
export function DeleteWorktreeDialog({
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
worktree,
onDeleted,
affectedFeatureCount = 0,
defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [isLoading, setIsLoading] = useState(false);
// Reset deleteBranch to default when dialog opens
useEffect(() => {
if (open) {
setDeleteBranch(defaultDeleteBranch);
}
}, [open, defaultDeleteBranch]);
const handleDelete = async () => {
if (!worktree) return;

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Feature } from '@/store/app-store';

View File

@@ -1,4 +1,6 @@
// @ts-nocheck
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -7,11 +9,11 @@ import {
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
@@ -19,41 +21,27 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
Sparkles,
ChevronDown,
GitBranch,
} from 'lucide-react';
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import {
Feature,
AgentModel,
ThinkingLevel,
AIProfile,
useAppStore,
PlanningMode,
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
WorkModeSelector,
PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
const logger = createLogger('EditFeatureDialog');
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -65,23 +53,25 @@ interface EditFeatureDialogProps {
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode,
preEnhancementDescription?: string
) => 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[];
}
@@ -94,71 +84,99 @@ export function EditFeatureDialog({
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;
// Derive initial workMode from feature's branchName
const [workMode, setWorkMode] = useState<WorkMode>(() => {
// If feature has a branchName, it's using 'custom' mode
// Otherwise, it's on 'current' branch (no worktree isolation)
return feature?.branchName ? 'custom' : 'current';
});
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();
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
thinkingLevel: feature?.thinkingLevel || 'none',
reasoningEffort: feature?.reasoningEffort || 'none',
}));
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track the description before enhancement (so it can be restored)
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
// Local history state for real-time display (combines persisted + session history)
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
feature?.descriptionHistory ?? []
);
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);
// Derive workMode from feature's branchName
setWorkMode(feature.branchName ? 'custom' : 'current');
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory([]);
}
}, [feature]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
};
const handleUpdate = () => {
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
// Validate branch selection for custom mode
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
if (isBranchSelectorEnabled && workMode === 'custom' && !editingFeature.branchName?.trim()) {
toast.error('Please select a branch name');
return;
}
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const selectedModel = modelEntry.model;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
? (modelEntry.thinkingLevel ?? 'none')
: 'none';
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
? (modelEntry.reasoningEffort ?? '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 || '';
// For 'current' mode, use empty string (work on current branch)
// For 'auto' mode, use empty string (will be auto-generated in use-board-actions)
// For 'custom' mode, use the specified branch name
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
const updates = {
title: editingFeature.title ?? '',
@@ -167,17 +185,38 @@ export function EditFeatureDialog({
skipTests: editingFeature.skipTests ?? false,
model: selectedModel,
thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
imagePaths: editingFeature.imagePaths ?? [],
textFilePaths: editingFeature.textFilePaths ?? [],
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
workMode,
};
onUpdate(editingFeature.id, updates);
// Determine if description changed and what source to use
const descriptionChanged = editingFeature.description !== originalDescription;
let historySource: 'enhance' | 'edit' | undefined;
let historyEnhancementMode: 'improve' | 'technical' | 'simplify' | 'acceptance' | undefined;
if (descriptionChanged && descriptionChangeSource) {
if (descriptionChangeSource === 'edit') {
historySource = 'edit';
} else {
historySource = 'enhance';
historyEnhancementMode = descriptionChangeSource.mode;
}
}
onUpdate(
editingFeature.id,
updates,
historySource,
historyEnhancementMode,
preEnhancementDescription ?? undefined
);
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
onClose();
};
@@ -187,57 +226,14 @@ export function EditFeatureDialog({
}
};
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;
}
// Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
return (
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
<DialogContent
@@ -260,34 +256,38 @@ export function EditFeatureDialog({
<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="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label>
{/* Version History Button - uses local history for real-time updates */}
<EnhancementHistoryButton
history={localHistory}
currentValue={editingFeature.description}
onRestore={(description) => {
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
setDescriptionChangeSource('edit');
}}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(value) =>
onChange={(value) => {
setEditingFeature({
...editingFeature,
description: value,
})
}
});
// Track that this change was a manual edit (unless already enhanced)
if (!descriptionChangeSource || descriptionChangeSource === 'edit') {
setDescriptionChangeSource('edit');
}
}}
images={editingFeature.imagePaths ?? []}
onImagesChange={(images) =>
setEditingFeature({
@@ -308,6 +308,7 @@ export function EditFeatureDialog({
data-testid="edit-feature-description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
@@ -323,64 +324,191 @@ export function EditFeatureDialog({
data-testid="edit-feature-title"
/>
</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>
{/* Enhancement Section */}
<EnhanceWithAI
value={editingFeature.description}
onChange={(enhanced) =>
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
setDescriptionChangeSource({ source: 'enhance', mode });
setPreEnhancementDescription(originalText);
// Update local history for real-time display
const timestamp = new Date().toISOString();
setLocalHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
</div>
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<span>AI & Execution</span>
</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 className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
<div className="grid gap-3 grid-cols-2">
<div className="space-y-1.5">
<Label
className={cn(
'text-xs text-muted-foreground',
!modelSupportsPlanningMode && 'opacity-50'
)}
>
Planning
</Label>
{modelSupportsPlanningMode ? (
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="edit-feature-planning"
compact
/>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="edit-feature-planning"
compact
disabled
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-skip-tests"
checked={!(editingFeature.skipTests ?? false)}
onCheckedChange={(checked) =>
setEditingFeature({ ...editingFeature, skipTests: !checked })
}
data-testid="edit-feature-skip-tests-checkbox"
/>
<Label
htmlFor="edit-feature-skip-tests"
className="text-xs font-normal cursor-pointer"
>
Run tests
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
!modelSupportsPlanningMode ||
planningMode === 'skip' ||
planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
</div>
</div>
</div>
</div>
{/* Organization Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<FolderKanban className="w-4 h-4 text-muted-foreground" />
<span>Organization</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Category</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>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Priority</Label>
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</div>
</div>
{/* Work Mode Selector */}
<div className="pt-2">
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
@@ -392,107 +520,12 @@ export function EditFeatureDialog({
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
testIdPrefix="edit-feature-work-mode"
/>
)}
</div>
</div>
</div>
{/* 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 })}
testIdPrefix="edit"
/>
</TabsContent>
</Tabs>
<DialogFooter className="sm:!justify-between">
<Button
variant="outline"
@@ -512,9 +545,8 @@ export function EditFeatureDialog({
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
workMode === 'custom' &&
!editingFeature.branchName?.trim()
}
>

View File

@@ -1,575 +0,0 @@
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,
List,
FileText,
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
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 [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
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';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
setProgress((prev) => [...prev, formattedTool]);
} 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: [], // Required empty steps array for new features
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 = isGenerating || 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>
<div className="flex items-center gap-2">
<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-2 py-1 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 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 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 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</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-[200px] max-h-[400px]"
>
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<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 && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</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>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -17,6 +18,21 @@ import {
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history
*/
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
prompt: string;
}
interface FollowUpDialogProps {
open: boolean;
@@ -30,6 +46,10 @@ interface FollowUpDialogProps {
onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void;
isMaximized: boolean;
/** History of prompt versions for restoration */
promptHistory?: FollowUpHistoryEntry[];
/** Callback to add a new entry to prompt history */
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
}
export function FollowUpDialog({
@@ -44,9 +64,11 @@ export function FollowUpDialog({
onPreviewMapChange,
onSend,
isMaximized,
promptHistory = [],
onHistoryAdd,
}: FollowUpDialogProps) {
const handleClose = (open: boolean) => {
if (!open) {
const handleClose = (openState: boolean) => {
if (!openState) {
onOpenChange(false);
}
};
@@ -77,7 +99,18 @@ export function FollowUpDialog({
</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>
<div className="flex items-center justify-between">
<Label htmlFor="follow-up-prompt">Instructions</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={promptHistory}
currentValue={prompt}
onRestore={onPromptChange}
valueAccessor={(entry) => entry.prompt}
title="Prompt History"
restoreMessage="Prompt restored from history"
/>
</div>
<DescriptionImageDropZone
value={prompt}
onChange={onPromptChange}
@@ -88,6 +121,33 @@ export function FollowUpDialog({
onPreviewMapChange={onPreviewMapChange}
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={prompt}
onChange={onPromptChange}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last history entry
const lastEntry = promptHistory[promptHistory.length - 1];
if (!lastEntry || lastEntry.prompt !== originalText) {
onHistoryAdd?.({
prompt: originalText,
timestamp,
source: promptHistory.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
onHistoryAdd?.({
prompt: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
}}
/>
<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.

View File

@@ -5,6 +5,6 @@ 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 { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -0,0 +1,314 @@
import { useState, useEffect, useMemo } 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 { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MassEditDialogProps {
open: boolean;
onClose: () => void;
selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>;
}
interface ApplyState {
model: boolean;
thinkingLevel: boolean;
planningMode: boolean;
requirePlanApproval: boolean;
priority: boolean;
skipTests: boolean;
}
function getMixedValues(features: Feature[]): Record<string, boolean> {
if (features.length === 0) return {};
const first = features[0];
return {
model: !features.every((f) => f.model === first.model),
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
planningMode: !features.every((f) => f.planningMode === first.planningMode),
requirePlanApproval: !features.every(
(f) => f.requirePlanApproval === first.requirePlanApproval
),
priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests),
};
}
function getInitialValue<T>(features: Feature[], key: keyof Feature, defaultValue: T): T {
if (features.length === 0) return defaultValue;
return (features[0][key] as T) ?? defaultValue;
}
interface FieldWrapperProps {
label: string;
isMixed: boolean;
willApply: boolean;
onApplyChange: (apply: boolean) => void;
children: React.ReactNode;
}
function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: FieldWrapperProps) {
return (
<div
className={cn(
'p-3 rounded-lg border transition-colors',
willApply ? 'border-brand-500/50 bg-brand-500/5' : 'border-border bg-muted/20'
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Checkbox
checked={willApply}
onCheckedChange={(checked) => onApplyChange(!!checked)}
className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500"
/>
<Label
className="text-sm font-medium cursor-pointer"
onClick={() => onApplyChange(!willApply)}
>
{label}
</Label>
</div>
{isMixed && (
<span className="flex items-center gap-1 text-xs text-amber-500">
<AlertCircle className="w-3 h-3" />
Mixed values
</span>
)}
</div>
<div className={cn(!willApply && 'opacity-50 pointer-events-none')}>{children}</div>
</div>
);
}
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
// Track which fields to apply
const [applyState, setApplyState] = useState<ApplyState>({
model: false,
thinkingLevel: false,
planningMode: false,
requirePlanApproval: false,
priority: false,
skipTests: false,
});
// Field values
const [model, setModel] = useState<ModelAlias>('sonnet');
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
const [priority, setPriority] = useState(2);
const [skipTests, setSkipTests] = useState(false);
// Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
// Reset state when dialog opens with new features
useEffect(() => {
if (open && selectedFeatures.length > 0) {
setApplyState({
model: false,
thinkingLevel: false,
planningMode: false,
requirePlanApproval: false,
priority: false,
skipTests: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
}
}, [open, selectedFeatures]);
const handleApply = async () => {
const updates: Partial<Feature> = {};
if (applyState.model) updates.model = model;
if (applyState.thinkingLevel) updates.thinkingLevel = thinkingLevel;
if (applyState.planningMode) updates.planningMode = planningMode;
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
if (applyState.priority) updates.priority = priority;
if (applyState.skipTests) updates.skipTests = skipTests;
if (Object.keys(updates).length === 0) {
onClose();
return;
}
setIsApplying(true);
try {
await onApply(updates);
onClose();
} finally {
setIsApplying(false);
}
};
const hasAnyApply = Object.values(applyState).some(Boolean);
const isCurrentModelCursor = isCursorModel(model);
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
const modelSupportsPlanningMode = isClaudeModel(model);
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl" data-testid="mass-edit-dialog">
<DialogHeader>
<DialogTitle>Edit {selectedFeatures.length} Features</DialogTitle>
<DialogDescription>
Select which settings to apply to all selected features.
</DialogDescription>
</DialogHeader>
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Model Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">AI Model</Label>
<p className="text-xs text-muted-foreground mb-2">
Select a specific model configuration
</p>
<PhaseModelSelector
value={{ model, thinkingLevel }}
onChange={(entry: PhaseModelEntry) => {
setModel(entry.model as ModelAlias);
setThinkingLevel(entry.thinkingLevel || 'none');
// Auto-enable model and thinking level for apply state
setApplyState((prev) => ({
...prev,
model: true,
thinkingLevel: true,
}));
}}
compact
/>
</div>
{/* Separator */}
<div className="border-t border-border" />
{/* Planning Mode */}
{modelSupportsPlanningMode ? (
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelect
mode={planningMode}
onModeChange={(newMode) => {
setPlanningMode(newMode);
// Auto-suggest approval based on mode, but user can override
setRequirePlanApproval(newMode === 'spec' || newMode === 'full');
}}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
testIdPrefix="mass-edit-planning"
/>
</FieldWrapper>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Checkbox checked={false} disabled className="opacity-50" />
<Label className="text-sm font-medium text-muted-foreground">
Planning Mode
</Label>
</div>
</div>
<div className="opacity-50 pointer-events-none">
<PlanningModeSelect
mode="skip"
onModeChange={() => {}}
testIdPrefix="mass-edit-planning"
disabled
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Planning modes are only available for Claude Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Priority */}
<FieldWrapper
label="Priority"
isMixed={mixedValues.priority}
willApply={applyState.priority}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, priority: apply }))}
>
<PrioritySelect
selectedPriority={priority}
onPrioritySelect={setPriority}
testIdPrefix="mass-edit-priority"
/>
</FieldWrapper>
{/* Testing */}
<FieldWrapper
label="Testing"
isMixed={mixedValues.skipTests}
willApply={applyState.skipTests}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, skipTests: apply }))}
>
<TestingTabContent
skipTests={skipTests}
onSkipTestsChange={setSkipTests}
testIdPrefix="mass-edit"
/>
</FieldWrapper>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={isApplying}>
Cancel
</Button>
<Button
onClick={handleApply}
disabled={!hasAnyApply || isApplying}
loading={isApplying}
data-testid="mass-edit-apply-button"
>
Apply to {selectedFeatures.length} Features
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -8,223 +8,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
interface PipelineSettingsDialogProps {
open: boolean;
@@ -234,18 +22,10 @@ interface PipelineSettingsDialogProps {
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
projectPath: _projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
@@ -262,9 +42,11 @@ export function PipelineSettingsDialog({
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sub-dialog state
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
@@ -276,22 +58,13 @@ export function PipelineSettingsDialog({
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
setEditingStep(null);
setAddEditDialogOpen(true);
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
setEditingStep(step);
setAddEditDialogOpen(true);
};
const handleDeleteStep = (stepId: string) => {
@@ -323,53 +96,21 @@ export function PipelineSettingsDialog({
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const handleSaveStep = (
stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
) => {
const now = new Date().toISOString();
if (editingStep.id) {
if (stepData.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
s.id === stepData.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
name: stepData.name,
instructions: stepData.instructions,
colorClass: stepData.colorClass,
updatedAt: now,
}
: s
@@ -379,90 +120,21 @@ export function PipelineSettingsDialog({
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
name: stepData.name,
instructions: stepData.instructions,
colorClass: stepData.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
@@ -470,7 +142,7 @@ export function PipelineSettingsDialog({
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
} catch {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
@@ -478,259 +150,121 @@ export function PipelineSettingsDialog({
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<>
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
{/* Add Step Button */}
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
</div>
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Pipeline'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Sub-dialog for adding/editing steps */}
<AddEditPipelineStepDialog
open={addEditDialogOpen}
onClose={() => {
setAddEditDialogOpen(false);
setEditingStep(null);
}}
onSave={handleSaveStep}
existingStep={editingStep}
defaultOrder={steps.length}
/>
</>
);
}

View File

@@ -0,0 +1,94 @@
export const codeReviewTemplate = {
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING, YOU MUST MODIFY THE CODE WITH YOUR FINDINGS.**
This step has TWO mandatory phases:
1. **REVIEW** the code (identify issues)
2. **UPDATE** the code (fix the issues you found)
**You cannot complete this step by only reviewing. You MUST make code changes based on your review findings.**
---
### Phase 1: Review Phase
Perform a thorough code review of the changes made in this feature. Focus on:
#### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
#### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
#### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
#### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE BASED ON YOUR REVIEW FINDINGS.**
**This is not optional. Every issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix Issues Immediately**: For every issue you found during review:
- ✅ Refactor code for better readability
- ✅ Extract duplicated code into reusable functions
- ✅ Improve variable/function names for clarity
- ✅ Add missing error handling
- ✅ Replace magic numbers/strings with named constants
- ✅ Optimize performance bottlenecks
- ✅ Fix any code quality issues you identify
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
2. **Apply All Improvements**: Don't just identify problems - fix them in code:
- ✅ Improve code structure and organization
- ✅ Enhance error handling and logging
- ✅ Optimize performance where possible
- ✅ Ensure consistency with codebase patterns
- ✅ Add or improve comments where needed
- ✅ **MODIFY THE FILES DIRECTLY WITH YOUR IMPROVEMENTS**
3. **For Complex Issues**: If you encounter issues that require significant refactoring:
- ✅ Make the improvements you can make safely
- ✅ Document remaining issues with clear explanations
- ✅ Provide specific suggestions for future improvements
- ✅ **STILL MAKE AS MANY CODE CHANGES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of issues found during review
- **A detailed list of ALL code changes and improvements made (this proves you updated the code)**
- Any remaining issues that need attention (if applicable)
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing without updating is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with your improvements.**
**You MUST show evidence of code changes in your summary.**
**This step is only complete when code has been updated.**`,
};

View File

@@ -0,0 +1,77 @@
export const documentationTemplate = {
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH DOCUMENTATION ⚠️
**THIS IS NOT OPTIONAL. YOU MUST ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs documentation
2. **UPDATE** the code by adding/updating documentation files and code comments
**You cannot complete this step by only identifying what needs documentation. You MUST add the documentation directly to the codebase.**
---
### Phase 1: Review Phase
Identify what documentation is needed:
- Review new functions, classes, and modules
- Identify new or modified API endpoints
- Check for missing README updates
- Identify changelog entries needed
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW ADD/UPDATE DOCUMENTATION IN THE CODEBASE.**
**This is not optional. You must modify files to add documentation.**
#### Action Steps (You MUST complete these):
1. **Code Documentation** - UPDATE THE CODE FILES:
- ✅ Add/update JSDoc or docstrings for new functions and classes
- ✅ Document complex algorithms or business logic
- ✅ Add inline comments for non-obvious code
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH DOCUMENTATION**
2. **API Documentation** - UPDATE API DOCUMENTATION FILES:
- ✅ Document any new or modified API endpoints
- ✅ Include request/response examples
- ✅ Document error responses
- ✅ **UPDATE THE API DOCUMENTATION FILES DIRECTLY**
3. **README Updates** - UPDATE THE README FILE:
- ✅ Update README if new setup steps are required
- ✅ Document any new environment variables
- ✅ Update architecture diagrams if applicable
- ✅ **MODIFY THE README FILE DIRECTLY**
4. **Changelog** - UPDATE THE CHANGELOG FILE:
- ✅ Document notable changes for the changelog
- ✅ Include breaking changes if any
- ✅ **UPDATE THE CHANGELOG FILE DIRECTLY**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of documentation needs identified
- **A detailed list of ALL documentation files and code comments added/updated (this proves you updated the code)**
- Specific files modified with documentation
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying documentation needs without adding documentation is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly to add documentation.**
**You MUST show evidence of documentation changes in your summary.**
**This step is only complete when documentation has been added to the codebase.**`,
};

View File

@@ -0,0 +1,28 @@
import { codeReviewTemplate } from './code-review';
import { securityReviewTemplate } from './security-review';
import { uxReviewTemplate } from './ux-review';
import { testingTemplate } from './testing';
import { documentationTemplate } from './documentation';
import { optimizationTemplate } from './optimization';
export interface PipelineStepTemplate {
id: string;
name: string;
colorClass: string;
instructions: string;
}
export const STEP_TEMPLATES: PipelineStepTemplate[] = [
codeReviewTemplate,
securityReviewTemplate,
uxReviewTemplate,
testingTemplate,
documentationTemplate,
optimizationTemplate,
];
// Helper to get template color class
export const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || 'bg-blue-500/20';
};

View File

@@ -0,0 +1,103 @@
export const optimizationTemplate = {
id: 'optimization',
name: 'Performance',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE WITH OPTIMIZATIONS ⚠️
**THIS IS NOT OPTIONAL. AFTER IDENTIFYING OPTIMIZATION OPPORTUNITIES, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for performance issues (identify bottlenecks)
2. **UPDATE** the code with optimizations (fix the performance issues)
**You cannot complete this step by only identifying performance issues. You MUST modify the code to optimize it.**
---
### Phase 1: Review Phase
Identify performance bottlenecks and optimization opportunities:
#### Code Performance
- Identify slow algorithms (O(n²) → O(n log n), etc.)
- Find unnecessary computations or redundant operations
- Identify inefficient loops and iterations
- Check for inappropriate data structures
#### Memory Usage
- Check for memory leaks
- Identify memory-intensive operations
- Check for proper cleanup of resources
#### Database/API
- Identify slow database queries (N+1 queries, missing indexes)
- Find opportunities for caching
- Identify API calls that could be batched
#### Frontend (if applicable)
- Identify bundle size issues
- Find render performance problems
- Identify opportunities for lazy loading
- Find expensive computations that need memoization
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO APPLY OPTIMIZATIONS.**
**This is not optional. Every performance issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Optimize Code Performance** - UPDATE THE CODE:
- ✅ Optimize slow algorithms (O(n²) → O(n log n), etc.)
- ✅ Remove unnecessary computations or redundant operations
- ✅ Optimize loops and iterations
- ✅ Use appropriate data structures
- ✅ **MODIFY THE SOURCE FILES DIRECTLY WITH OPTIMIZATIONS**
2. **Fix Memory Issues** - UPDATE THE CODE:
- ✅ Fix memory leaks
- ✅ Optimize memory-intensive operations
- ✅ Ensure proper cleanup of resources
- ✅ **MAKE THE ACTUAL CODE CHANGES**
3. **Optimize Database/API** - UPDATE THE CODE:
- ✅ Optimize database queries (add indexes, reduce N+1 queries)
- ✅ Implement caching where appropriate
- ✅ Batch API calls when possible
- ✅ **MODIFY THE DATABASE/API CODE DIRECTLY**
4. **Optimize Frontend** (if applicable) - UPDATE THE CODE:
- ✅ Minimize bundle size
- ✅ Optimize render performance
- ✅ Implement lazy loading where appropriate
- ✅ Use memoization for expensive computations
- ✅ **MODIFY THE FRONTEND CODE DIRECTLY**
5. **Profile and Measure**:
- ✅ Profile the code to verify bottlenecks are fixed
- ✅ Measure improvements achieved
- ✅ **DOCUMENT THE PERFORMANCE IMPROVEMENTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of performance issues identified
- **A detailed list of ALL optimizations applied to the code (this proves you updated the code)**
- Performance improvements achieved (with metrics if possible)
- Any remaining optimization opportunities
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying performance issues without optimizing the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the code files directly with optimizations.**
**You MUST show evidence of optimization changes in your summary.**
**This step is only complete when code has been optimized.**`,
};

View File

@@ -0,0 +1,114 @@
export const securityReviewTemplate = {
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO FIX SECURITY ISSUES ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING FOR SECURITY ISSUES, YOU MUST FIX THEM IN THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the code for security vulnerabilities (identify issues)
2. **UPDATE** the code to fix vulnerabilities (secure the code)
**You cannot complete this step by only identifying security issues. You MUST modify the code to fix them.**
**Security vulnerabilities left unfixed are unacceptable. You must address them with code changes.**
---
### Phase 1: Review Phase
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
#### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
#### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
#### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
#### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO FIX ALL SECURITY VULNERABILITIES.**
**This is not optional. Every security issue you identify must be fixed with code changes.**
**Security vulnerabilities cannot be left unfixed. You must address them immediately.**
#### Action Steps (You MUST complete these):
1. **Fix Vulnerabilities Immediately** - UPDATE THE CODE:
- ✅ Add input validation and sanitization where missing
- ✅ Fix SQL injection vulnerabilities by using parameterized queries
- ✅ Fix XSS vulnerabilities by properly encoding output
- ✅ Add authentication/authorization checks where needed
- ✅ Remove hardcoded secrets and credentials
- ✅ Implement proper encryption for sensitive data
- ✅ Fix broken access control
- ✅ Add security headers and configurations
- ✅ Fix any other security vulnerabilities you find
- ✅ **MODIFY THE SOURCE FILES DIRECTLY TO FIX SECURITY ISSUES**
2. **Apply Security Best Practices** - UPDATE THE CODE:
- ✅ Implement proper input validation on all user inputs
- ✅ Ensure all outputs are properly encoded
- ✅ Add authentication checks to protected routes/endpoints
- ✅ Implement proper authorization logic
- ✅ Remove or secure any exposed sensitive data
- ✅ Add security logging and monitoring
- ✅ Update dependencies with known vulnerabilities
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex Security Issues** - UPDATE THE CODE:
- ✅ Fix what you can fix safely
- ✅ Document critical security issues with severity levels
- ✅ Provide specific remediation steps for complex issues
- ✅ Add security-related comments explaining protections in place
- ✅ **STILL MAKE AS MANY SECURITY FIXES AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A security assessment summary of vulnerabilities found
- **A detailed list of ALL security fixes applied to the code (this proves you updated the code)**
- Any remaining security concerns that need attention (if applicable)
- Severity levels for any unfixed issues
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing security without fixing vulnerabilities is INCOMPLETE, UNACCEPTABLE, and DANGEROUS.**
**You MUST modify the code files directly to fix security issues.**
**You MUST show evidence of security fixes in your summary.**
**This step is only complete when security vulnerabilities have been fixed in the code.**
**Security issues cannot be left as documentation - they must be fixed.**`,
};

View File

@@ -0,0 +1,81 @@
export const testingTemplate = {
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODEBASE WITH TESTS ⚠️
**THIS IS NOT OPTIONAL. YOU MUST WRITE AND ADD TESTS TO THE CODEBASE.**
This step requires you to:
1. **REVIEW** what needs testing
2. **UPDATE** the codebase by writing and adding test files
**You cannot complete this step by only identifying what needs testing. You MUST create test files and write tests.**
---
### Phase 1: Review Phase
Identify what needs test coverage:
- Review new functions, methods, and classes
- Identify new API endpoints
- Check for edge cases that need testing
- Identify integration points that need testing
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW WRITE AND ADD TESTS TO THE CODEBASE.**
**This is not optional. You must create test files and write actual test code.**
#### Action Steps (You MUST complete these):
1. **Write Unit Tests** - CREATE TEST FILES:
- ✅ Write unit tests for all new functions and methods
- ✅ Ensure edge cases are covered
- ✅ Test error handling paths
- ✅ Aim for high code coverage on new code
- ✅ **CREATE TEST FILES AND WRITE THE ACTUAL TEST CODE**
2. **Write Integration Tests** - CREATE TEST FILES:
- ✅ Test interactions between components/modules
- ✅ Verify API endpoints work correctly
- ✅ Test database operations if applicable
- ✅ **CREATE INTEGRATION TEST FILES AND WRITE THE ACTUAL TEST CODE**
3. **Ensure Test Quality** - WRITE QUALITY TESTS:
- ✅ Tests should be readable and well-documented
- ✅ Each test should have a clear purpose
- ✅ Use descriptive test names that explain the scenario
- ✅ Follow the Arrange-Act-Assert pattern
- ✅ **WRITE COMPLETE, FUNCTIONAL TESTS**
4. **Run Tests** - VERIFY TESTS WORK:
- ✅ Run the full test suite and ensure all new tests pass
- ✅ Verify no existing tests are broken
- ✅ Check that test coverage meets project standards
- ✅ **FIX ANY FAILING TESTS**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of testing needs identified
- **A detailed list of ALL test files created and tests written (this proves you updated the codebase)**
- Test coverage metrics achieved
- Any issues found during testing and how they were resolved
---
# ⚠️ FINAL REMINDER ⚠️
**Identifying what needs testing without writing tests is INCOMPLETE and UNACCEPTABLE.**
**You MUST create test files and write actual test code.**
**You MUST show evidence of test files created in your summary.**
**This step is only complete when tests have been written and added to the codebase.**`,
};

View File

@@ -0,0 +1,116 @@
export const uxReviewTemplate = {
id: 'ux-reviewer',
name: 'User Experience',
colorClass: 'bg-purple-500/20',
instructions: `## User Experience Review & Update
# ⚠️ CRITICAL REQUIREMENT: YOU MUST UPDATE THE CODE TO IMPROVE UX ⚠️
**THIS IS NOT OPTIONAL. AFTER REVIEWING THE USER EXPERIENCE, YOU MUST UPDATE THE CODE.**
This step has TWO mandatory phases:
1. **REVIEW** the user experience (identify UX issues)
2. **UPDATE** the code to improve UX (fix the issues you found)
**You cannot complete this step by only reviewing UX. You MUST modify the code to improve the user experience.**
---
### Phase 1: Review Phase
Review the changes made in this feature from a user experience and design perspective. Focus on creating an exceptional user experience.
#### User-Centered Design
- **User Goals**: Does this feature solve a real user problem?
- **Clarity**: Is the interface clear and easy to understand?
- **Simplicity**: Can the feature be simplified without losing functionality?
- **Consistency**: Does it follow existing design patterns and conventions?
#### Visual Design & Hierarchy
- **Layout**: Is the visual hierarchy clear? Does important information stand out?
- **Spacing**: Is there appropriate whitespace and grouping?
- **Typography**: Is text readable with proper sizing and contrast?
- **Color**: Does color usage support functionality and meet accessibility standards?
#### Accessibility (WCAG 2.1)
- **Keyboard Navigation**: Can all functionality be accessed via keyboard?
- **Screen Readers**: Are ARIA labels and semantic HTML used appropriately?
- **Color Contrast**: Does text meet WCAG AA standards (4.5:1 for body, 3:1 for large)?
- **Focus Indicators**: Are focus states visible and clear?
- **Touch Targets**: Are interactive elements at least 44x44px on mobile?
#### Responsive Design
- **Mobile Experience**: Does it work well on small screens?
- **Touch Targets**: Are buttons and links easy to tap?
- **Content Adaptation**: Does content adapt appropriately to different screen sizes?
- **Navigation**: Is navigation accessible and intuitive on mobile?
#### User Feedback & States
- **Loading States**: Are loading indicators shown for async operations?
- **Error States**: Are error messages clear and actionable?
- **Empty States**: Do empty states guide users on what to do next?
- **Success States**: Are successful actions clearly confirmed?
#### Performance & Perceived Performance
- **Loading Speed**: Does the feature load quickly?
- **Skeleton Screens**: Are skeleton screens used for better perceived performance?
- **Optimistic Updates**: Can optimistic UI updates improve perceived speed?
- **Micro-interactions**: Do animations and transitions enhance the experience?
---
### Phase 2: Update Phase - ⚠️ MANDATORY ACTION REQUIRED ⚠️
**YOU MUST NOW MODIFY THE CODE TO IMPROVE THE USER EXPERIENCE.**
**This is not optional. Every UX issue you identify must be addressed with code changes.**
#### Action Steps (You MUST complete these):
1. **Fix UX Issues Immediately** - UPDATE THE CODE:
- ✅ Improve visual hierarchy and layout
- ✅ Fix spacing and typography issues
- ✅ Add missing ARIA labels and semantic HTML
- ✅ Fix color contrast issues
- ✅ Add or improve focus indicators
- ✅ Ensure touch targets meet size requirements
- ✅ Add missing loading, error, empty, and success states
- ✅ Improve responsive design for mobile
- ✅ Add keyboard navigation support
- ✅ Fix any accessibility issues
- ✅ **MODIFY THE UI COMPONENT FILES DIRECTLY WITH UX IMPROVEMENTS**
2. **Apply UX Improvements** - UPDATE THE CODE:
- ✅ Refactor components for better clarity and simplicity
- ✅ Improve visual design and spacing
- ✅ Enhance accessibility features
- ✅ Add user feedback mechanisms (loading, error, success states)
- ✅ Optimize for mobile and responsive design
- ✅ Improve micro-interactions and animations
- ✅ Ensure consistency with design system
- ✅ **MAKE THE ACTUAL CODE CHANGES - DO NOT JUST DOCUMENT THEM**
3. **For Complex UX Issues** - UPDATE THE CODE:
- ✅ Make the improvements you can make safely
- ✅ Document UX considerations and recommendations
- ✅ Provide specific suggestions for major UX improvements
- ✅ **STILL MAKE AS MANY UX IMPROVEMENTS AS POSSIBLE**
---
### Summary Required
After completing BOTH review AND update phases, provide:
- A summary of UX issues found during review
- **A detailed list of ALL UX improvements made to the code (this proves you updated the code)**
- Any remaining UX considerations that need attention (if applicable)
- Recommendations for future UX enhancements
---
# ⚠️ FINAL REMINDER ⚠️
**Reviewing UX without updating the code is INCOMPLETE and UNACCEPTABLE.**
**You MUST modify the UI component files directly with UX improvements.**
**You MUST show evidence of UX code changes in your summary.**
**This step is only complete when code has been updated to improve the user experience.**`,
};

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsDialog({
open,
onOpenChange,
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, features created via the Plan dialog will be assigned to the currently
selected worktree branch. When disabled, features will be added to the main branch.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,67 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface WorktreeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
addFeatureUseSelectedWorktreeBranch: boolean;
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function WorktreeSettingsDialog({
open,
onOpenChange,
addFeatureUseSelectedWorktreeBranch,
onAddFeatureUseSelectedWorktreeBranchChange,
}: WorktreeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Worktree Settings
</DialogTitle>
<DialogDescription>
Configure how worktrees affect feature creation and organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="worktree-branch-toggle"
checked={addFeatureUseSelectedWorktreeBranch}
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
data-testid="worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, the Add Feature dialog will default to custom branch mode with the
currently selected worktree branch pre-filled.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,4 +7,4 @@ 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';
export { useSelectionMode } from './use-selection-mode';

View File

@@ -1,18 +1,24 @@
// @ts-nocheck
import { useCallback } from 'react';
import {
Feature,
FeatureImage,
AgentModel,
ModelAlias,
ThinkingLevel,
PlanningMode,
useAppStore,
} from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardActions');
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
@@ -20,7 +26,13 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -76,6 +88,7 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
@@ -92,28 +105,46 @@ export function useBoardActions({
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
workMode?: 'current' | 'auto' | 'custom';
}) => {
// 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;
const workMode = featureData.workMode || 'current';
// 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) {
// Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// No worktree isolation - work directly on current branch
finalBranchName = undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp
const baseBranch = currentWorktreeBranch || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
} else {
// Custom mode - use provided branch name
finalBranchName = featureData.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName && currentProject) {
try {
const api = getElectronAPI();
if (api?.worktree?.create) {
const result = await api.worktree.create(currentProject.path, finalBranchName);
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
@@ -125,8 +156,8 @@ export function useBoardActions({
// Refresh worktree list in UI
onWorktreeCreated?.();
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -135,7 +166,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -180,7 +211,7 @@ export function useBoardActions({
}
})
.catch((error) => {
console.error('[Board] Error generating title:', error);
logger.error('Error generating title:', error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
@@ -195,10 +226,10 @@ export function useBoardActions({
persistFeatureUpdate,
updateFeature,
saveCategory,
useWorktrees,
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
]
);
@@ -210,35 +241,53 @@ export function useBoardActions({
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
workMode?: 'current' | 'auto' | 'custom';
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
const finalBranchName = updates.branchName || undefined;
const workMode = updates.workMode || 'current';
// 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) {
// Determine final branch name based on work mode
let finalBranchName: string | undefined;
if (workMode === 'current') {
finalBranchName = undefined;
} else if (workMode === 'auto') {
const baseBranch = currentWorktreeBranch || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
} else {
finalBranchName = updates.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && 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}" ${
logger.info(
`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}":`,
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
@@ -247,7 +296,7 @@ export function useBoardActions({
}
}
} catch (error) {
console.error('[Board] Error creating worktree:', error);
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -261,7 +310,13 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (updates.category) {
saveCategory(updates.category);
}
@@ -272,9 +327,9 @@ export function useBoardActions({
persistFeatureUpdate,
saveCategory,
setEditingFeature,
useWorktrees,
currentProject,
onWorktreeCreated,
currentWorktreeBranch,
]
);
@@ -292,7 +347,7 @@ export function useBoardActions({
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature before delete:', error);
logger.error('Error stopping feature before delete:', error);
toast.error('Failed to stop agent', {
description: 'The feature will still be deleted.',
});
@@ -305,13 +360,13 @@ export function useBoardActions({
for (const imagePathObj of feature.imagePaths) {
try {
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
logger.info(`Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
logger.error(`Failed to delete image ${imagePathObj.path}:`, error);
}
}
} catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
logger.error(`Error deleting images for feature ${featureId}:`, error);
}
}
@@ -323,38 +378,31 @@ export function useBoardActions({
const handleRunFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
if (!currentProject) {
throw new Error('No project selected');
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
return;
}
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error('Auto mode API not available');
}
// 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
);
// 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();
if (result.success) {
logger.info('Feature run started successfully, branch:', feature.branchName || 'default');
} else {
// Throw error so caller can handle rollback
throw new Error(result.error || 'Failed to start feature');
}
},
[currentProject, useWorktrees, loadFeatures]
[currentProject, useWorktrees]
);
const handleStartImplementation = useCallback(
@@ -390,11 +438,34 @@ export function useBoardActions({
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;
try {
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
logger.info('Feature moved to in_progress, starting agent...');
await handleRunFeature(feature);
return true;
} catch (error) {
// Rollback to backlog if persist or run fails (e.g., server offline)
logger.error('Failed to start feature, rolling back to backlog:', error);
const rollbackUpdates = {
status: 'backlog' as const,
startedAt: undefined,
};
updateFeature(feature.id, rollbackUpdates);
// If server is offline (connection refused), redirect to login page
if (isConnectionError(error)) {
handleServerOffline();
return false;
}
toast.error('Failed to start feature', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
return false;
}
},
[
autoMode,
@@ -413,20 +484,20 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
logger.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');
logger.info('Feature verification started successfully');
} else {
console.error('[Board] Failed to verify feature:', result.error);
logger.error('Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error verifying feature:', error);
logger.error('Error verifying feature:', error);
await loadFeatures();
}
},
@@ -435,20 +506,20 @@ export function useBoardActions({
const handleResumeFeature = useCallback(
async (feature: Feature) => {
console.log('[Board] handleResumeFeature called for feature:', feature.id);
logger.info('handleResumeFeature called for feature:', feature.id);
if (!currentProject) {
console.error('[Board] No current project');
logger.error('No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('[Board] Auto mode API not available');
logger.error('Auto mode API not available');
return;
}
console.log('[Board] Calling resumeFeature API...', {
logger.info('Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
@@ -460,16 +531,16 @@ export function useBoardActions({
useWorktrees
);
console.log('[Board] resumeFeature result:', result);
logger.info('resumeFeature result:', result);
if (result.success) {
console.log('[Board] Feature resume started successfully');
logger.info('Feature resume started successfully');
} else {
console.error('[Board] Failed to resume feature:', result.error);
logger.error('Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
console.error('[Board] Error resuming feature:', error);
logger.error('Error resuming feature:', error);
await loadFeatures();
}
},
@@ -520,10 +591,11 @@ export function useBoardActions({
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
const previousStatus = followUpFeature.status;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
console.error('Follow-up feature API not available');
logger.error('Follow-up feature API not available');
toast.error('Follow-up not available', {
description: 'This feature is not available in the current version.',
});
@@ -536,35 +608,53 @@ export function useBoardActions({
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
try {
await persistFeatureUpdate(featureId, updates);
toast.success('Follow-up started', {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
setShowFollowUpDialog(false);
setFollowUpFeature(null);
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
const imagePaths = followUpImagePaths.map((img) => img.path);
// Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
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
const result = await 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();
);
if (!result.success) {
throw new Error(result.error || 'Failed to send follow-up');
}
} catch (error) {
// Rollback to previous status if follow-up fails
logger.error('Error sending follow-up, rolling back:', error);
const rollbackUpdates = {
status: previousStatus as 'backlog' | 'in_progress' | 'waiting_approval' | 'verified',
startedAt: undefined,
};
updateFeature(featureId, rollbackUpdates);
// If server is offline (connection refused), redirect to login page
if (isConnectionError(error)) {
handleServerOffline();
return;
}
toast.error('Failed to send follow-up', {
description:
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
});
}
}, [
currentProject,
followUpFeature,
@@ -577,7 +667,6 @@ export function useBoardActions({
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
loadFeatures,
]);
const handleCommitFeature = useCallback(
@@ -587,7 +676,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.autoMode?.commitFeature) {
console.error('Commit feature API not available');
logger.error('Commit feature API not available');
toast.error('Commit not available', {
description: 'This feature is not available in the current version.',
});
@@ -610,14 +699,14 @@ export function useBoardActions({
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error('[Board] Failed to commit feature:', result.error);
logger.error('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);
logger.error('Error committing feature:', error);
toast.error('Failed to commit feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -634,7 +723,7 @@ export function useBoardActions({
try {
const api = getElectronAPI();
if (!api?.worktree?.mergeFeature) {
console.error('Worktree API not available');
logger.error('Worktree API not available');
toast.error('Merge not available', {
description: 'This feature is not available in the current version.',
});
@@ -651,13 +740,13 @@ export function useBoardActions({
)}`,
});
} else {
console.error('[Board] Failed to merge feature:', result.error);
logger.error('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);
logger.error('Error merging feature:', error);
toast.error('Failed to merge feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -747,7 +836,7 @@ export function useBoardActions({
: `Stopped working on: ${truncateDescription(feature.description)}`,
});
} catch (error) {
console.error('[Board] Error stopping feature:', error);
logger.error('Error stopping feature:', error);
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
@@ -805,12 +894,14 @@ export function useBoardActions({
// 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;
const aBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
@@ -822,14 +913,14 @@ export function useBoardActions({
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
if (!enableDependencyBlocking || skipVerificationInAutoMode) 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.',
'All backlog features have unmet dependencies. Complete their dependencies first (or enable "Skip verification requirement" in Auto Mode settings).',
});
return;
}
@@ -846,6 +937,7 @@ export function useBoardActions({
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
]);
const handleArchiveAllVerified = useCallback(async () => {
@@ -857,7 +949,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error('[Board] Error stopping feature before archive:', error);
logger.error('Error stopping feature before archive:', error);
}
}
// Archive the feature by setting status to completed

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getServerUrlSync } from '@/lib/http-api-client';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
@@ -22,14 +22,14 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {};
}
const imageUrl = getAuthenticatedImageUrl(
backgroundSettings.imagePath,
currentProject.path,
backgroundSettings.imageVersion
);
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${
backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : ''
})`,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -69,9 +70,21 @@ export function useBoardColumnFeatures({
// 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;
if (projectPath) {
const worktrees = useAppStore.getState().worktreesByProject[projectPath] ?? [];
if (worktrees.length === 0) {
// Worktrees not loaded yet - fallback to showing features on common default branches
// This prevents features from disappearing during initial load
matchesWorktree =
featureBranch === 'main' || featureBranch === 'master' || featureBranch === 'develop';
} else {
matchesWorktree = useAppStore
.getState()
.isPrimaryWorktreeBranch(projectPath, featureBranch);
}
} else {
matchesWorktree = false;
}
} else {
// Match by branch name
matchesWorktree = featureBranch === effectiveBranch;

View File

@@ -1,10 +1,13 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
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';
const logger = createLogger('BoardDragDrop');
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -63,7 +66,7 @@ export function useBoardDragDrop({
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');
logger.debug('Cannot drag feature - currently running');
return;
}
}

View File

@@ -1,14 +1,14 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
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;
@@ -20,9 +20,6 @@ export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
@@ -44,38 +41,13 @@ export function useBoardEffects({
};
}, [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
);
logger.info('Spec regeneration event:', event.type, 'for project:', event.projectPath);
if (event.projectPath !== specCreatingForProject) {
return;
@@ -108,7 +80,7 @@ export function useBoardEffects({
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log('[Board] Syncing running tasks from backend:', status.runningFeatures);
logger.info('Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
@@ -118,7 +90,7 @@ export function useBoardEffects({
}
}
} catch (error) {
console.error('[Board] Failed to sync running tasks:', error);
logger.error('Failed to sync running tasks:', error);
}
};

View File

@@ -2,6 +2,9 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardFeatures');
interface UseBoardFeaturesProps {
currentProject: { path: string; id: string } | null;
@@ -32,7 +35,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// 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}`);
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
@@ -48,7 +51,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
// Keep cached features if API is unavailable
return;
}
@@ -72,8 +75,19 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (isProjectSwitch) {
setPersistedCategories([]);
}
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) {
console.error('[BoardView] API returned error:', result.error);
logger.error('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) {
@@ -83,7 +97,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// Otherwise keep cached features
}
} catch (error) {
console.error('Failed to load features:', error);
logger.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) {
@@ -115,7 +129,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories([]);
}
} catch (error) {
console.error('Failed to load categories:', error);
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
@@ -147,7 +161,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(categories);
}
} catch (error) {
console.error('Failed to save category:', error);
logger.error('Failed to save category:', error);
}
},
[currentProject, persistedCategories]
@@ -165,7 +179,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
currentProject &&
event.projectPath === currentProject.path
) {
console.log('[BoardView] Spec regeneration complete, refreshing features');
logger.info('Spec regeneration complete, refreshing features');
loadFeatures();
}
});
@@ -190,27 +204,27 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
console.log('[Board] Feature completed, reloading features...');
logger.info('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));
audio.play().catch((err) => logger.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...');
logger.info('Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
logger.info('Pipeline step started, 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);
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
if (event.featureId) {

View File

@@ -2,6 +2,9 @@ import { useCallback } from 'react';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardPersistence');
interface UseBoardPersistenceProps {
currentProject: { path: string; id: string } | null;
@@ -12,22 +15,35 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
async (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
const result = await api.features.update(currentProject.path, featureId, updates);
const result = await api.features.update(
currentProject.path,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature update:', error);
logger.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
@@ -41,7 +57,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
@@ -50,7 +66,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error('Failed to persist feature creation:', error);
logger.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
@@ -64,13 +80,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
console.error('[BoardView] Features API not available');
logger.error('Features API not available');
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error('Failed to persist feature deletion:', error);
logger.error('Failed to persist feature deletion:', error);
}
},
[currentProject]

View File

@@ -4,13 +4,18 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
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 [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
@@ -18,6 +23,7 @@ export function useFollowUpState() {
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []);
const handleFollowUpDialogChange = useCallback(
@@ -31,6 +37,13 @@ export function useFollowUpState() {
[resetFollowUpState]
);
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return {
// State
showFollowUpDialog,
@@ -38,14 +51,17 @@ export function useFollowUpState() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
addToPromptHistory,
};
}

View File

@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from 'react';
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectedFeatureIds: Set<string>;
selectedCount: number;
toggleSelectionMode: () => void;
toggleFeatureSelection: (featureId: string) => void;
selectAll: (featureIds: string[]) => void;
clearSelection: () => void;
isFeatureSelected: (featureId: string) => boolean;
exitSelectionMode: () => void;
}
export function useSelectionMode(): UseSelectionModeReturn {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) {
// Exiting selection mode - clear selection
setSelectedFeatureIds(new Set());
}
return !prev;
});
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelectionMode(false);
setSelectedFeatureIds(new Set());
}, []);
const toggleFeatureSelection = useCallback((featureId: string) => {
setSelectedFeatureIds((prev) => {
const next = new Set(prev);
if (next.has(featureId)) {
next.delete(featureId);
} else {
next.add(featureId);
}
return next;
});
}, []);
const selectAll = useCallback((featureIds: string[]) => {
setSelectedFeatureIds(new Set(featureIds));
}, []);
const clearSelection = useCallback(() => {
setSelectedFeatureIds(new Set());
}, []);
const isFeatureSelected = useCallback(
(featureId: string) => selectedFeatureIds.has(featureId),
[selectedFeatureIds]
);
// Handle Escape key to exit selection mode
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isSelectionMode) {
exitSelectionMode();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSelectionMode, exitSelectionMode]);
return {
isSelectionMode,
selectedFeatureIds,
selectedCount: selectedFeatureIds.size,
toggleSelectionMode,
toggleFeatureSelection,
selectAll,
clearSelection,
isFeatureSelected,
exitSelectionMode,
};
}

View File

@@ -1,34 +0,0 @@
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,
};
}

View File

@@ -0,0 +1,209 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
interface SingleIndicatorProps {
stateKey: string;
state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
autoDismiss: boolean; // Whether to auto-dismiss after completion
}
function SingleIndicator({
stateKey,
state,
onDismiss,
isOnlyOne,
autoDismiss,
}: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [output, showLogs]);
// Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => {
if (status === 'running' && isOnlyOne) {
setShowLogs(true);
}
}, [status, isOnlyOne]);
// Auto-dismiss after completion (5 seconds)
useEffect(() => {
if (autoDismiss && (status === 'success' || status === 'failed')) {
const timer = setTimeout(() => {
onDismiss(stateKey);
}, 5000);
return () => clearTimeout(timer);
}
}, [status, autoDismiss, stateKey, onDismiss]);
if (status === 'idle') return null;
return (
<div
className={cn(
'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">
Init Script{' '}
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowLogs(!showLogs)}
className="p-1 hover:bg-accent rounded transition-colors"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{showLogs ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{status !== 'running' && (
<button
onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Branch info */}
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
<span>Branch: {branch}</span>
</div>
{/* Logs (collapsible) */}
{showLogs && (
<div className="border-t border-border/50">
<div className="p-3 max-h-[300px] overflow-y-auto">
{output.length > 0 ? (
<AnsiOutput text={output.join('')} />
) : (
<div className="text-xs text-muted-foreground/60 text-center py-2">
{status === 'running' ? 'Waiting for output...' : 'No output'}
</div>
)}
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Status bar for completed states */}
{status !== 'running' && (
<div
className={cn(
'px-3 py-2 text-xs',
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
)}
>
{status === 'success'
? 'Initialization completed successfully'
: 'Initialization failed - worktree is still usable'}
</div>
)}
</div>
);
}
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
// Get auto-dismiss setting
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
autoDismiss={autoDismiss}
/>
))}
</div>
);
}

View File

@@ -2,13 +2,11 @@ import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
@@ -37,7 +35,6 @@ interface KanbanBoardProps {
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;
@@ -45,13 +42,21 @@ interface KanbanBoardProps {
onSpawnTask?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
onAddFeature: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
// Empty state action props
onAiSuggest?: () => void;
/** Whether currently dragging (hides empty states during drag) */
isDragging?: boolean;
/** Whether the board is in read-only mode */
isReadOnly?: boolean;
}
export function KanbanBoard({
@@ -72,7 +77,6 @@ export function KanbanBoard({
onManualVerify,
onMoveBackToInProgress,
onFollowUp,
onCommit,
onComplete,
onImplement,
onViewPlan,
@@ -80,23 +84,31 @@ export function KanbanBoard({
onSpawnTask,
featuresWithContext,
runningAutoTasks,
shortcuts,
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
onAddFeature,
pipelineConfig,
onOpenPipelineSettings,
isSelectionMode = false,
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
onAiSuggest,
isDragging = false,
isReadOnly = false,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
const { keyboardShortcuts } = useAppStore();
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -131,38 +143,36 @@ export function KanbanBoard({
</Button>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 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"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
data-testid="selection-mode-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>
{isSelectionMode ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</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>
) : column.id === 'in_progress' ? (
<Button
@@ -188,11 +198,48 @@ export function KanbanBoard({
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
@@ -224,6 +271,9 @@ export function KanbanBoard({
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})}

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
const logger = createLogger('EnhanceWithAI');
interface EnhanceWithAIProps {
/** Current text value to enhance */
value: string;
/** Callback when text is enhanced */
onChange: (enhancedText: string) => void;
/** Optional callback to track enhancement in history */
onHistoryAdd?: (entry: {
mode: EnhancementMode;
originalText: string;
enhancedText: string;
}) => void;
/** Disable the enhancement feature */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Reusable "Enhance with AI" component
*
* Provides AI-powered text enhancement with multiple modes:
* - Improve Clarity
* - Add Technical Details
* - Simplify
* - Add Acceptance Criteria
* - User Experience
*
* Used in Add Feature, Edit Feature, and Follow-Up dialogs.
*/
export function EnhanceWithAI({
value,
onChange,
onHistoryAdd,
disabled = false,
className,
}: EnhanceWithAIProps) {
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const handleEnhance = async () => {
if (!value.trim() || isEnhancing || disabled) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
onHistoryAdd?.({ mode: enhancementMode, originalText, enhancedText });
toast.success('Enhanced successfully!');
} else {
toast.error(result?.error || 'Failed to enhance');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance');
} finally {
setIsEnhancing(false);
}
};
return (
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen} className={className}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"
disabled={disabled}
>
{enhanceOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs" disabled={disabled}>
{ENHANCEMENT_MODE_LABELS[enhancementMode]}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhance}
disabled={!value.trim() || isEnhancing || disabled}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -0,0 +1,20 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
/** Descriptions for enhancement modes (for tooltips/accessibility) */
export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: 'Make the prompt clearer and more concise',
technical: 'Add implementation details and specifications',
simplify: 'Reduce complexity while keeping the core intent',
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};

View File

@@ -0,0 +1,136 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { History } from 'lucide-react';
import { toast } from 'sonner';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
/**
* Base interface for history entries
*/
export interface BaseHistoryEntry {
timestamp: string;
source: 'initial' | 'enhance' | 'edit';
enhancementMode?: EnhancementMode;
}
interface EnhancementHistoryButtonProps<T extends BaseHistoryEntry> {
/** Array of history entries */
history: T[];
/** Current value to compare against for highlighting */
currentValue: string;
/** Callback when a history entry is restored */
onRestore: (value: string) => void;
/** Function to extract the text value from a history entry */
valueAccessor: (entry: T) => string;
/** Title for the history popover (e.g., "Version History", "Prompt History") */
title?: string;
/** Message shown when restoring an entry */
restoreMessage?: string;
}
/**
* Reusable history button component for enhancement-related history
*
* Displays a popover with a list of historical versions that can be restored.
* Used in edit-feature-dialog and follow-up-dialog for description/prompt history.
*/
export function EnhancementHistoryButton<T extends BaseHistoryEntry>({
history,
currentValue,
onRestore,
valueAccessor,
title = 'Version History',
restoreMessage = 'Restored from history',
}: EnhancementHistoryButtonProps<T>) {
const [showHistory, setShowHistory] = useState(false);
// Memoize reversed history to avoid creating new array on every render
// NOTE: This hook MUST be called before any early returns to follow Rules of Hooks
const reversedHistory = useMemo(() => [...history].reverse(), [history]);
// Early return AFTER all hooks are called
if (history.length === 0) {
return null;
}
const getSourceLabel = (entry: T): string => {
if (entry.source === 'initial') {
return 'Original';
}
if (entry.source === 'enhance') {
const mode = entry.enhancementMode ?? 'improve';
const label = ENHANCEMENT_MODE_LABELS[mode as EnhancementMode] ?? mode;
return `Enhanced (${label})`;
}
return 'Edited';
};
const formatDate = (timestamp: string): string => {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({history.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">Click a version to restore it</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{reversedHistory.map((entry, index) => {
const value = valueAccessor(entry);
const isCurrentVersion = value === currentValue;
const sourceLabel = getSourceLabel(entry);
const formattedDate = formatDate(entry.timestamp);
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
onRestore(value);
setShowHistory(false);
toast.success(restoreMessage);
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{value.slice(0, 100)}
{value.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export * from './enhancement-constants';
export * from './enhance-with-ai';
export * from './enhancement-history-button';

View File

@@ -1,9 +1,13 @@
export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './profile-quick-select';
export * from './reasoning-effort-selector';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './priority-select';
export * from './branch-selector';
export * from './planning-mode-selector';
export * from './planning-mode-select';
export * from './ancestor-context-section';
export * from './work-mode-selector';
export * from './enhancement';

View File

@@ -1,12 +1,20 @@
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import type { ModelAlias } from '@/store/app-store';
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
export type ModelOption = {
id: AgentModel;
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
label: string;
description: string;
badge?: string;
provider: 'claude';
provider: ModelProvider;
hasThinking?: boolean;
};
export const CLAUDE_MODELS: ModelOption[] = [
@@ -33,6 +41,88 @@ export const CLAUDE_MODELS: ModelOption[] = [
},
];
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: `cursor-${id}`,
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,
hasThinking: config.hasThinking,
})
);
/**
* Codex/OpenAI models
* Official models from https://developers.openai.com/codex/models/
*/
export const CODEX_MODELS: ModelOption[] = [
{
id: CODEX_MODEL_MAP.gpt52Codex,
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering.',
badge: 'Premium',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
label: 'GPT-5.1-Codex-Max',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
badge: 'Premium',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMini,
label: 'GPT-5.1-Codex-Mini',
description: 'Smaller, more cost-effective version for faster workflows.',
badge: 'Speed',
provider: 'codex',
hasThinking: false,
},
{
id: CODEX_MODEL_MAP.gpt52,
label: 'GPT-5.2',
description: 'Best general agentic model for tasks across industries and domains.',
badge: 'Balanced',
provider: 'codex',
hasThinking: true,
},
{
id: CODEX_MODEL_MAP.gpt51,
label: 'GPT-5.1',
description: 'Great for coding and agentic tasks across domains.',
badge: 'Balanced',
provider: 'codex',
hasThinking: true,
},
];
/**
* OpenCode models derived from OPENCODE_MODEL_CONFIGS
*/
export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config) => ({
id: config.id,
label: config.label,
description: config.description,
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
provider: config.provider as ModelProvider,
}));
/**
* All available models (Claude + Cursor + Codex + OpenCode)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
@@ -43,6 +133,28 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
ultrathink: 'Ultra',
};
/**
* Reasoning effort levels for Codex/OpenAI models
* All models support reasoning effort levels
*/
export const REASONING_EFFORT_LEVELS: ReasoningEffort[] = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
];
export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
none: 'None',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
};
// Profile icon mapping
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
@@ -51,4 +163,8 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Cpu,
Rocket,
Sparkles,
Anthropic: AnthropicIcon,
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
};

View File

@@ -1,12 +1,21 @@
// @ts-nocheck
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Brain, AlertTriangle } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { AgentModel } from '@/store/app-store';
import { CLAUDE_MODELS, ModelOption } from './model-constants';
import type { ModelAlias } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface ModelSelectorProps {
selectedModel: AgentModel;
onModelSelect: (model: AgentModel) => void;
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
onModelSelect: (model: string) => void;
testIdPrefix?: string;
}
@@ -15,40 +24,339 @@ export function ModelSelector({
onModelSelect,
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
const {
enabledCursorModels,
cursorDefaultModel,
codexModels,
codexModelsLoading,
codexModelsError,
fetchCodexModels,
} = useAppStore();
const { cursorCliStatus, codexCliStatus } = useSetupStore();
const selectedProvider = getModelProvider(selectedModel);
// Check if Cursor CLI is available
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
// Check if Codex CLI is available
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
// Fetch Codex models on mount
useEffect(() => {
if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) {
fetchCodexModels();
}
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
// Transform codex models from store to ModelOption format
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
// Infer badge based on tier
let badge: string | undefined;
if (model.tier === 'premium') badge = 'Premium';
else if (model.tier === 'basic') badge = 'Speed';
else if (model.tier === 'standard') badge = 'Balanced';
return {
id: model.id,
label: model.label,
description: model.description,
badge,
provider: 'codex' as ModelProvider,
hasThinking: model.hasThinking,
};
});
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
const cursorModelId = stripProviderPrefix(model.id);
return enabledCursorModels.includes(cursorModelId as any);
});
const handleProviderChange = (provider: ModelProvider) => {
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (use isDefault flag from dynamic models)
const defaultModel = codexModels.find((m) => m.isDefault);
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
onModelSelect(defaultModelId);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
}
};
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 className="space-y-4">
{/* Provider Selection */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-claude`}
>
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-cursor`}
>
<CursorIcon className="w-4 h-4" />
Cursor CLI
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-codex`}
>
<OpenAIIcon className="w-4 h-4" />
Codex CLI
</button>
</div>
</div>
{/* Claude Models */}
{selectedProvider === 'claude' && (
<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 Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native SDK
</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>
)}
{/* Cursor Models */}
{selectedProvider === 'cursor' && (
<div className="space-y-3">
{/* Warning when Cursor CLI is not available */}
{!isCursorAvailable && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-400">
Cursor CLI is not installed or authenticated. Configure it in Settings AI
Providers.
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<CursorIcon className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
CLI
</span>
</div>
<div className="flex flex-col gap-2">
{filteredCursorModels.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Cursor models enabled. Enable models in Settings AI Providers.
</div>
) : (
filteredCursorModels.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
<div className="flex gap-1">
{option.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</div>
)}
{/* Codex Models */}
{selectedProvider === 'codex' && (
<div className="space-y-3">
{/* Warning when Codex CLI is not available */}
{!isCodexAvailable && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-400">
Codex CLI is not installed or authenticated. Configure it in Settings AI
Providers.
</div>
</div>
)}
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4 text-primary" />
Codex Model
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-600 dark:text-emerald-400">
CLI
</span>
</div>
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading models...
</div>
)}
{/* Error state */}
{codexModelsError && !codexModelsLoading && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
<div className="space-y-1">
<div className="text-sm text-red-400">Failed to load Codex models</div>
<button
type="button"
onClick={() => fetchCodexModels(true)}
className="text-xs text-red-400 underline hover:no-underline"
>
Retry
</button>
</div>
</div>
)}
{/* Model list */}
{!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Codex models available
</div>
)}
{!codexModelsLoading && dynamicCodexModels.length > 0 && (
<div className="flex flex-col gap-2">
{dynamicCodexModels.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
<div className="flex gap-1">
{option.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
)}
>
Thinking
</Badge>
)}
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { Zap, ClipboardList, FileText, ScrollText } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import type { PlanningMode } from '@automaker/types';
interface PlanningModeSelectProps {
mode: PlanningMode;
onModeChange: (mode: PlanningMode) => void;
requireApproval?: boolean;
onRequireApprovalChange?: (require: boolean) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
/** If true, only renders the dropdown without description or checkbox */
compact?: boolean;
}
const modes = [
{
value: 'skip' as const,
label: 'Skip',
description: 'Direct implementation, no upfront planning',
icon: Zap,
color: 'text-emerald-500',
},
{
value: 'lite' as const,
label: 'Lite',
description: 'Think through approach, create task list',
icon: ClipboardList,
color: 'text-blue-500',
},
{
value: 'spec' as const,
label: 'Spec',
description: 'Generate spec with acceptance criteria',
icon: FileText,
color: 'text-purple-500',
},
{
value: 'full' as const,
label: 'Full',
description: 'Comprehensive spec with phased plan',
icon: ScrollText,
color: 'text-amber-500',
},
];
/**
* PlanningModeSelect - Compact dropdown selector for planning modes
*
* A lightweight alternative to PlanningModeSelector for contexts where
* spec management UI is not needed (e.g., mass edit, bulk operations).
*
* Shows icon + label in dropdown, with description text below.
* Does not include spec generation, approval, or require-approval checkbox.
*
* @example
* ```tsx
* <PlanningModeSelect
* mode={planningMode}
* onModeChange={(mode) => {
* setPlanningMode(mode);
* setRequireApproval(mode === 'spec' || mode === 'full');
* }}
* testIdPrefix="mass-edit-planning"
* />
* ```
*/
export function PlanningModeSelect({
mode,
onModeChange,
requireApproval,
onRequireApprovalChange,
testIdPrefix = 'planning-mode',
className,
disabled = false,
compact = false,
}: PlanningModeSelectProps) {
const selectedMode = modes.find((m) => m.value === mode);
// Disable approval checkbox for skip/lite modes since they don't use planning
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
const selectDropdown = (
<Select
value={mode}
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedMode && (
<div className="flex items-center gap-2">
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
<span>{selectedMode.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{modes.map((m) => {
const Icon = m.icon;
return (
<SelectItem
key={m.value}
value={m.value}
data-testid={`${testIdPrefix}-option-${m.value}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', m.color)} />
<span>{m.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
// Compact mode - just the dropdown
if (compact) {
return <div className={className}>{selectDropdown}</div>;
}
// Full mode with description and optional checkbox
return (
<div className={cn('space-y-2', className)}>
{selectDropdown}
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
{onRequireApprovalChange && (
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`${testIdPrefix}-require-approval`}
checked={requireApproval && !isApprovalDisabled}
onCheckedChange={(checked) => onRequireApprovalChange(!!checked)}
disabled={isApprovalDisabled}
data-testid={`${testIdPrefix}-require-approval-checkbox`}
/>
<Label
htmlFor={`${testIdPrefix}-require-approval`}
className={cn(
'text-sm font-normal',
isApprovalDisabled ? 'cursor-not-allowed text-muted-foreground' : 'cursor-pointer'
)}
>
Require plan approval before execution
</Label>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PrioritySelectProps {
selectedPriority: number;
onPrioritySelect: (priority: number) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
const priorities = [
{
value: 1,
label: 'High',
description: 'Urgent, needs immediate attention',
icon: ChevronUp,
color: 'text-red-500',
bgColor: 'bg-red-500/10',
},
{
value: 2,
label: 'Medium',
description: 'Normal priority, standard workflow',
icon: AlertCircle,
color: 'text-yellow-500',
bgColor: 'bg-yellow-500/10',
},
{
value: 3,
label: 'Low',
description: 'Can wait, not time-sensitive',
icon: ChevronDown,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
];
/**
* PrioritySelect - Compact dropdown selector for feature priority
*
* A lightweight alternative to PrioritySelector for contexts where
* space is limited (e.g., mass edit, bulk operations).
*
* Shows icon + priority level in dropdown, with description below.
*
* @example
* ```tsx
* <PrioritySelect
* selectedPriority={priority}
* onPrioritySelect={setPriority}
* testIdPrefix="mass-edit-priority"
* />
* ```
*/
export function PrioritySelect({
selectedPriority,
onPrioritySelect,
testIdPrefix = 'priority',
className,
disabled = false,
}: PrioritySelectProps) {
const selectedPriorityObj = priorities.find((p) => p.value === selectedPriority);
return (
<div className={cn('space-y-2', className)}>
<Select
value={selectedPriority.toString()}
onValueChange={(value: string) => onPrioritySelect(parseInt(value, 10))}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedPriorityObj && (
<div className="flex items-center gap-2">
<selectedPriorityObj.icon className={cn('h-4 w-4', selectedPriorityObj.color)} />
<span>{selectedPriorityObj.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{priorities.map((p) => {
const Icon = p.icon;
return (
<SelectItem
key={p.value}
value={p.value.toString()}
data-testid={`${testIdPrefix}-option-${p.label.toLowerCase()}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', p.color)} />
<span>{p.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedPriorityObj && (
<p className="text-xs text-muted-foreground">{selectedPriorityObj.description}</p>
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PrioritySelectorProps {
@@ -13,49 +12,46 @@ export function PrioritySelector({
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 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>
);
}

View File

@@ -1,91 +0,0 @@
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>
);
}

View File

@@ -0,0 +1,47 @@
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ReasoningEffort } from '@automaker/types';
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS } from './model-constants';
interface ReasoningEffortSelectorProps {
selectedEffort: ReasoningEffort;
onEffortSelect: (effort: ReasoningEffort) => void;
testIdPrefix?: string;
}
export function ReasoningEffortSelector({
selectedEffort,
onEffortSelect,
testIdPrefix = 'reasoning-effort',
}: ReasoningEffortSelectorProps) {
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" />
Reasoning Effort
</Label>
<div className="flex gap-2 flex-wrap">
{REASONING_EFFORT_LEVELS.map((effort) => (
<button
key={effort}
type="button"
onClick={() => onEffortSelect(effort)}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]',
selectedEffort === effort
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${effort}`}
>
{REASONING_EFFORT_LABELS[effort]}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher efforts give more reasoning tokens for complex problems.
</p>
</div>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { Label } from '@/components/ui/label';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitBranch, GitFork, Pencil } from 'lucide-react';
import { cn } from '@/lib/utils';
export type WorkMode = 'current' | 'auto' | 'custom';
interface WorkModeSelectorProps {
workMode: WorkMode;
onWorkModeChange: (mode: WorkMode) => void;
branchName: string;
onBranchNameChange: (branchName: string) => void;
branchSuggestions: string[];
branchCardCounts?: Record<string, number>;
currentBranch?: string;
disabled?: boolean;
testIdPrefix?: string;
}
const WORK_MODES = [
{
value: 'current' as const,
label: 'Current Branch',
description: 'Work directly on the selected branch',
icon: GitBranch,
},
{
value: 'auto' as const,
label: 'Auto Worktree',
description: 'Create isolated worktree automatically',
icon: GitFork,
},
{
value: 'custom' as const,
label: 'Custom Branch',
description: 'Specify a branch name',
icon: Pencil,
},
];
export function WorkModeSelector({
workMode,
onWorkModeChange,
branchName,
onBranchNameChange,
branchSuggestions,
branchCardCounts,
currentBranch,
disabled = false,
testIdPrefix = 'work-mode',
}: WorkModeSelectorProps) {
const hasError = workMode === 'custom' && !branchName.trim();
return (
<div className="space-y-3">
<Label id={`${testIdPrefix}-label`}>Work Mode</Label>
<div className="grid grid-cols-3 gap-2">
{WORK_MODES.map((mode) => {
const isSelected = workMode === mode.value;
const Icon = mode.icon;
return (
<button
key={mode.value}
type="button"
onClick={() => !disabled && onWorkModeChange(mode.value)}
disabled={disabled}
data-testid={`${testIdPrefix}-${mode.value}`}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-lg cursor-pointer transition-all duration-200',
'border-2 hover:border-primary/50',
isSelected
? 'border-primary bg-primary/10'
: 'border-border/50 bg-card/50 hover:bg-accent/30',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-primary/20' : 'bg-muted'
)}
>
<Icon
className={cn(
'h-4 w-4 transition-colors',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
</div>
<span
className={cn(
'font-medium text-xs text-center',
isSelected ? 'text-foreground' : 'text-muted-foreground'
)}
>
{mode.label}
</span>
</button>
);
})}
</div>
{/* Description text based on selected mode */}
<p className="text-xs text-muted-foreground">
{workMode === 'current' && (
<>
Work will be done directly on{' '}
{currentBranch ? (
<span className="font-medium">{currentBranch}</span>
) : (
'the current branch'
)}
. No isolation.
</>
)}
{workMode === 'auto' && (
<>
A new worktree will be created automatically based on{' '}
{currentBranch ? (
<span className="font-medium">{currentBranch}</span>
) : (
'the current branch'
)}{' '}
when this card is created.
</>
)}
{workMode === 'custom' && (
<>Specify a branch name below. A worktree will be created if it doesn't exist.</>
)}
</p>
{/* Branch input for custom mode */}
{workMode === 'custom' && (
<div className="space-y-1">
<BranchAutocomplete
value={branchName}
onChange={onBranchNameChange}
branches={branchSuggestions}
branchCardCounts={branchCardCounts}
placeholder="Select or create branch..."
data-testid={`${testIdPrefix}-branch-input`}
disabled={disabled}
error={hasError}
/>
{hasError && (
<p className="text-xs text-destructive">
Branch name is required for custom branch mode.
</p>
)}
</div>
)}
{disabled && (
<p className="text-xs text-muted-foreground italic">
Work mode cannot be changed after work has started.
</p>
)}
</div>
);
}

View File

@@ -6,13 +6,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
@@ -21,15 +23,19 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
@@ -41,7 +47,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -50,12 +56,13 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
@@ -76,7 +83,23 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
// Use shared hook for effective default editor
const effectiveDefaultEditor = useEffectiveDefaultEditor(editors);
// Get other editors (excluding the default) for the submenu
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
// Get icon component for the effective editor (avoid IIFE in JSX)
const DefaultEditorIcon = effectiveDefaultEditor
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
@@ -200,10 +223,60 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default editor */}
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
{DefaultEditorIcon && <DefaultEditorIcon className="w-3.5 h-3.5 mr-2" />}
Open in {effectiveDefaultEditor.name}
</DropdownMenuItem>
{/* Chevron trigger for submenu with other editors and Copy Path */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Other editors */}
{otherEditors.map((editor) => {
const EditorIcon = getEditorIcon(editor.command);
return (
<DropdownMenuItem
key={editor.command}
onClick={() => onOpenInEditor(worktree, editor.command)}
className="text-xs"
>
<EditorIcon className="w-3.5 h-3.5 mr-2" />
{editor.name}
</DropdownMenuItem>
);
})}
{otherEditors.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(worktree.path);
toast.success('Path copied to clipboard');
} catch {
toast.error('Failed to copy path to clipboard');
}
}}
className="text-xs"
>
<Copy className="w-3.5 h-3.5 mr-2" />
Copy Path
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -16,7 +17,6 @@ interface WorktreeTabProps {
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
@@ -36,7 +36,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -45,6 +45,8 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeTab({
@@ -57,7 +59,6 @@ export function WorktreeTab({
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
@@ -86,6 +87,8 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -314,7 +317,6 @@ export function WorktreeTab({
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
@@ -335,6 +337,8 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>
</div>
);

View File

@@ -2,5 +2,5 @@ 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';
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
export type { EditorInfo };
export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableEditors) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
}
} catch (error) {
logger.error('Failed to fetch available editors:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
return {
editors,
isLoading,
isRefreshing,
refresh,
// Convenience property: has multiple editors (for deciding whether to show submenu)
hasMultipleEditors: editors.length > 1,
// The first editor is the "default" one
defaultEditor: editors[0] ?? null,
};
}
/**
* Hook to get the effective default editor based on user settings
* Falls back to: Cursor > VS Code > first available editor
*/
export function useEffectiveDefaultEditor(editors: EditorInfo[]): EditorInfo | null {
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
return useMemo(() => {
if (editors.length === 0) return null;
// If user has a saved preference and it exists in available editors, use it
if (defaultEditorCommand) {
const found = editors.find((e) => e.command === defaultEditorCommand);
if (found) return found;
}
// Auto-detect: prefer Cursor, then VS Code, then first available
const cursor = editors.find((e) => e.command === 'cursor');
if (cursor) return cursor;
const vscode = editors.find((e) => e.command === 'code');
if (vscode) return vscode;
return editors[0];
}, [editors, defaultEditorCommand]);
}

View File

@@ -1,7 +1,10 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo, GitRepoStatus } from '../types';
const logger = createLogger('Branches');
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
@@ -26,7 +29,7 @@ export function useBranches() {
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn('List branches API not available');
logger.warn('List branches API not available');
return;
}
const result = await api.worktree.listBranches(worktreePath);
@@ -45,11 +48,11 @@ export function useBranches() {
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
} else if (!result.success) {
// Other errors - log them
console.warn('Failed to fetch branches:', result.error);
logger.warn('Failed to fetch branches:', result.error);
resetBranchState();
}
} catch (error) {
console.error('Failed to fetch branches:', error);
logger.error('Failed to fetch branches:', error);
resetBranchState();
// Reset git status to unknown state on network/API errors
setGitRepoStatus({ isGitRepo: true, hasCommits: true });

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
const logger = createLogger('DefaultEditor');
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>('Editor');
@@ -15,7 +18,7 @@ export function useDefaultEditor() {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error('Failed to fetch default editor:', error);
logger.error('Failed to fetch default editor:', error);
}
}, []);

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils';
import { toast } from 'sonner';
import type { DevServerInfo, WorktreeInfo } from '../types';
const logger = createLogger('DevServers');
interface UseDevServersOptions {
projectPath: string;
}
@@ -27,7 +30,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers(serversMap);
}
} catch (error) {
console.error('Failed to fetch dev servers:', error);
logger.error('Failed to fetch dev servers:', error);
}
}, []);
@@ -73,7 +76,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
toast.error(result.error || 'Failed to start dev server');
}
} catch (error) {
console.error('Start dev server failed:', error);
logger.error('Start dev server failed:', error);
toast.error('Failed to start dev server');
} finally {
setIsStartingDevServer(false);
@@ -105,7 +108,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
toast.error(result.error || 'Failed to stop dev server');
}
} catch (error) {
console.error('Stop dev server failed:', error);
logger.error('Stop dev server failed:', error);
toast.error('Failed to stop dev server');
}
},

View File

@@ -1,8 +1,11 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
// Error codes that need special user-friendly handling
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
@@ -56,7 +59,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to switch branch');
}
} catch (error) {
console.error('Switch branch failed:', error);
logger.error('Switch branch failed:', error);
toast.error('Failed to switch branch');
} finally {
setIsSwitching(false);
@@ -84,7 +87,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to pull latest changes');
}
} catch (error) {
console.error('Pull failed:', error);
logger.error('Pull failed:', error);
toast.error('Failed to pull latest changes');
} finally {
setIsPulling(false);
@@ -113,7 +116,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
console.error('Push failed:', error);
logger.error('Push failed:', error);
toast.error('Failed to push changes');
} finally {
setIsPushing(false);
@@ -122,21 +125,21 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn('Open in editor API not available');
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path);
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
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);
logger.error('Open in editor failed:', error);
}
}, []);

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
const logger = createLogger('Worktrees');
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
@@ -33,7 +36,7 @@ export function useWorktrees({
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn('Worktree API not available');
logger.warn('Worktree API not available');
return;
}
const result = await api.worktree.listAll(projectPath, true);
@@ -44,7 +47,7 @@ export function useWorktrees({
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error('Failed to fetch worktrees:', error);
logger.error('Failed to fetch worktrees:', error);
return undefined;
} finally {
if (!silent) {

View File

@@ -1,21 +1,19 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -78,24 +76,32 @@ export function WorktreePanel({
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
features,
});
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === 'true';
});
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
useEffect(() => {
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
if (!projectPath) {
setHasInitScript(false);
return;
}
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
@@ -112,18 +118,6 @@ export function WorktreePanel({
};
}, [fetchWorktrees]);
// Get the currently selected worktree for collapsed view
const selectedWorktree = worktrees.find((w) => {
if (
currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
) {
return w.isMain;
}
return pathsEqual(w.path, currentWorktreePath);
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
@@ -143,47 +137,38 @@ export function WorktreePanel({
}
};
const handleRunInitScript = useCallback(
async (worktree: WorktreeInfo) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.runInitScript(
projectPath,
worktree.path,
worktree.branch
);
if (!result.success) {
toast.error('Failed to run init script', {
description: result.error,
});
}
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
} catch (error) {
toast.error('Failed to run init script', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[projectPath]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
if (isCollapsed) {
return (
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">{selectedWorktree?.branch ?? 'main'}</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
</div>
);
}
// Expanded view - full worktree panel
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
@@ -200,7 +185,6 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -229,6 +213,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
</div>
@@ -255,7 +241,6 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -284,6 +269,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
);
})}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { cn } from '@/lib/utils';
const logger = createLogger('CodeView');
interface FileTreeNode {
name: string;
path: string;
@@ -60,7 +63,7 @@ export function CodeView() {
setFileTree(entries);
}
} catch (error) {
console.error('Failed to load file tree:', error);
logger.error('Failed to load file tree:', error);
} finally {
setIsLoading(false);
}
@@ -91,7 +94,7 @@ export function CodeView() {
}));
}
} catch (error) {
console.error('Failed to load subdirectory:', error);
logger.error('Failed to load subdirectory:', error);
}
return [];
};
@@ -107,7 +110,7 @@ export function CodeView() {
setSelectedFile(path);
}
} catch (error) {
console.error('Failed to load file:', error);
logger.error('Failed to load file:', error);
}
};

Some files were not shown because too many files have changed in this diff Show More