mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal file
4
apps/ui/src/components/views/agent-view/hooks/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
2
apps/ui/src/components/views/agent-view/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgentModelSelector } from './agent-model-selector';
|
||||
export * from './constants';
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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.**`,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './enhancement-constants';
|
||||
export * from './enhance-with-ai';
|
||||
export * from './enhancement-history-button';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user